PersistenceUtils.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2017 wcm.io
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */
package io.wcm.caconfig.extensions.persistence.impl;

import static com.day.cq.commons.jcr.JcrConstants.JCR_CONTENT;
import static org.apache.sling.api.resource.ResourceResolver.PROPERTY_RESOURCE_TYPE;

import java.util.Calendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.ModifiableValueMap;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.api.resource.ResourceUtil;
import org.apache.sling.caconfig.management.ConfigurationManagementSettings;
import org.apache.sling.caconfig.spi.ConfigurationCollectionPersistData;
import org.apache.sling.caconfig.spi.ConfigurationPersistData;
import org.apache.sling.caconfig.spi.ConfigurationPersistenceAccessDeniedException;
import org.apache.sling.caconfig.spi.ConfigurationPersistenceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.day.cq.commons.jcr.JcrConstants;
import com.day.cq.wcm.api.NameConstants;
import com.day.cq.wcm.api.Page;
import com.day.cq.wcm.api.PageManager;
import com.day.cq.wcm.api.WCMException;

final class PersistenceUtils {

  private static final String DEFAULT_FOLDER_NODE_TYPE = "sling:Folder";
  private static final String DEFAULT_FOLDER_NODE_TYPE_IN_PAGE = JcrConstants.NT_UNSTRUCTURED;
  private static final Pattern PAGE_PATH_PATTERN = Pattern.compile("^(.*?)/" + Pattern.quote(JCR_CONTENT) + "(/.*)?$");
  private static final Pattern JCR_CONTENT_PATTERN = Pattern.compile("^(.*/)?" + Pattern.quote(JCR_CONTENT) + "(/.*)?$");

  private static final Logger log = LoggerFactory.getLogger(PersistenceUtils.class);

  private PersistenceUtils() {
    // static methods only
  }

  public static boolean containsJcrContent(String path) {
    if (path == null) {
      return false;
    }
    return JCR_CONTENT_PATTERN.matcher(path).matches();
  }

  /**
   * Ensure that a containing page exists for the given path inside a content page.
   * If no containing page exists a page is created with the path before /jcr:content/*.
   * If the path does not contain /jcr:content nothing is done.
   * @param resolver Resource resolver
   * @param configResourcePath Configuration resource path
   * @param resourceType Resource type for page (if not template is set)
   * @param configurationManagementSettings Configuration management settings
   */
  public static void ensureContainingPage(ResourceResolver resolver, String configResourcePath,
      String resourceType, ConfigurationManagementSettings configurationManagementSettings) {
    ensureContainingPage(resolver, configResourcePath, null, resourceType, null, configurationManagementSettings);
  }

  /**
   * Ensure that a containing page exists for the given path inside a content page.
   * If no containing page exists a page is created with the path before /jcr:content/*.
   * If the path does not contain /jcr:content nothing is done.
   * @param resolver Resource resolver
   * @param configResourcePath Configuration resource path
   * @param template Template for page
   * @param resourceType Resource type for page (if not template is set)
   * @param parentTemplate Template for parent/intermediate pages
   * @param configurationManagementSettings Configuration management settings
   */
  @SuppressWarnings("PMD.UseObjectForClearerAPI")
  public static void ensureContainingPage(ResourceResolver resolver, String configResourcePath,
      String template, String resourceType, String parentTemplate,
      ConfigurationManagementSettings configurationManagementSettings) {
    Matcher matcher = PAGE_PATH_PATTERN.matcher(configResourcePath);
    if (!matcher.matches()) {
      return;
    }
    String pagePath = matcher.group(1);
    ensurePage(resolver, pagePath, template, resourceType, parentTemplate, configurationManagementSettings);
  }

  /**
   * Ensure that a page at the given path exists, if the path is not already contained in a page.
   * @param resolver Resource resolver
   * @param pagePath Page path
   * @param resourceType Resource type for page (if not template is set)
   * @param configurationManagementSettings Configuration management settings
   * @return Resource for AEM page or resource inside a page.
   */
  public static Resource ensurePageIfNotContainingPage(ResourceResolver resolver, String pagePath,
      String resourceType, ConfigurationManagementSettings configurationManagementSettings) {
    Matcher matcher = PAGE_PATH_PATTERN.matcher(pagePath);
    if (matcher.matches()) {
      // ensure that shorted path part that ends with /jcr:content is created as AEM page (if not existent already)
      String detectedPagePath = matcher.group(1);
      ensurePage(resolver, detectedPagePath, null, resourceType, null, configurationManagementSettings);
      return getOrCreateResource(resolver, pagePath, DEFAULT_FOLDER_NODE_TYPE_IN_PAGE, null, configurationManagementSettings);
    }
    return ensurePage(resolver, pagePath, null, resourceType, null, configurationManagementSettings);
  }

  private static Resource ensurePage(ResourceResolver resolver, String pagePath,
      String template, String resourceType, String parentTemplate,
      ConfigurationManagementSettings configurationManagementSettings) {
    // check if page or resource already exists
    Resource resource = resolver.getResource(pagePath);
    if (resource != null) {
      return resource;
    }

    // ensure parent page or resource exists
    String parentPath = ResourceUtil.getParent(pagePath);
    String pageName = ResourceUtil.getName(pagePath);
    Resource parentResource;
    if (StringUtils.isNotEmpty(parentTemplate)) {
      parentResource = ensurePage(resolver, parentPath, parentTemplate, null, parentTemplate, configurationManagementSettings);
    }
    else {
      parentResource = getOrCreateResource(resolver, parentPath, DEFAULT_FOLDER_NODE_TYPE, null, configurationManagementSettings);
    }

    // create page
    return createPage(resolver, parentResource, pageName, template, resourceType);
  }

  private static Resource createPage(ResourceResolver resolver, Resource parentResource, String pageName,
      String template, String resourceType) {
    String pagePath = parentResource.getPath() + "/" + pageName;
    log.trace("! Create cq:Page node at {}", pagePath);
    try {
      // create page directly via Sling API instead of PageManager because page name may contain dots (.)
      Map<String, Object> props = new HashMap<>();
      props.put(JcrConstants.JCR_PRIMARYTYPE, NameConstants.NT_PAGE);
      Resource pageResource = resolver.create(parentResource, pageName, props);

      // create jcr:content node
      props = new HashMap<>();
      props.put(JcrConstants.JCR_PRIMARYTYPE, "cq:PageContent");
      if (StringUtils.isNotEmpty(template)) {
        applyPageTemplate(resolver, props, pageName, template);
      }
      if (StringUtils.isNotEmpty(resourceType) && props.get(PROPERTY_RESOURCE_TYPE) == null) {
        props.put(PROPERTY_RESOURCE_TYPE, resourceType);
      }
      resolver.create(pageResource, JCR_CONTENT, props);

      return pageResource;
    }
    catch (PersistenceException ex) {
      throw convertPersistenceException("Unable to create config page at " + pagePath, ex);
    }
  }

  private static void applyPageTemplate(ResourceResolver resolver, Map<String, Object> props, String pageName, String template) {
    // set template
    props.put(NameConstants.PN_TEMPLATE, template);

    // also set title for author when template is set
    props.put(JcrConstants.JCR_TITLE, pageName);

    // get sling:resourceType from template definition
    Resource templateContentResource = resolver.getResource(template + "/" + JCR_CONTENT);
    if (templateContentResource != null) {
      props.put(PROPERTY_RESOURCE_TYPE, templateContentResource.getValueMap().get(PROPERTY_RESOURCE_TYPE, String.class));
    }
  }

  public static Resource getOrCreateResource(ResourceResolver resolver, String path, String defaultNodeType, Map<String, Object> properties,
      ConfigurationManagementSettings configurationManagementSettings) {
    try {
      Resource resource = ResourceUtil.getOrCreateResource(resolver, path, defaultNodeType, defaultNodeType, false);
      if (properties != null) {
        replaceProperties(resource, properties, configurationManagementSettings);
      }
      return resource;
    }
    catch (PersistenceException ex) {
      throw convertPersistenceException("Unable to create resource at " + path, ex);
    }
  }

  /**
   * Delete children that are no longer contained in list of collection items.
   * @param resource Parent resource
   * @param data List of collection items
   */
  public static void deleteChildrenNotInCollection(Resource resource, ConfigurationCollectionPersistData data) {
    Set<String> collectionItemNames = data.getItems().stream()
        .map(ConfigurationPersistData::getCollectionItemName)
        .collect(Collectors.toSet());

    for (Resource child : resource.getChildren()) {
      if (!collectionItemNames.contains(child.getName()) && !StringUtils.equals(JCR_CONTENT, child.getName())) {
        deletePageOrResource(child);
      }
    }
  }

  public static void replaceProperties(Resource resource, Map<String, Object> properties,
      ConfigurationManagementSettings configurationManagementSettings) {
    if (log.isTraceEnabled()) {
      log.trace("! Store properties for resource {}: {}", resource.getPath(), properties);
    }
    ModifiableValueMap modValueMap = resource.adaptTo(ModifiableValueMap.class);
    if (modValueMap == null) {
      throw new ConfigurationPersistenceAccessDeniedException("No write access: Unable to store configuration data to " + resource.getPath() + ".");
    }

    // remove all existing properties that are not filtered
    Set<String> propertyNamesToRemove = new HashSet<>(modValueMap.keySet());
    PropertiesFilterUtil.removeIgnoredProperties(propertyNamesToRemove, configurationManagementSettings);
    for (String propertyName : propertyNamesToRemove) {
      modValueMap.remove(propertyName);
    }

    modValueMap.putAll(properties);
  }

  public static void updatePageLastMod(ResourceResolver resolver, PageManager pageManager, String configResourcePath) {
    Page page = pageManager.getContainingPage(configResourcePath);
    if (page == null) {
      return;
    }
    Resource contentResource = page.getContentResource();
    if (contentResource != null) {
      ModifiableValueMap contentProps = contentResource.adaptTo(ModifiableValueMap.class);
      if (contentProps == null) {
        throw new ConfigurationPersistenceAccessDeniedException("No write access: Unable to update page " + configResourcePath + ".");
      }

      Object user = resolver.getAttribute(ResourceResolverFactory.USER);
      Calendar now = Calendar.getInstance();

      contentProps.put(NameConstants.PN_LAST_MOD, now);
      contentProps.put(NameConstants.PN_LAST_MOD_BY, user);

      // check if resource has cq:lastModified because it is created in site admin
      if (contentProps.containsKey(NameConstants.PN_PAGE_LAST_MOD)) {
        contentProps.put(NameConstants.PN_PAGE_LAST_MOD, now);
        contentProps.put(NameConstants.PN_PAGE_LAST_MOD_BY, user);
      }
    }
  }

  public static void commit(ResourceResolver resourceResolver, String relatedResourcePath) {
    try {
      resourceResolver.commit();
    }
    catch (PersistenceException ex) {
      throw convertPersistenceException("Unable to persist configuration changes to " + relatedResourcePath, ex);
    }
  }

  /**
   * Checks if the given item is modified or newly added by comparing its properties with the current state of the resource.
   *
   * @param resolver     The ResourceResolver to access the resource.
   * @param resourcePath The path of the resource to compare against.
   * @param item         The ConfigurationPersistData item containing the properties to compare.
   * @param settings     The ConfigurationManagementSettings to determine which properties to ignore.
   * @return true if the resource does not exist or if any property value differs, false otherwise.
   */
  public static boolean isItemModifiedOrNewlyAdded(ResourceResolver resolver, String resourcePath, ConfigurationPersistData item, ConfigurationManagementSettings settings) {
    Resource resource = resolver.getResource(resourcePath);
    if (resource == null) {
      return true; // Resource does not exist, so it is considered modified
    }

    Map<String, Object> currentProperties = new HashMap<>(resource.getValueMap());
    Map<String, Object> newProperties = new HashMap<>(item.getProperties());

    // Filter out ignored properties
    PropertiesFilterUtil.removeIgnoredProperties(currentProperties, settings);
    PropertiesFilterUtil.removeIgnoredProperties(newProperties, settings);

    return !currentProperties.equals(newProperties);
  }

  /**
   * If the given resource points to an AEM page, delete the page using PageManager.
   * Otherwise delete the resource using ResourceResolver.
   * @param resource Resource to delete
   */
  public static void deletePageOrResource(Resource resource) {
    Page configPage = resource.adaptTo(Page.class);
    if (configPage != null) {
      try {
        log.trace("! Delete page {}", configPage.getPath());
        PageManager pageManager = configPage.getPageManager();
        pageManager.delete(configPage, false);
      }
      catch (WCMException ex) {
        throw convertWCMException("Unable to delete configuration page at " + resource.getPath(), ex);
      }
    }
    else {
      try {
        log.trace("! Delete resource {}", resource.getPath());
        resource.getResourceResolver().delete(resource);
      }
      catch (PersistenceException ex) {
        throw convertPersistenceException("Unable to delete configuration resource at " + resource.getPath(), ex);
      }
    }
  }

  private static ConfigurationPersistenceException convertWCMException(String message, WCMException ex) {
    String causeClsName = ex.getCause().getClass().getName();
    if (StringUtils.equals(causeClsName, "com.day.cq.replication.AccessDeniedException")
        || StringUtils.equals(causeClsName, "javax.jcr.AccessDeniedException")) {
      return new ConfigurationPersistenceAccessDeniedException("No write access: " + message, ex);
    }
    return new ConfigurationPersistenceException(message, ex);
  }

  private static ConfigurationPersistenceException convertPersistenceException(String message, PersistenceException ex) {
    if (StringUtils.equals(ex.getCause().getClass().getName(), "javax.jcr.AccessDeniedException")) {
      // detect if commit failed due to read-only access to repository
      return new ConfigurationPersistenceAccessDeniedException("No write access: " + message, ex);
    }
    return new ConfigurationPersistenceException(message, ex);
  }

}