ToolsConfigPagePersistenceStrategy.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.NT_UNSTRUCTURED;
import static io.wcm.caconfig.extensions.persistence.impl.PersistenceUtils.commit;
import static io.wcm.caconfig.extensions.persistence.impl.PersistenceUtils.deleteChildrenNotInCollection;
import static io.wcm.caconfig.extensions.persistence.impl.PersistenceUtils.deletePageOrResource;
import static io.wcm.caconfig.extensions.persistence.impl.PersistenceUtils.ensureContainingPage;
import static io.wcm.caconfig.extensions.persistence.impl.PersistenceUtils.getOrCreateResource;
import static io.wcm.caconfig.extensions.persistence.impl.PersistenceUtils.replaceProperties;
import static io.wcm.caconfig.extensions.persistence.impl.PersistenceUtils.updatePageLastMod;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.apache.commons.collections4.IteratorUtils;
import org.apache.commons.collections4.PredicateUtils;
import org.apache.commons.collections4.iterators.FilterIterator;
import org.apache.commons.collections4.iterators.TransformIterator;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceUtil;
import org.apache.sling.api.resource.ValueMap;
import org.apache.sling.caconfig.management.ConfigurationManagementSettings;
import org.apache.sling.caconfig.management.multiplexer.ContextPathStrategyMultiplexer;
import org.apache.sling.caconfig.resource.spi.ConfigurationResourceResolvingStrategy;
import org.apache.sling.caconfig.resource.spi.ContextResource;
import org.apache.sling.caconfig.spi.ConfigurationCollectionPersistData;
import org.apache.sling.caconfig.spi.ConfigurationPersistData;
import org.apache.sling.caconfig.spi.ConfigurationPersistenceStrategy2;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.Designate;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.day.cq.wcm.api.PageManager;
import com.day.cq.wcm.api.PageManagerFactory;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
/**
* AEM-specific persistence strategy that gets only active if a context path is redirected to path
* <code>/content/.../tools/config</code>.
* In this case the configuration date is stored in a single page at /tools/config which can be easily activated by
* editors via the authoring GUI, and the configuration can neatly be packaged together with the content.
*/
@Component(service = { ConfigurationPersistenceStrategy2.class, ConfigurationResourceResolvingStrategy.class })
@Designate(ocd = ToolsConfigPagePersistenceStrategy.Config.class)
public class ToolsConfigPagePersistenceStrategy implements ConfigurationPersistenceStrategy2, ConfigurationResourceResolvingStrategy {
@ObjectClassDefinition(name = "wcm.io Context-Aware Configuration Persistence Strategy: Tools Config Page",
description = "Stores Context-Aware Configuration in a single AEM content page at /tools/config.")
@interface Config {
@AttributeDefinition(name = "Enabled",
description = "Enable this persistence strategy.")
boolean enabled() default false;
@AttributeDefinition(name = "Config Template",
description = "Template that is used for a configuration page.")
String configPageTemplate();
@AttributeDefinition(name = "Structure Template",
description = "Template that is used for the tools page.")
String structurePageTemplate();
@AttributeDefinition(name = "Service Ranking",
description = "Priority of persistence strategy (higher = higher priority).")
int service_ranking() default 2000;
@AttributeDefinition(name = "Relative config path",
description = "Relative path to the configuration page content.")
String relativeConfigPath() default "/tools/config/jcr:content";
@AttributeDefinition(name = "Context path allow list",
description = "Expression to match context paths. Context paths matching this expression are allowed.")
String contextPathRegex() default "^/content(/.+)$";
}
private static final String DEFAULT_CONFIG_NODE_TYPE = NT_UNSTRUCTURED;
private static final String PROPERTY_CONFIG_COLLECTION_INHERIT = "sling:configCollectionInherit";
private static final Logger log = LoggerFactory.getLogger(ToolsConfigPagePersistenceStrategy.class);
private boolean enabled;
private Pattern configPathPattern;
private Pattern contextPathPattern;
private Config config;
@Reference
private ContextPathStrategyMultiplexer contextPathStrategy;
@Reference
private ConfigurationManagementSettings configurationManagementSettings;
@Reference
private PageManagerFactory pageManagerFactory;
// --- ConfigurationPersitenceStrategy ---
@Activate
void activate(Config value) {
this.enabled = value.enabled();
this.configPathPattern = loadConfigPathPattern(value);
this.contextPathPattern = loadContextPathPattern(value);
this.config = value;
}
private @Nullable Pattern loadConfigPathPattern(Config value) {
String relativeConfigPath = value.relativeConfigPath();
return enabled && StringUtils.isNotBlank(relativeConfigPath)
? Pattern.compile(String.format("^.*%s(/.*)?$", relativeConfigPath))
: null;
}
private @Nullable Pattern loadContextPathPattern(Config value) {
String contextPathRegex = value.contextPathRegex();
return enabled && StringUtils.isNotBlank(contextPathRegex)
? Pattern.compile(contextPathRegex)
: null;
}
@Override
public Resource getResource(@NotNull Resource resource) {
if (!enabled || !isConfigPagePath(resource.getPath())) {
return null;
}
return resource;
}
@Override
public Resource getCollectionParentResource(@NotNull Resource resource) {
return getResource(resource);
}
@Override
public Resource getCollectionItemResource(@NotNull Resource resource) {
return getResource(resource);
}
@Override
public String getResourcePath(@NotNull String resourcePath) {
if (!enabled || !isConfigPagePath(resourcePath)) {
return null;
}
return resourcePath;
}
@Override
public String getCollectionParentResourcePath(@NotNull String resourcePath) {
return getResourcePath(resourcePath);
}
@Override
public String getCollectionItemResourcePath(@NotNull String resourcePath) {
return getResourcePath(resourcePath);
}
@Override
public String getConfigName(@NotNull String configName, @Nullable String relatedConfigPath) {
if (!enabled || (relatedConfigPath != null && !isConfigPagePath(relatedConfigPath))) {
return null;
}
return configName;
}
@Override
public String getCollectionParentConfigName(@NotNull String configName, @Nullable String relatedConfigPath) {
return getConfigName(configName, relatedConfigPath);
}
@Override
public String getCollectionItemConfigName(@NotNull String configName, @Nullable String relatedConfigPath) {
return getConfigName(configName, relatedConfigPath);
}
@Override
@SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
public boolean persistConfiguration(@NotNull ResourceResolver resolver, @NotNull String configResourcePath,
@NotNull ConfigurationPersistData data) {
if (!enabled || !isConfigPagePath(configResourcePath)) {
return false;
}
String path = getResourcePath(configResourcePath);
ensureContainingPage(resolver, path, config.configPageTemplate(), null, config.structurePageTemplate(), configurationManagementSettings);
getOrCreateResource(resolver, path, DEFAULT_CONFIG_NODE_TYPE, data.getProperties(), configurationManagementSettings);
PageManager pageManager = pageManagerFactory.getPageManager(resolver);
updatePageLastMod(resolver, pageManager, path);
commit(resolver, configResourcePath);
return true;
}
@Override
public boolean persistConfigurationCollection(@NotNull ResourceResolver resolver, @NotNull String configResourceCollectionParentPath,
@NotNull ConfigurationCollectionPersistData data) {
if (!enabled || !isConfigPagePath(configResourceCollectionParentPath)) {
return false;
}
ensureContainingPage(resolver, configResourceCollectionParentPath, config.configPageTemplate(), null, config.structurePageTemplate(),
configurationManagementSettings);
Resource configResourceParent = getOrCreateResource(resolver, configResourceCollectionParentPath, DEFAULT_CONFIG_NODE_TYPE, ValueMap.EMPTY,
configurationManagementSettings);
// delete existing children no longer in the list
deleteChildrenNotInCollection(configResourceParent, data);
for (ConfigurationPersistData item : data.getItems()) {
String path = configResourceParent.getPath() + "/" + item.getCollectionItemName();
getOrCreateResource(resolver, path, DEFAULT_CONFIG_NODE_TYPE, item.getProperties(), configurationManagementSettings);
}
// if resource collection parent properties are given replace them as well
if (data.getProperties() != null) {
replaceProperties(configResourceParent, data.getProperties(), configurationManagementSettings);
}
PageManager pageManager = pageManagerFactory.getPageManager(resolver);
updatePageLastMod(resolver, pageManager, configResourceCollectionParentPath);
commit(resolver, configResourceCollectionParentPath);
return true;
}
@Override
public boolean deleteConfiguration(@NotNull ResourceResolver resolver, @NotNull String configResourcePath) {
if (!enabled || !isConfigPagePath(configResourcePath)) {
return false;
}
Resource resource = resolver.getResource(configResourcePath);
if (resource != null) {
deletePageOrResource(resource);
}
PageManager pageManager = pageManagerFactory.getPageManager(resolver);
updatePageLastMod(resolver, pageManager, configResourcePath);
commit(resolver, configResourcePath);
return true;
}
private boolean isConfigPagePath(String configPath) {
return configPathPattern != null && configPathPattern.matcher(configPath).matches();
}
// --- ConfigurationResourceResolvingStrategy ---
/**
* Searches the resource hierarchy upwards for all config references and returns them.
*/
private Iterator<String> findConfigRefs(@NotNull final Resource startResource, @NotNull final Collection<String> bucketNames) {
// collect all context path resources (but filter out those without config reference)
final Iterator<ContextResource> contextResources = new FilterIterator<>(contextPathStrategy.findContextResources(startResource),
contextResource -> StringUtils.isNotBlank(contextResource.getConfigRef()));
// get config resource path for each context resource, filter out items where not reference could be resolved
final Iterator<String> configPaths = new TransformIterator<>(contextResources,
contextResource -> {
String val = checkPath(contextResource, contextResource.getConfigRef(), bucketNames);
if (val != null) {
log.trace("+ Found reference for context path {}: {}", contextResource.getResource().getPath(), val);
}
return val;
});
return new FilterIterator<>(configPaths, PredicateUtils.notNullPredicate());
}
private String checkPath(final ContextResource contextResource, final String checkRef, final Collection<String> bucketNames) {
// combine full path if relativeRef is present
String ref = ResourceUtil.normalize(checkRef);
for (String bucketName : bucketNames) {
String notAllowedPostfix = "/" + bucketName;
if (ref != null && ref.endsWith(notAllowedPostfix)) {
log.debug("Ignoring reference to {} from {} - Probably misconfigured as it ends with '{}'",
contextResource.getConfigRef(), contextResource.getResource().getPath(), notAllowedPostfix);
ref = null;
}
}
return ref;
}
@SuppressWarnings("unused")
private boolean isEnabledAndParamsValid(final Resource contentResource, final Collection<String> bucketNames, final String configName) {
return enabled && contentResource != null && isContextPathAllowed(contentResource.getPath());
}
private boolean isContextPathAllowed(String contextPath) {
return contextPathPattern == null || contextPathPattern.matcher(contextPath).matches();
}
private String buildResourcePath(String path, String name) {
return ResourceUtil.normalize(path + "/" + name);
}
@Override
public Resource getResource(@NotNull final Resource contentResource, @NotNull final Collection<String> bucketNames, @NotNull final String configName) {
Iterator<Resource> resources = getResourceInheritanceChain(contentResource, bucketNames, configName);
if (resources != null && resources.hasNext()) {
return resources.next();
}
return null;
}
private Iterator<Resource> getResourceInheritanceChainInternal(final Collection<String> bucketNames, final String configName,
final Iterator<String> paths, final ResourceResolver resourceResolver) {
// find all matching items among all configured paths
Iterator<Resource> matchingResources = IteratorUtils.transformedIterator(paths,
path -> {
for (String bucketName : bucketNames) {
final String name = bucketName + "/" + configName;
final String configPath = buildResourcePath(path, name);
Resource resource = resourceResolver.getResource(configPath);
if (resource != null) {
log.trace("+ Found matching config resource for inheritance chain: {}", configPath);
return resource;
}
else {
log.trace("- No matching config resource for inheritance chain: {}", configPath);
}
}
return null;
});
Iterator<Resource> result = IteratorUtils.filteredIterator(matchingResources, PredicateUtils.notNullPredicate());
if (result.hasNext()) {
return result;
}
return null;
}
@Override
public Iterator<Resource> getResourceInheritanceChain(@NotNull Resource contentResource, @NotNull Collection<String> bucketNames,
@NotNull String configName) {
if (!isEnabledAndParamsValid(contentResource, bucketNames, configName)) {
return null;
}
final ResourceResolver resourceResolver = contentResource.getResourceResolver();
Iterator<String> paths = findConfigRefs(contentResource, bucketNames);
return getResourceInheritanceChainInternal(bucketNames, configName, paths, resourceResolver);
}
private Collection<Resource> getResourceCollectionInternal(final Collection<String> bucketNames, final String configName,
Iterator<String> paths, ResourceResolver resourceResolver) {
final Map<String, Resource> result = new LinkedHashMap<>();
boolean inherit = false;
while (paths.hasNext()) {
final String path = paths.next();
Resource item = null;
for (String bucketName : bucketNames) {
String name = bucketName + "/" + configName;
String configPath = buildResourcePath(path, name);
item = resourceResolver.getResource(configPath);
if (item != null) {
break;
}
else {
log.trace("- No collection parent resource found: {}", configPath);
}
}
if (item != null) {
log.trace("o Check children of collection parent resource: {}", item.getPath());
if (item.hasChildren()) {
for (Resource child : item.getChildren()) {
if (isValidResourceCollectionItem(child)
&& !result.containsKey(child.getName())) {
log.trace("+ Found collection resource item {}", child.getPath());
result.put(child.getName(), child);
}
}
}
// check collection inheritance mode on current level - should we check on next-highest level as well?
final ValueMap valueMap = item.getValueMap();
inherit = valueMap.get(PROPERTY_CONFIG_COLLECTION_INHERIT, false);
if (!inherit) {
break;
}
}
}
return result.values();
}
@Override
@SuppressWarnings("java:S1168")
public Collection<Resource> getResourceCollection(@NotNull final Resource contentResource, @NotNull final Collection<String> bucketNames,
@NotNull final String configName) {
if (!isEnabledAndParamsValid(contentResource, bucketNames, configName)) {
return null;
}
Iterator<String> paths = findConfigRefs(contentResource, bucketNames);
Collection<Resource> result = getResourceCollectionInternal(bucketNames, configName, paths, contentResource.getResourceResolver());
if (!result.isEmpty()) {
return result;
}
else {
return null;
}
}
@Override
@SuppressWarnings("java:S1168")
public Collection<Iterator<Resource>> getResourceCollectionInheritanceChain(@NotNull final Resource contentResource,
@NotNull final Collection<String> bucketNames, @NotNull final String configName) {
if (!isEnabledAndParamsValid(contentResource, bucketNames, configName)) {
return null;
}
final ResourceResolver resourceResolver = contentResource.getResourceResolver();
final List<String> paths = IteratorUtils.toList(findConfigRefs(contentResource, bucketNames));
// get resource collection with respect to collection inheritance
Collection<Resource> resourceCollection = getResourceCollectionInternal(bucketNames, configName, paths.iterator(), resourceResolver);
// get inheritance chain for each item found
// yes, this resolves the closest item twice, but is the easiest solution to combine both logic aspects
Iterator<Iterator<Resource>> result = IteratorUtils.transformedIterator(resourceCollection.iterator(),
item -> getResourceInheritanceChainInternal(bucketNames, configName + "/" + item.getName(), paths.iterator(), resourceResolver));
if (result.hasNext()) {
return IteratorUtils.toList(result);
}
else {
return null;
}
}
private boolean isValidResourceCollectionItem(Resource resource) {
// do not include jcr:content nodes in resource collection list
return !StringUtils.equals(resource.getName(), "jcr:content");
}
@Override
public String getResourcePath(@NotNull Resource contentResource, @NotNull String bucketName, @NotNull String configName) {
if (!isEnabledAndParamsValid(contentResource, Collections.singleton(bucketName), configName)) {
return null;
}
String name = bucketName + "/" + configName;
Iterator<String> configPaths = this.findConfigRefs(contentResource, Collections.singleton(bucketName));
if (configPaths.hasNext()) {
String configPath = buildResourcePath(configPaths.next(), name);
log.trace("+ Building configuration path for name '{}' for resource {}: {}", name, contentResource.getPath(), configPath);
return configPath;
}
else {
log.trace("- No configuration path for name '{}' found for resource {}", name, contentResource.getPath());
return null;
}
}
@Override
public String getResourceCollectionParentPath(@NotNull Resource contentResource, @NotNull String bucketName, @NotNull String configName) {
return getResourcePath(contentResource, bucketName, configName);
}
}