View Javadoc
1   /**
2    * This file Copyright (c) 2015-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.resourceloader.jcr;
35  
36  import static info.magnolia.cms.util.FilteredEventListener.JCR_SYSTEM_EXCLUDING_PREDICATE;
37  import static info.magnolia.resourceloader.ResourceOriginChange.Type.*;
38  import static info.magnolia.resourceloader.ResourceOriginChange.resourceChange;
39  
40  import info.magnolia.cms.util.FilteredEventListener;
41  import info.magnolia.context.SystemContext;
42  import info.magnolia.jcr.util.NodeTypes;
43  import info.magnolia.jcr.util.PropertyUtil;
44  import info.magnolia.observation.WorkspaceEventListenerRegistration;
45  import info.magnolia.resourceloader.AbstractResourceOrigin;
46  import info.magnolia.resourceloader.ResourceOriginChange;
47  import info.magnolia.resourceloader.ResourceOriginFactory;
48  
49  import java.io.IOException;
50  import java.io.InputStream;
51  import java.io.Reader;
52  import java.io.StringReader;
53  import java.nio.charset.Charset;
54  import java.nio.charset.StandardCharsets;
55  import java.util.Calendar;
56  import java.util.Iterator;
57  import java.util.List;
58  import java.util.Map;
59  import java.util.regex.Matcher;
60  import java.util.regex.Pattern;
61  
62  import javax.jcr.Binary;
63  import javax.jcr.Node;
64  import javax.jcr.RepositoryException;
65  import javax.jcr.Session;
66  import javax.jcr.observation.Event;
67  import javax.jcr.observation.EventIterator;
68  import javax.jcr.observation.EventListener;
69  
70  import org.apache.commons.io.input.NullInputStream;
71  import org.apache.commons.lang3.StringUtils;
72  import org.apache.jackrabbit.JcrConstants;
73  import org.slf4j.Logger;
74  import org.slf4j.LoggerFactory;
75  
76  import com.google.auto.factory.AutoFactory;
77  import com.google.auto.factory.Provided;
78  import com.google.common.base.Function;
79  import com.google.common.base.Optional;
80  import com.google.common.base.Predicate;
81  import com.google.common.collect.Iterators;
82  import com.google.common.collect.Lists;
83  import com.google.common.collect.Maps;
84  
85  import lombok.SneakyThrows;
86  
87  /**
88   * A {@link info.magnolia.resourceloader.ResourceOrigin} which loads resources from JCR.
89   */
90  @AutoFactory(implementing = ResourceOriginFactory.class)
91  public class JcrResourceOrigin extends AbstractResourceOrigin<JcrResource> {
92      private final static Logger log = LoggerFactory.getLogger(JcrResourceOrigin.class);
93  
94      public static final String RESOURCES_WORKSPACE = "resources";
95  
96      public static final String BINARY_NODE_NAME = "binary";
97  
98      public static final String TEXT_PROPERTY = "text";
99      public static final String BYPASS_PROPERTY = "bypass";
100 
101     private final SystemContext systemContext;
102 
103     JcrResourceOrigin(@Provided SystemContext systemContext, String name) {
104         super(name);
105         this.systemContext = systemContext;
106     }
107 
108     @Override
109     protected void initializeResourceChangeMonitoring() {
110         try {
111             EventListener listener = new FilteredEventListener(new ResourcesObservationListener(), EVENT_LISTENER_FILTER);
112             WorkspaceEventListenerRegistration.observe(RESOURCES_WORKSPACE, "/", listener).withDelay(1000L, 1000L).register();
113         } catch (RepositoryException e) {
114             log.error("Failed to initialize JCR resource change monitoring: {}", e.getMessage(), e);
115         }
116     }
117 
118     @Override
119     @SneakyThrows(RepositoryException.class)
120     public JcrResource getRoot() {
121         final Node rootNode = getJcrSession().getRootNode();
122         return newResource(rootNode);
123     }
124 
125     @Override
126     @SneakyThrows(RepositoryException.class)
127     public JcrResource getByPath(String path) {
128         final Node node = getNode(path);
129         if (node == null || isBypassed(node)) {
130             throw new ResourceNotFoundException(this, path);
131         }
132         return newResource(node);
133     }
134 
135     @Override
136     @SneakyThrows(RepositoryException.class)
137     public boolean hasPath(String path) {
138         final Node node = getNode(path);
139         return node != null && !isBypassed(node);
140     }
141 
142     @Override
143     @SneakyThrows(RepositoryException.class)
144     protected boolean isFile(JcrResource resource) {
145         // Make sure resource is always either file or directory
146         return !isDirectory(resource.getNode());
147     }
148 
149     @Override
150     @SneakyThrows(RepositoryException.class)
151     protected boolean isDirectory(JcrResource resource) {
152         // TODO seems incomplete/unsafe ? what about other node types ?
153         return isDirectory(resource.getNode());
154     }
155 
156     @Override
157     protected boolean isEditable(JcrResource resource) {
158         // For now we naively consider that any resource in the JcrResourceOrigin will be editable
159         return true;
160     }
161 
162     @Override
163     @SneakyThrows(RepositoryException.class)
164     protected String getPath(JcrResource resource) {
165         return resource.getNode().getPath();
166     }
167 
168     @Override
169     @SneakyThrows(RepositoryException.class)
170     protected String getName(JcrResource resource) {
171         return resource.getNode().getName();
172     }
173 
174     @Override
175     @SneakyThrows(RepositoryException.class)
176     protected long getLastModified(JcrResource resource) {
177         // TODO Compare: return resource.getNode().getProperty(JcrConstants.JCR_LASTMODIFIED)
178         final Calendar lastModified = NodeTypes.LastModified.getLastModified(resource.getNode());
179         if (lastModified == null) {
180             // TODO: shouldn't this happen in info.magnolia.jcr.util.NodeTypes.LastModified.getLastModified() ?
181             throw new RepositoryException("No lastModified or created date property on " + resource.getNode());
182         }
183         return lastModified.getTimeInMillis();
184     }
185 
186     @Override
187     @SneakyThrows(RepositoryException.class)
188     protected List<JcrResource> listChildren(JcrResource resource) {
189         if (!resource.isDirectory()) {
190             throw new IllegalStateException(resource.getPath() + " is not a directory.");
191         }
192 
193         final Iterator<Node> nodeIterator = resource.getNode().getNodes();
194         final Iterator<Node> filtered = Iterators.filter(nodeIterator, new NodeFilter());
195         final Iterator<JcrResource> resources = Iterators.transform(filtered, new NodeToResource());
196         return Lists.newArrayList(resources);
197     }
198 
199     @Override
200     @SneakyThrows(RepositoryException.class)
201     protected JcrResource getParent(JcrResource resource) {
202         Node node = resource.getNode();
203         String childPath = node.getPath();
204 
205         if (childPath.equals("/")) {
206             return null;
207         }
208 
209         return newResource(node.getParent());
210     }
211 
212     @Override
213     @SneakyThrows(RepositoryException.class)
214     protected Reader openReader(JcrResource resource) throws IOException {
215         Node node = resource.getNode();
216         if (isTextResource(node)) {
217             return new StringReader(node.getProperty(TEXT_PROPERTY).getString());
218         }
219         return super.openReader(resource);
220     }
221 
222     @Override
223     @SneakyThrows(RepositoryException.class)
224     protected InputStream doOpenStream(JcrResource resource) {
225         Binary binary = getBinary(resource.getNode());
226         if (binary != null) {
227             return binary.getStream();
228         }
229         // Node exists, but has no content yet (i.e empty file)
230         log.debug("JCR resource {} has no content", resource);
231         return new NullInputStream(0);
232     }
233 
234     @Override
235     @SneakyThrows(RepositoryException.class)
236     protected Charset getCharsetFor(JcrResource resource) {
237         Node node = resource.getNode();
238         if (isBinaryResource(node)) {
239             Node binary = node.getNode(BINARY_NODE_NAME);
240             if (binary.hasProperty(JcrConstants.JCR_ENCODING)) {
241                 return Charset.forName(node.getProperty(JcrConstants.JCR_ENCODING).getString());
242             }
243         }
244         // assume UTF-8
245         return StandardCharsets.UTF_8;
246     }
247 
248     protected JcrResource newResource(Node node) {
249         return new JcrResource(this, node);
250     }
251 
252     /**
253      * Only returns the node if it exists.
254      */
255     private Node getNode(String resource) throws RepositoryException {
256         final Session jcrSession = getJcrSession();
257         final boolean exists = jcrSession.nodeExists(resource);
258 
259         if (!exists) {
260             return null;
261         }
262 
263         return jcrSession.getNode(resource);
264     }
265 
266     protected Session getJcrSession() throws RepositoryException {
267         return systemContext.getJCRSession(RESOURCES_WORKSPACE);
268     }
269 
270     protected boolean isResource(Node node) throws RepositoryException {
271         return isBinaryResource(node) || isTextResource(node);
272     }
273 
274     protected Binary getBinary(Node resourceNode) throws RepositoryException {
275         if (isTextResource(resourceNode)) {
276             return resourceNode.getProperty(TEXT_PROPERTY).getBinary();
277         } else if (isBinaryResource(resourceNode)) {
278             final Node binary = resourceNode.getNode(BINARY_NODE_NAME);
279             return binary.getProperty(JcrConstants.JCR_DATA).getBinary();
280         }
281         return null;
282     }
283 
284     protected boolean isTextResource(Node resourceNode) throws RepositoryException {
285         return resourceNode.hasProperty(TEXT_PROPERTY);
286     }
287 
288     protected boolean isBinaryResource(Node node) throws RepositoryException {
289         return node.hasNode(BINARY_NODE_NAME);
290     }
291 
292     protected boolean isFile(Node node) throws RepositoryException {
293         // Resource are mgnl:content, which is mutually exclusive with mgnl:folder (a resource should never be both file and directory)
294         return node.isNodeType(NodeTypes.Content.NAME);
295     }
296 
297     protected boolean isDirectory(Node node) throws RepositoryException {
298         return node.isNodeType(NodeTypes.Folder.NAME) || node.getDepth() == 0;
299     }
300 
301     // TODO avoid !bypass double negatives --> isEnabled.
302     protected boolean isBypassed(Node node) {
303         return PropertyUtil.getBoolean(node, BYPASS_PROPERTY, false);
304     }
305 
306     private class NodeFilter implements Predicate<Node> {
307         @Override
308         @SneakyThrows(RepositoryException.class)
309         public boolean apply(Node node) {
310             return node != null && !isBypassed(node) && (isDirectory(node) || isFile(node));
311         }
312     }
313 
314     private class NodeToResource implements Function<Node, JcrResource> {
315         @Override
316         public JcrResource apply(Node node) {
317             return newResource(node);
318         }
319     }
320 
321     /**
322      * The ResourcesObservationListener collects paths for received change events, then invokes the given visitor
323      * with proper corresponding {@link info.magnolia.resourceloader.Resource Resource} objects.
324      */
325     private class ResourcesObservationListener implements EventListener {
326 
327         @Override
328         public void onEvent(EventIterator events) {
329             // Collect changed paths from EventIterator
330             final Map<String, ResourceOriginChange> changes = Maps.newLinkedHashMap();
331 
332             final ResourceOriginChange.Builder builder = resourceChange().inOrigin(JcrResourceOrigin.this);
333             while (events.hasNext()) {
334                 final Event event = events.nextEvent();
335                 int eventType = event.getType();
336 
337                 try {
338 
339                     String path = event.getPath();
340                     builder.at(path);
341                     if (eventType == Event.NODE_ADDED) {
342                         builder.ofType(ResourceOriginChange.Type.ADDED);
343                         changes.put(path, builder.build());
344                     } else if (eventType == Event.NODE_REMOVED) {
345                         builder.ofType(REMOVED);
346                         changes.put(path, builder.build());
347                     }
348 
349 
350                     final Matcher itemPathMatcher = Pattern.compile("^(?<relatedNodePath>.+)?/(?<name>.+)?$").matcher(event.getPath());
351 
352                     if (itemPathMatcher.matches()) {
353                         final String relatedNode = StringUtils.defaultIfBlank(itemPathMatcher.group("relatedNodePath"),  "/");
354                         final String name = StringUtils.defaultIfBlank(itemPathMatcher.group("name"), "");
355 
356                         /**
357                          * Handle bypass property change first. If the state of {@value #BYPASS_PROPERTY} has changed, then the following changes might be communicated:
358                          * <ul>
359                          * <li>{@value #BYPASS_PROPERTY} turned to true => resource is not a part of this origin any longer => it is deleted</li>
360                          * <li>{@value #BYPASS_PROPERTY} turned to false => unless the property was added with a false value (no effect then) the resource can be treated as a re-appeared,
361                          * new resource, hence a corresponding change emission/</li>
362                          * </ul>
363                          */
364                         if (eventType == Event.PROPERTY_ADDED || eventType == Event.PROPERTY_CHANGED || eventType == Event.PROPERTY_REMOVED) {
365                             final Optional<ResourceOriginChange> bypassStateChange = tryDetectBypassPropertyChange(relatedNode, name, eventType);
366                             if (bypassStateChange.isPresent()) {
367                                 changes.put(bypassStateChange.get().getRelatedResourcePath(), bypassStateChange.get());
368                                 continue;
369                             }
370                         }
371 
372                         // If a node the changes is not about an added or removed node - signal the event for the parent
373                         changes.put(relatedNode, builder.at(relatedNode).ofType(MODIFIED).build());
374                     }
375                 } catch (RepositoryException e) {
376                     log.error("Failed to process event [{}] due to: {}", event, e.getMessage(), e);
377                 }
378             }
379 
380             for (final ResourceOriginChange change : changes.values()) {
381                 if (change.getType() == MODIFIED || change.getType() == ADDED) {
382                     try {
383                         final Node relatedNode = getNode(change.getRelatedResourcePath());
384                         if (isBypassed(relatedNode)) {
385                             continue;
386                         }
387                     } catch (RepositoryException e) {
388                         log.warn("Failed to retrieve supposedly present node [{}] during resource change dispatching: {}", change.getRelatedResourcePath(), e.getMessage());
389                         continue;
390                     }
391                 }
392 
393                 dispatchResourceChange(change);
394             }
395         }
396 
397         private Optional<ResourceOriginChange> tryDetectBypassPropertyChange(String relatedNodePath, String name, int eventType) throws RepositoryException {
398             if (BYPASS_PROPERTY.equals(name)) {
399                 final Node resourceNode = getNode(relatedNodePath);
400                 boolean isBypassed = PropertyUtil.getBoolean(resourceNode, BYPASS_PROPERTY, false);
401 
402                 if (isBypassed) {
403                     return Optional.of(resourceChange().at(relatedNodePath).inOrigin(JcrResourceOrigin.this).ofType(REMOVED).build());
404                 } else if (eventType != Event.PROPERTY_ADDED) {
405                     return Optional.of(resourceChange().at(relatedNodePath).inOrigin(JcrResourceOrigin.this).ofType(ADDED).build());
406                 }
407             }
408             return Optional.absent();
409         }
410     }
411 
412     /**
413      * Besides what a standard {@link FilteredEventListener#JCR_SYSTEM_EXCLUDING_PREDICATE} does, filters out {@value NodeTypes.LastModified#LAST_MODIFIED} and {@value NodeTypes.LastModified#LAST_MODIFIED_BY} -
414      * those only add noise to the event listening, if they're modified - means that something else is modified and the actual change (e.g. {@value BYPASS_PROPERTY}) can be mangled by either of them.
415      */
416     private final static org.apache.jackrabbit.commons.predicate.Predicate EVENT_LISTENER_FILTER = new org.apache.jackrabbit.commons.predicate.Predicate() {
417         @Override
418         public boolean evaluate(Object eventObject) {
419             if (!JCR_SYSTEM_EXCLUDING_PREDICATE.evaluate(eventObject)) {
420                 return false;
421             }
422 
423             final Event event = (Event) eventObject;
424             try {
425                 final String relatedPath = event.getPath();
426                 return !relatedPath.endsWith("/" + NodeTypes.LastModified.LAST_MODIFIED) && !relatedPath.endsWith("/" + NodeTypes.LastModified.LAST_MODIFIED_BY);
427             } catch (RepositoryException e) {
428                 return false;
429             }
430         }
431     };
432 }