View Javadoc
1   /**
2    * This file Copyright (c) 2016-2018 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  import java.util.stream.Collectors;
66  
67  import javax.inject.Provider;
68  import javax.jcr.Node;
69  import javax.jcr.Property;
70  import javax.jcr.PropertyType;
71  import javax.jcr.RepositoryException;
72  import javax.jcr.Session;
73  
74  import org.apache.commons.collections4.CollectionUtils;
75  import org.apache.commons.io.FilenameUtils;
76  import org.apache.commons.io.IOUtils;
77  import org.apache.commons.lang3.ObjectUtils;
78  import org.apache.commons.lang3.StringUtils;
79  import org.apache.jackrabbit.JcrConstants;
80  import org.slf4j.Logger;
81  import org.slf4j.LoggerFactory;
82  
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 = () -> Components.getComponent(ResourceOrigin.class);
133         this.cleanupMode = cleanupMode;
134         this.onSuccess = onSuccess;
135     }
136 
137     /**
138      * Restores resource extension in JCR node names, by visiting a JCR sub-tree recursively, from the given parent path.
139      *
140      * <ul>
141      * <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>
142      * <li>Then it tries to restore this extension if it's not in node-name already</li>
143      * </ul>
144      *
145      * <p>If resources already exist *in JCR* with extension and same content, they are considered redundant and are removed.<br/>
146      * If resources already exist on classpath or file-system with extension and same content, they are renamed to restore extension;
147      * mind that they are kept in JCR so that bypassed resources keep working via URI2RepositoryMapping.
148      * </p>
149      *
150      * @param parentPath a path in JCR resources workspace, to start visiting from
151      * @param onSuccess an {@link UpdateResourceReferencesTask} might be needed to update resource references for renamed resources
152      */
153     public ResourceCleanUpTask(String name, String description, String parentPath, UpdateResourceReferencesTask onSuccess) {
154         this(name, description, CleanupMode.VISIT_JCR_TREE, onSuccess);
155         if (parentPath == null) {
156             throw new IllegalArgumentException("parentPath cannot be null");
157         }
158         this.parentPath = parentPath;
159     }
160 
161     /**
162      * Restores resource extension in JCR node names, for resources matching a given pattern.
163      *
164      * <ul>
165      * <li>First the task looks up for resource files matching the pattern in module resources</li>
166      * <li>Then it tries to find corresponding nodes in JCR, with extension stripped and same content</li>
167      * </ul>
168      *
169      * <p>If resources already exist *in JCR* with extension and same content, they are considered redundant and are removed.<br/>
170      * If resources already exist on classpath or file-system with extension and same content, they are renamed to restore extension;
171      * mind that they are kept in JCR so that bypassed resources keep working via URI2RepositoryMapping.
172      * </p>
173      *
174      * @param pathPattern a {@link Pattern} to match resource paths in module resources, including extension.
175      * @param onSuccess an {@link UpdateResourceReferencesTask} might be needed to update resource references for renamed resources
176      */
177     public ResourceCleanUpTask(String name, String description, Pattern pathPattern, UpdateResourceReferencesTask onSuccess) {
178         this(name, description, CleanupMode.MATCH_CLASSPATH_RESOURCES_BY_PATTERN, onSuccess);
179         if (pathPattern == null) {
180             throw new IllegalArgumentException("pathPattern cannot be null");
181         }
182         this.pattern = pathPattern;
183     }
184 
185     /**
186      * Restores resource extension in JCR node names, for a set of specific resources.
187      *
188      * <ul>
189      * <li>First the task looks up for resource files in module resources</li>
190      * <li>Then it tries to find corresponding nodes in JCR, with extension stripped and same content</li>
191      * </ul>
192      *
193      * <p>If resources already exist *in JCR* with extension and same content, they are considered redundant and are removed.<br/>
194      * If resources already exist on classpath or file-system with extension and same content, they are renamed to restore extension;
195      * mind that they are kept in JCR so that bypassed resources keep working via URI2RepositoryMapping.
196      * </p>
197      *
198      * @param absPaths a set of existing resource paths in module resources, including extension.
199      * @param onSuccess an {@link UpdateResourceReferencesTask} might be needed to update resource references for renamed resources
200      */
201     public ResourceCleanUpTask(String name, String description, Set<String> absPaths, UpdateResourceReferencesTask onSuccess) {
202         this(name, description, CleanupMode.MATCH_CLASSPATH_RESOURCES_BY_PATHS, onSuccess);
203         if (absPaths == null) {
204             throw new IllegalArgumentException("absPaths cannot be null");
205         }
206         this.absPaths = absPaths;
207     }
208 
209     @Override
210     protected void doExecute(InstallContext installContext) throws RepositoryException, TaskExecutionException {
211 
212         resourcesSession = installContext.getJCRSession(RESOURCES_WORKSPACE);
213 
214         ResourceOrigin origin = resourceOriginProvider.get();
215 
216         if (!(origin instanceof LayeredResourceOrigin)) {
217             return;
218         }
219 
220         final LayeredResourceOrigin resourceOrigin = (LayeredResourceOrigin) origin;
221 
222         final Iterator<Node> resourcesIterator = findResourceNodes();
223 
224         while (resourcesIterator.hasNext()) {
225             Node resourceNode = resourcesIterator.next();
226             String extension = resolveResourceExtension(resourceNode);
227             // clean up properties after resolving extension (silly case of STK special resources, all installed as css)
228             cleanupProperties(resourceNode);
229 
230             if (StringUtils.isNotBlank(extension)) {
231                 // For the case node name does not have extension
232                 // When resource exists in resource origin check whether it was 'hot-fixed' or not
233                 final String resourceNodePath = resourceNode.getPath();
234                 final String resourcePath = resourceNodePath + "." + extension;
235                 if (resourceOrigin.hasPath(resourcePath)) {
236                     final LayeredResource layeredResource = resourceOrigin.getByPath(resourcePath);
237                     // Check if they have same contents or not
238                     if (equalContents(resourceNode, layeredResource.getFirst())) {
239                         if (layeredResource.getFirst().getOrigin() instanceof JcrResourceOrigin) {
240                             resourceNode.remove();
241                             changedPaths.put(resourceNodePath, resourcePath);
242                             messages.put(UpdateResourceMessage.EXISTS_JCR_AND_SAME_CONTENT, new String[] { resourceNodePath });
243                         } else {
244                             renameResource(resourceNode, extension);
245                             messages.put(UpdateResourceMessage.NODE_RENAMED, new String[] { resourceNodePath, resourcePath });
246                         }
247                     } else {
248                         // Resource with extension exists in origin, but contents are not the same
249                         messages.put(UpdateResourceMessage.EXISTS_BUT_DIFFERENT_CONTENT, new String[] { resourceNodePath });
250                     }
251                 } else if (StringUtils.isEmpty(FilenameUtils.getExtension(resourceNode.getName()))) {
252                     // When node name does not have dot (.)
253                     // Rename node and keep track
254                     renameResource(resourceNode, extension);
255                     messages.put(UpdateResourceMessage.NODE_RENAMED, new String[] { resourceNodePath, resourcePath });
256                 }
257             }
258         }
259 
260         UpdateResourceMessage.logMessages(installContext, log, messages);
261 
262         // Execute addition task
263         if (onSuccess != null && !changedPaths.isEmpty()) {
264             onSuccess.setChangedPaths(changedPaths);
265             onSuccess.execute(installContext);
266         }
267     }
268 
269     /**
270      * Cleans up empty magnolia properties (e.g. rights, source, version, modelClass);
271      * cleans up properties of binary nodes too (no modelClass and size of type Long)
272      */
273     private void cleanupProperties(Node resourceNode) throws RepositoryException {
274         final List<String> magnoliaEmptyProperties = Lists.newArrayList("rights", "source", "version", "modelClass", PROPERTY_EXTENSION, PROPERTY_TEMPLATE);
275         for (String emptyProperty : magnoliaEmptyProperties) {
276             if (resourceNode.hasProperty(emptyProperty)) {
277                 Property property = resourceNode.getProperty(emptyProperty);
278                 if (property.getString().isEmpty()) {
279                     property.remove();
280                 }
281             }
282         }
283 
284         if (resourceNode.hasProperty(PROPERTY_TEMPLATE)
285                 && resourceNode.getProperty(PROPERTY_TEMPLATE).getString().equals(BINARY_RESOURCES_TEMPLATE)
286                 && resourceNode.hasNode(BINARY_NODE_NAME)) {
287             if (resourceNode.hasProperty("modelClass")) {
288                 // modelClasses don't make any sense for binary resources
289                 resourceNode.getProperty("modelClass").remove();
290             }
291             Node binary = resourceNode.getNode(BINARY_NODE_NAME);
292             if (binary.hasProperty("size")) {
293                 Property size = binary.getProperty("size");
294                 if (size.getType() == PropertyType.STRING) {
295                     size.setValue(Long.parseLong(size.getString()));
296                 }
297             }
298         }
299     }
300 
301     protected Map<String, String> getChangedPaths() {
302         return changedPaths;
303     }
304 
305     protected Iterator<Node> findResourceNodes() throws RepositoryException {
306         switch (cleanupMode) {
307         case MATCH_CLASSPATH_RESOURCES_BY_PATHS:
308             return findResourceNodesByPaths(absPaths).iterator();
309         case MATCH_CLASSPATH_RESOURCES_BY_PATTERN:
310             return findResourceNodesByPattern(pattern).iterator();
311         case VISIT_JCR_TREE:
312             Node parentNode = parentPath != null ? resourcesSession.getNode(parentPath) : resourcesSession.getRootNode();
313             return NodeUtil.collectAllChildren(parentNode, new NodeTypePredicate(NodeTypes.Content.NAME)).iterator();
314         }
315         return null;
316     }
317 
318     private List<Node> findResourceNodesByPaths(final Set<String> paths) throws RepositoryException {
319         List<String> foundResourcePaths = Arrays.asList(ClasspathResourcesUtil.findResources(paths::contains));
320 
321         // Log message for resource not found in classpath.
322         if (!CollectionUtils.isEqualCollection(paths, foundResourcePaths)) {
323             List<String> givenPaths = Lists.newArrayList(paths);
324             givenPaths.removeAll(foundResourcePaths);
325             messages.putAll(UpdateResourceMessage.RESOURCE_NOT_FOUND_CLASSPATH, givenPaths.stream().map(input -> new String[]{input}).collect(Collectors.toList()));
326         }
327 
328         return getStrippedNodesByPaths(foundResourcePaths);
329     }
330 
331     private List<Node> findResourceNodesByPattern(final Pattern pattern) throws RepositoryException {
332         List<String> foundResourcePaths = Arrays.asList(ClasspathResourcesUtil.findResources(pattern));
333         return getStrippedNodesByPaths(foundResourcePaths);
334     }
335 
336     private List<Node> getStrippedNodesByPaths(final List<String> paths) throws RepositoryException {
337         List<Node> nodes = new ArrayList<>();
338         for (String path : paths) {
339             String strippedPath = StringUtils.substringBeforeLast(path, FilenameUtils.EXTENSION_SEPARATOR_STR);
340             if (resourcesSession.nodeExists(strippedPath)) {
341                 Node node = resourcesSession.getNode(strippedPath);
342 
343                 // only consider node if it has expected extension here
344                 if (path.equals(strippedPath) || path.equals(strippedPath + "." + resolveResourceExtension(node))) {
345                     nodes.add(node);
346                     continue;
347                 }
348             }
349             messages.put(UpdateResourceMessage.RESOURCE_NOT_FOUND_JCR, new String[] { path });
350         }
351         return nodes;
352     }
353 
354     private String resolveResourceExtension(Node resourceNode) throws RepositoryException {
355         String template = PropertyUtil.getString(resourceNode, PROPERTY_TEMPLATE);
356 
357         if (REFERENCES_RESOURCES_TEMPLATE.equals(template)) {
358             return null;
359         }
360 
361         Node tempNode = (BINARY_RESOURCES_TEMPLATE.equals(template)) ? resourceNode.getNode(JcrResourceOrigin.BINARY_NODE_NAME) : resourceNode;
362 
363         if (tempNode.hasProperty(PROPERTY_EXTENSION)) {
364             return PropertyUtil.getString(tempNode, PROPERTY_EXTENSION);
365         }
366 
367         return StringUtils.lowerCase(StringUtils.substringAfter(template, (StringUtils.startsWith(template, RESOURCE_PROCESSED)) ? RESOURCE_PROCESSED : RESOURCES_PREFIX));
368     }
369 
370     /**
371      * Compare contents inside JCR resource node and other resource origin.
372      */
373     private boolean equalContents(Node resourceNode, Resource resource) throws RepositoryException {
374         String template = PropertyUtil.getString(resourceNode, PROPERTY_TEMPLATE);
375         if (BINARY_RESOURCES_TEMPLATE.equals(template)) {
376             Node binaryNode = resourceNode.getNode(JcrResourceOrigin.BINARY_NODE_NAME);
377 
378             return equalBinaryContents(binaryNode, resource);
379         } else {
380             return equalTextContents(resourceNode, resource);
381         }
382     }
383 
384     private boolean equalBinaryContents(Node binaryNode, Resource binaryResource) throws RepositoryException {
385         try (final InputStream nodeStream = binaryNode.getProperty(JcrConstants.JCR_DATA).getBinary().getStream();
386              final InputStream resourceStream = binaryResource.openStream()) {
387 
388             return IOUtils.contentEquals(nodeStream, resourceStream);
389         } catch (IOException e) {
390             log.error("Cannot read contents of '{}:{}'.", binaryResource.getOrigin().getName(), binaryResource.getName(), e);
391         }
392 
393         return false;
394     }
395 
396     private boolean equalTextContents(Node resourceNode, Resource resource) {
397         try (final Reader reader = resource.openReader()) {
398             String contentResourceNode = PropertyUtil.getString(resourceNode, PROPERTY_TEXT);
399             String contentResource = IOUtils.toString(reader);
400 
401             return ObjectUtils.equals(contentResourceNode, contentResource);
402         } catch (IOException e) {
403             log.error("Cannot read contents of '{}:{}'.", resource.getOrigin().getName(), resource.getName(), e);
404         }
405 
406         return false;
407     }
408 
409     /**
410      * Restores file extension in node name and removes redundant extension property.
411      * If this is a binary resource, also restores file extension on the binary node's fileName property.
412      */
413     private void renameResource(Node resourceNode, String extension) throws RepositoryException {
414         String oldPath = resourceNode.getPath();
415         String newPath = resourceNode.getPath() + "." + extension;
416         resourcesSession.move(oldPath, newPath);
417         removeExtensionProperty(resourceNode);
418         // clean up binary node fileName property
419         if (resourceNode.hasProperty(PROPERTY_TEMPLATE)
420                 && resourceNode.getProperty(PROPERTY_TEMPLATE).getString().equals(BINARY_RESOURCES_TEMPLATE)
421                 && resourceNode.hasNode(BINARY_NODE_NAME)) {
422             Node binary = resourceNode.getNode(BINARY_NODE_NAME);
423             if (binary.hasProperty("fileName")) {
424                 binary.getProperty("fileName").setValue(StringUtils.substringAfterLast(newPath, "/"));
425             }
426         }
427         changedPaths.put(oldPath, newPath);
428     }
429 
430     private void removeExtensionProperty(Node resourceNode) throws RepositoryException {
431         if (resourceNode.hasProperty(PROPERTY_EXTENSION)) {
432             resourceNode.getProperty(PROPERTY_EXTENSION).remove();
433         }
434     }
435 
436     private enum CleanupMode {
437         VISIT_JCR_TREE,
438         MATCH_CLASSPATH_RESOURCES_BY_PATHS,
439         MATCH_CLASSPATH_RESOURCES_BY_PATTERN
440     }
441 }