View Javadoc
1   /**
2    * This file Copyright (c) 2016 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.module.resources.setup;
35  
36  import static info.magnolia.resourceloader.jcr.JcrResourceOrigin.*;
37  
38  import info.magnolia.cms.util.ClasspathResourcesUtil;
39  import info.magnolia.jcr.predicate.NodeTypePredicate;
40  import info.magnolia.jcr.util.NodeTypes;
41  import info.magnolia.jcr.util.NodeUtil;
42  import info.magnolia.jcr.util.PropertyUtil;
43  import info.magnolia.module.InstallContext;
44  import info.magnolia.module.delta.AbstractRepositoryTask;
45  import info.magnolia.module.delta.TaskExecutionException;
46  import info.magnolia.module.resources.ResourceTypes;
47  import info.magnolia.objectfactory.Components;
48  import info.magnolia.resourceloader.Resource;
49  import info.magnolia.resourceloader.ResourceOrigin;
50  import info.magnolia.resourceloader.jcr.JcrResourceOrigin;
51  import info.magnolia.resourceloader.layered.LayeredResource;
52  import info.magnolia.resourceloader.layered.LayeredResourceOrigin;
53  
54  import java.io.IOException;
55  import java.io.InputStream;
56  import java.io.Reader;
57  import java.util.ArrayList;
58  import java.util.Arrays;
59  import java.util.HashMap;
60  import java.util.Iterator;
61  import java.util.List;
62  import java.util.Map;
63  import java.util.Set;
64  import java.util.regex.Pattern;
65  
66  import javax.inject.Provider;
67  import javax.jcr.Node;
68  import javax.jcr.Property;
69  import javax.jcr.PropertyType;
70  import javax.jcr.RepositoryException;
71  import javax.jcr.Session;
72  
73  import org.apache.commons.collections4.CollectionUtils;
74  import org.apache.commons.io.FilenameUtils;
75  import org.apache.commons.io.IOUtils;
76  import org.apache.commons.lang3.ObjectUtils;
77  import org.apache.commons.lang3.StringUtils;
78  import org.apache.jackrabbit.JcrConstants;
79  import org.slf4j.Logger;
80  import org.slf4j.LoggerFactory;
81  
82  import com.google.common.base.Function;
83  import com.google.common.collect.ArrayListMultimap;
84  import com.google.common.collect.ListMultimap;
85  import com.google.common.collect.Lists;
86  
87  /**
88   * <b>Cleanup process for previously installed JCR resources.</b>
89   *
90   * <p>Since 5.4, a side effect of the new Resources API + app is that previously installed resources appear twice in the new app (one from the classpath with extension,
91   * one from JCR without extension). </p>
92   *
93   * <p>Installing resources was traditionally how resources used to be accessed, over <code>URI2ResourcesRepositoryMapping</code>, at <code>/resources</code>.
94   * This is still working and supported, but not encouraged in favor of the new <a href="https://documentation.magnolia-cms.com/display/DOCS/Resources" title="resource loading cascade">resource loading cascade</a>.
95   *
96   * <ul>
97   *     <li>Resources had to be installed in order to be found by the URI2Repository mapping</li>
98   *     <li>Those installed resources had to be bypassed in order to be served from the classpath instead.</li>
99   * </ul>
100  *
101  * Also, historically, dots were not allowed in JCR node names, hence why extension used to be stripped.</p>
102  *
103  * <p>{@linkplain ResourceCleanUpTask} tries to restore extension in JCR node names to resolve this problem.</p>
104  */
105 public class ResourceCleanUpTask extends AbstractRepositoryTask {
106 
107     private static final Logger log = LoggerFactory.getLogger(ResourceCleanUpTask.class);
108 
109     protected static final String PROPERTY_TEMPLATE = "mgnl:template";
110     protected static final String PROPERTY_EXTENSION = "extension";
111     protected static final String PROPERTY_TEXT = "text";
112     protected static final String PROPERTY_REFERENCE = "reference";
113     protected static final String RESOURCES_PREFIX = ResourceTypes.RESOURCES_PREFIX;
114     protected static final String BINARY_RESOURCES_TEMPLATE = ResourceTypes.BINARY;
115     protected static final String REFERENCES_RESOURCES_TEMPLATE = RESOURCES_PREFIX + "reference";
116     protected static final String RESOURCE_PROCESSED = RESOURCES_PREFIX + ResourceTypes.PROCESSED_PREFIX;
117 
118     private final Provider<ResourceOrigin> resourceOriginProvider;
119     private final CleanupMode cleanupMode;
120     private String parentPath;
121     private Pattern pattern;
122     private Set<String> absPaths;
123     private final UpdateResourceReferencesTask onSuccess;
124 
125     private final Map<String, String> changedPaths = new HashMap<>();
126     private final ListMultimap<UpdateResourceMessage, String[]> messages = ArrayListMultimap.create();
127 
128     private Session resourcesSession;
129 
130     private ResourceCleanUpTask(String name, String description, CleanupMode cleanupMode, UpdateResourceReferencesTask onSuccess) {
131         super(name, description);
132         this.resourceOriginProvider = new Provider<ResourceOrigin>() {
133             @Override
134             public ResourceOrigin get() {
135                 return Components.getComponent(ResourceOrigin.class);
136             }
137         };
138         this.cleanupMode = cleanupMode;
139         this.onSuccess = onSuccess;
140     }
141 
142     /**
143      * Restores resource extension in JCR node names, by visiting a JCR sub-tree recursively, from the given parent path.
144      *
145      * <ul>
146      * <li>First the task resolves extension for resource nodes, by the <code>extension</code> property, binary node <code>extension</code> property, or resource template</li>
147      * <li>Then it tries to restore this extension if it's not in node-name already</li>
148      * </ul>
149      *
150      * <p>If resources already exist *in JCR* with extension and same content, they are considered redundant and are removed.<br/>
151      * If resources already exist on classpath or file-system with extension and same content, they are renamed to restore extension;
152      * mind that they are kept in JCR so that bypassed resources keep working via URI2RepositoryMapping.
153      * </p>
154      *
155      * @param parentPath a path in JCR resources workspace, to start visiting from
156      * @param onSuccess an {@link UpdateResourceReferencesTask} might be needed to update resource references for renamed resources
157      */
158     public ResourceCleanUpTask(String name, String description, String parentPath, UpdateResourceReferencesTask onSuccess) {
159         this(name, description, CleanupMode.VISIT_JCR_TREE, onSuccess);
160         if (parentPath == null) {
161             throw new IllegalArgumentException("parentPath cannot be null");
162         }
163         this.parentPath = parentPath;
164     }
165 
166     /**
167      * Restores resource extension in JCR node names, for resources matching a given pattern.
168      *
169      * <ul>
170      * <li>First the task looks up for resource files matching the pattern in module resources</li>
171      * <li>Then it tries to find corresponding nodes in JCR, with extension stripped and same content</li>
172      * </ul>
173      *
174      * <p>If resources already exist *in JCR* with extension and same content, they are considered redundant and are removed.<br/>
175      * If resources already exist on classpath or file-system with extension and same content, they are renamed to restore extension;
176      * mind that they are kept in JCR so that bypassed resources keep working via URI2RepositoryMapping.
177      * </p>
178      *
179      * @param pathPattern a {@link Pattern} to match resource paths in module resources, including extension.
180      * @param onSuccess an {@link UpdateResourceReferencesTask} might be needed to update resource references for renamed resources
181      */
182     public ResourceCleanUpTask(String name, String description, Pattern pathPattern, UpdateResourceReferencesTask onSuccess) {
183         this(name, description, CleanupMode.MATCH_CLASSPATH_RESOURCES_BY_PATTERN, onSuccess);
184         if (pathPattern == null) {
185             throw new IllegalArgumentException("pathPattern cannot be null");
186         }
187         this.pattern = pathPattern;
188     }
189 
190     /**
191      * Restores resource extension in JCR node names, for a set of specific resources.
192      *
193      * <ul>
194      * <li>First the task looks up for resource files in module resources</li>
195      * <li>Then it tries to find corresponding nodes in JCR, with extension stripped and same content</li>
196      * </ul>
197      *
198      * <p>If resources already exist *in JCR* with extension and same content, they are considered redundant and are removed.<br/>
199      * If resources already exist on classpath or file-system with extension and same content, they are renamed to restore extension;
200      * mind that they are kept in JCR so that bypassed resources keep working via URI2RepositoryMapping.
201      * </p>
202      *
203      * @param absPaths a set of existing resource paths in module resources, including extension.
204      * @param onSuccess an {@link UpdateResourceReferencesTask} might be needed to update resource references for renamed resources
205      */
206     public ResourceCleanUpTask(String name, String description, Set<String> absPaths, UpdateResourceReferencesTask onSuccess) {
207         this(name, description, CleanupMode.MATCH_CLASSPATH_RESOURCES_BY_PATHS, onSuccess);
208         if (absPaths == null) {
209             throw new IllegalArgumentException("absPaths cannot be null");
210         }
211         this.absPaths = absPaths;
212     }
213 
214     @Override
215     protected void doExecute(InstallContext installContext) throws RepositoryException, TaskExecutionException {
216 
217         resourcesSession = installContext.getJCRSession(RESOURCES_WORKSPACE);
218 
219         ResourceOrigin origin = resourceOriginProvider.get();
220 
221         if (!(origin instanceof LayeredResourceOrigin)) {
222             return;
223         }
224 
225         final LayeredResourceOrigin resourceOrigin = (LayeredResourceOrigin) origin;
226 
227         final Iterator<Node> resourcesIterator = findResourceNodes();
228 
229         while (resourcesIterator.hasNext()) {
230             Node resourceNode = resourcesIterator.next();
231             String extension = resolveResourceExtension(resourceNode);
232             // clean up properties after resolving extension (silly case of STK special resources, all installed as css)
233             cleanupProperties(resourceNode);
234 
235             if (StringUtils.isNotBlank(extension)) {
236                 // For the case node name does not have extension
237                 // When resource exists in resource origin check whether it was 'hot-fixed' or not
238                 final String resourceNodePath = resourceNode.getPath();
239                 final String resourcePath = resourceNodePath + "." + extension;
240                 if (resourceOrigin.hasPath(resourcePath)) {
241                     final LayeredResource layeredResource = resourceOrigin.getByPath(resourcePath);
242                     // Check if they have same contents or not
243                     if (equalContents(resourceNode, layeredResource.getFirst())) {
244                         if (layeredResource.getFirst().getOrigin() instanceof JcrResourceOrigin) {
245                             resourceNode.remove();
246                             changedPaths.put(resourceNodePath, resourcePath);
247                             messages.put(UpdateResourceMessage.EXISTS_JCR_AND_SAME_CONTENT, new String[] { resourceNodePath });
248                         } else {
249                             renameResource(resourceNode, extension);
250                             messages.put(UpdateResourceMessage.NODE_RENAMED, new String[] { resourceNodePath, resourcePath });
251                         }
252                     } else {
253                         // Resource with extension exists in origin, but contents are not the same
254                         messages.put(UpdateResourceMessage.EXISTS_BUT_DIFFERENT_CONTENT, new String[] { resourceNodePath });
255                     }
256                 } else if (StringUtils.isEmpty(FilenameUtils.getExtension(resourceNode.getName()))) {
257                     // When node name does not have dot (.)
258                     // Rename node and keep track
259                     renameResource(resourceNode, extension);
260                     messages.put(UpdateResourceMessage.NODE_RENAMED, new String[] { resourceNodePath, resourcePath });
261                 }
262             }
263         }
264 
265         UpdateResourceMessage.logMessages(installContext, log, messages);
266 
267         // Execute addition task
268         if (onSuccess != null && !changedPaths.isEmpty()) {
269             onSuccess.setChangedPaths(changedPaths);
270             onSuccess.execute(installContext);
271         }
272     }
273 
274     /**
275      * Cleans up empty magnolia properties (e.g. rights, source, version, modelClass);
276      * cleans up properties of binary nodes too (no modelClass and size of type Long)
277      */
278     private void cleanupProperties(Node resourceNode) throws RepositoryException {
279         final List<String> magnoliaEmptyProperties = Lists.newArrayList("rights", "source", "version", "modelClass", PROPERTY_EXTENSION, PROPERTY_TEMPLATE);
280         for (String emptyProperty : magnoliaEmptyProperties) {
281             if (resourceNode.hasProperty(emptyProperty)) {
282                 Property property = resourceNode.getProperty(emptyProperty);
283                 if (property.getString().isEmpty()) {
284                     property.remove();
285                 }
286             }
287         }
288 
289         if (resourceNode.hasProperty(PROPERTY_TEMPLATE)
290                 && resourceNode.getProperty(PROPERTY_TEMPLATE).getString().equals(BINARY_RESOURCES_TEMPLATE)
291                 && resourceNode.hasNode(BINARY_NODE_NAME)) {
292             if (resourceNode.hasProperty("modelClass")) {
293                 // modelClasses don't make any sense for binary resources
294                 resourceNode.getProperty("modelClass").remove();
295             }
296             Node binary = resourceNode.getNode(BINARY_NODE_NAME);
297             if (binary.hasProperty("size")) {
298                 Property size = binary.getProperty("size");
299                 if (size.getType() == PropertyType.STRING) {
300                     size.setValue(Long.parseLong(size.getString()));
301                 }
302             }
303         }
304     }
305 
306     protected Map<String, String> getChangedPaths() {
307         return changedPaths;
308     }
309 
310     protected Iterator<Node> findResourceNodes() throws RepositoryException {
311         switch (cleanupMode) {
312         case MATCH_CLASSPATH_RESOURCES_BY_PATHS:
313             return findResourceNodesByPaths(absPaths).iterator();
314         case MATCH_CLASSPATH_RESOURCES_BY_PATTERN:
315             return findResourceNodesByPattern(pattern).iterator();
316         case VISIT_JCR_TREE:
317             Node parentNode = parentPath != null ? resourcesSession.getNode(parentPath) : resourcesSession.getRootNode();
318             return NodeUtil.collectAllChildren(parentNode, new NodeTypePredicate(NodeTypes.Content.NAME)).iterator();
319         }
320         return null;
321     }
322 
323     private List<Node> findResourceNodesByPaths(final Set<String> paths) throws RepositoryException {
324         List<String> foundResourcePaths = Arrays.asList(ClasspathResourcesUtil.findResources(new ClasspathResourcesUtil.Filter() {
325 
326             @Override
327             public boolean accept(String path) {
328                 return paths.contains(path);
329             }
330         }));
331 
332         // Log message for resource not found in classpath.
333         if (!CollectionUtils.isEqualCollection(paths, foundResourcePaths)) {
334             List<String> givenPaths = Lists.newArrayList(paths);
335             givenPaths.removeAll(foundResourcePaths);
336             messages.putAll(UpdateResourceMessage.RESOURCE_NOT_FOUND_CLASSPATH, Lists.transform(givenPaths, new Function<String, String[]>() {
337                 @Override
338                 public String[] apply(String input) {
339                     return new String[] { input };
340                 }
341             }));
342         }
343 
344         return getStrippedNodesByPaths(foundResourcePaths);
345     }
346 
347     private List<Node> findResourceNodesByPattern(final Pattern pattern) throws RepositoryException {
348         List<String> foundResourcePaths = Arrays.asList(ClasspathResourcesUtil.findResources(pattern));
349         return getStrippedNodesByPaths(foundResourcePaths);
350     }
351 
352     private List<Node> getStrippedNodesByPaths(final List<String> paths) throws RepositoryException {
353         List<Node> nodes = new ArrayList<>();
354         for (String path : paths) {
355             String strippedPath = StringUtils.substringBeforeLast(path, FilenameUtils.EXTENSION_SEPARATOR_STR);
356             if (resourcesSession.nodeExists(strippedPath)) {
357                 Node node = resourcesSession.getNode(strippedPath);
358 
359                 // only consider node if it has expected extension here
360                 if (path.equals(strippedPath) || path.equals(strippedPath + "." + resolveResourceExtension(node))) {
361                     nodes.add(node);
362                     continue;
363                 }
364             }
365             messages.put(UpdateResourceMessage.RESOURCE_NOT_FOUND_JCR, new String[] { path });
366         }
367         return nodes;
368     }
369 
370     private String resolveResourceExtension(Node resourceNode) throws RepositoryException {
371         String template = PropertyUtil.getString(resourceNode, PROPERTY_TEMPLATE);
372 
373         if (REFERENCES_RESOURCES_TEMPLATE.equals(template)) {
374             return null;
375         }
376 
377         Node tempNode = (BINARY_RESOURCES_TEMPLATE.equals(template)) ? resourceNode.getNode(JcrResourceOrigin.BINARY_NODE_NAME) : resourceNode;
378 
379         if (tempNode.hasProperty(PROPERTY_EXTENSION)) {
380             return PropertyUtil.getString(tempNode, PROPERTY_EXTENSION);
381         }
382 
383         return StringUtils.lowerCase(StringUtils.substringAfter(template, (StringUtils.startsWith(template, RESOURCE_PROCESSED)) ? RESOURCE_PROCESSED : RESOURCES_PREFIX));
384     }
385 
386     /**
387      * Compare contents inside JCR resource node and other resource origin.
388      */
389     private boolean equalContents(Node resourceNode, Resource resource) throws RepositoryException {
390         String template = PropertyUtil.getString(resourceNode, PROPERTY_TEMPLATE);
391         if (BINARY_RESOURCES_TEMPLATE.equals(template)) {
392             Node binaryNode = resourceNode.getNode(JcrResourceOrigin.BINARY_NODE_NAME);
393 
394             return equalBinaryContents(binaryNode, resource);
395         } else {
396             return equalTextContents(resourceNode, resource);
397         }
398     }
399 
400     private boolean equalBinaryContents(Node binaryNode, Resource binaryResource) throws RepositoryException {
401         try (final InputStream nodeStream = binaryNode.getProperty(JcrConstants.JCR_DATA).getBinary().getStream();
402              final InputStream resourceStream = binaryResource.openStream()) {
403 
404             return IOUtils.contentEquals(nodeStream, resourceStream);
405         } catch (IOException e) {
406             log.error("Cannot read contents of '{}:{}'.", binaryResource.getOrigin().getName(), binaryResource.getName(), e);
407         }
408 
409         return false;
410     }
411 
412     private boolean equalTextContents(Node resourceNode, Resource resource) {
413         try (final Reader reader = resource.openReader()) {
414             String contentResourceNode = PropertyUtil.getString(resourceNode, PROPERTY_TEXT);
415             String contentResource = IOUtils.toString(reader);
416 
417             return ObjectUtils.equals(contentResourceNode, contentResource);
418         } catch (IOException e) {
419             log.error("Cannot read contents of '{}:{}'.", resource.getOrigin().getName(), resource.getName(), e);
420         }
421 
422         return false;
423     }
424 
425     /**
426      * Restores file extension in node name and removes redundant extension property.
427      * If this is a binary resource, also restores file extension on the binary node's fileName property.
428      */
429     private void renameResource(Node resourceNode, String extension) throws RepositoryException {
430         String oldPath = resourceNode.getPath();
431         String newPath = resourceNode.getPath() + "." + extension;
432         resourcesSession.move(oldPath, newPath);
433         removeExtensionProperty(resourceNode);
434         // clean up binary node fileName property
435         if (resourceNode.hasProperty(PROPERTY_TEMPLATE)
436                 && resourceNode.getProperty(PROPERTY_TEMPLATE).getString().equals(BINARY_RESOURCES_TEMPLATE)
437                 && resourceNode.hasNode(BINARY_NODE_NAME)) {
438             Node binary = resourceNode.getNode(BINARY_NODE_NAME);
439             if (binary.hasProperty("fileName")) {
440                 binary.getProperty("fileName").setValue(StringUtils.substringAfterLast(newPath, "/"));
441             }
442         }
443         changedPaths.put(oldPath, newPath);
444     }
445 
446     private void removeExtensionProperty(Node resourceNode) throws RepositoryException {
447         if (resourceNode.hasProperty(PROPERTY_EXTENSION)) {
448             resourceNode.getProperty(PROPERTY_EXTENSION).remove();
449         }
450     }
451 
452     private enum CleanupMode {
453         VISIT_JCR_TREE,
454         MATCH_CLASSPATH_RESOURCES_BY_PATHS,
455         MATCH_CLASSPATH_RESOURCES_BY_PATTERN
456     }
457 }