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  import static java.util.stream.Collectors.toList;
40  
41  import info.magnolia.cms.util.FilteredEventListener;
42  import info.magnolia.context.SystemContext;
43  import info.magnolia.jcr.util.NodeTypes;
44  import info.magnolia.jcr.util.NodeUtil;
45  import info.magnolia.jcr.util.PropertyUtil;
46  import info.magnolia.observation.WorkspaceEventListenerRegistration;
47  import info.magnolia.resourceloader.AbstractResourceOrigin;
48  import info.magnolia.resourceloader.ResourceOriginChange;
49  import info.magnolia.resourceloader.ResourceOriginFactory;
50  
51  import java.io.IOException;
52  import java.io.InputStream;
53  import java.io.Reader;
54  import java.io.StringReader;
55  import java.nio.charset.Charset;
56  import java.nio.charset.StandardCharsets;
57  import java.util.ArrayList;
58  import java.util.Calendar;
59  import java.util.LinkedHashMap;
60  import java.util.List;
61  import java.util.Map;
62  import java.util.Optional;
63  import java.util.regex.Matcher;
64  import java.util.regex.Pattern;
65  import java.util.stream.StreamSupport;
66  
67  import javax.inject.Provider;
68  import javax.jcr.Node;
69  import javax.jcr.Property;
70  import javax.jcr.RepositoryException;
71  import javax.jcr.Session;
72  import javax.jcr.observation.Event;
73  import javax.jcr.observation.EventIterator;
74  import javax.jcr.observation.EventListener;
75  
76  import org.apache.commons.io.input.NullInputStream;
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.auto.factory.AutoFactory;
83  import com.google.auto.factory.Provided;
84  import com.machinezoo.noexception.Exceptions;
85  
86  /**
87   * A {@link info.magnolia.resourceloader.ResourceOrigin} which loads resources from JCR.
88   */
89  @AutoFactory(implementing = ResourceOriginFactory.class)
90  public class JcrResourceOrigin extends AbstractResourceOrigin<JcrResource> {
91      private final static Logger log = LoggerFactory.getLogger(JcrResourceOrigin.class);
92  
93      public static final String RESOURCES_WORKSPACE = "resources";
94  
95      public static final String BINARY_NODE_NAME = "binary";
96  
97      public static final String TEXT_PROPERTY = "text";
98      public static final String BYPASS_PROPERTY = "bypass";
99      private static final String SLASH = "/";
100 
101     private final Provider<SystemContext> systemContextProvider;
102 
103     JcrResourceOrigin(@Provided Provider<SystemContext> systemContextProvider, String name) {
104         super(name);
105         this.systemContextProvider = systemContextProvider;
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, SLASH, 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     public JcrResource getRoot() {
120         return getJcrSession()
121                 .map(Exceptions.wrap().function(Session::getRootNode))
122                 .map(this::newResource)
123                 .orElseThrow(() -> new ResourceNotFoundException(this, SLASH));
124     }
125 
126     @Override
127     public JcrResource getByPath(String path) {
128         return getNode(path)
129                 .filter(node -> !isBypassed(node))
130                 .map(this::newResource)
131                 .orElseThrow(() -> new ResourceNotFoundException(this, path));
132     }
133 
134     @Override
135     public boolean hasPath(String path) {
136         return getNode(path)
137                 .filter(node -> !isBypassed(node))
138                 .isPresent();
139     }
140 
141     @Override
142     protected boolean isFile(JcrResource resource) {
143         // Make sure resource is always either file or directory
144         return !isDirectory(resource);
145     }
146 
147     @Override
148     protected boolean isDirectory(JcrResource resource) {
149         // TODO seems incomplete/unsafe ? what about other node types ?
150         return getNode(resource)
151                 .map(this::isDirectory)
152                 .orElse(false);
153     }
154 
155     @Override
156     protected boolean isEditable(JcrResource resource) {
157         // For now we naively consider that any resource in the JcrResourceOrigin will be editable
158         return true;
159     }
160 
161     @Override
162     protected String getPath(JcrResource resource) {
163         return getNode(resource)
164                 .map(Exceptions.wrap().function(Node::getPath))
165                 .orElse(null);
166     }
167 
168     @Override
169     protected String getName(JcrResource resource) {
170         return getNode(resource)
171                 .map(Exceptions.wrap().function(Node::getName))
172                 .orElse(null);
173     }
174 
175     @Override
176     protected long getLastModified(JcrResource resource) {
177         return getNode(resource)
178                 .map(Exceptions.wrap().function(NodeTypes.LastModified::getLastModified)) // TODO Compare: return resource.getNode().getProperty(JcrConstants.JCR_LASTMODIFIED)
179                 .map(Calendar::getTimeInMillis)
180                 .orElseThrow(() -> new RuntimeException("No lastModified or created date property on " + resource.getPath()));
181     }
182 
183     @Override
184     protected List<JcrResource> listChildren(JcrResource resource) {
185         if (!resource.isDirectory()) {
186             throw new IllegalStateException(resource.getPath() + " is not a directory.");
187         }
188         return getNode(resource)
189                 .map(Exceptions.wrap().function(node -> StreamSupport.stream(NodeUtil.getNodes(node, child -> nodeFilter((Node) child)).spliterator(), false)
190                         .map(this::newResource)
191                         .collect(toList())
192                 ))
193                 .orElse(new ArrayList<>());
194     }
195 
196     @Override
197     protected JcrResource/../../info/magnolia/resourceloader/jcr/JcrResource.html#JcrResource">JcrResource getParent(JcrResource resource) {
198         return getNode(resource)
199                 .filter(Exceptions.wrap().predicate(node -> !node.getPath().equals(SLASH)))
200                 .map(Exceptions.wrap().function(Node::getParent))
201                 .map(this::newResource)
202                 .orElse(null);
203     }
204 
205     @Override
206     protected Reader openReader(JcrResource resource) throws IOException {
207         Optional<Node> node = getNode(resource);
208         if (node.map(this::isTextResource).orElse(false)) {
209             return new StringReader(PropertyUtil.getString(node.get(), TEXT_PROPERTY));
210         }
211         return super.openReader(resource);
212     }
213 
214     @Override
215     protected InputStream doOpenStream(JcrResource resource) {
216         return getNode(resource)
217                 .map(this::getStream)
218                 .orElseGet(() -> new NullInputStream(0));
219     }
220 
221     @Override
222     protected Charset getCharsetFor(JcrResource resource) {
223         return getNode(resource)
224                 .filter(Exceptions.wrap().predicate(node -> node.hasNode(BINARY_NODE_NAME)))
225                 .map(Exceptions.wrap().function(node -> node.getNode(BINARY_NODE_NAME)))
226                 .map(node -> PropertyUtil.getString(node, JcrConstants.JCR_ENCODING))
227                 .map(Charset::forName)
228                 .orElse(StandardCharsets.UTF_8);
229     }
230 
231     protected JcrResource newResource(Node node) {
232         return new JcrResource(this, node);
233     }
234 
235     private Optional<Node> getNode(String resource) {
236         return getJcrSession()
237                 .filter(Exceptions.wrap().predicate(session -> session.nodeExists(resource)))
238                 .map(Exceptions.wrap().function(session -> session.getNode(resource)));
239     }
240 
241     private Optional<Node> getNode(JcrResource resource) {
242         return getNode(resource.getPath());
243     }
244 
245     protected Optional<Session> getJcrSession() {
246         return Optional.of(Exceptions.wrap().get(() -> systemContextProvider.get().getJCRSession(RESOURCES_WORKSPACE)));
247     }
248 
249     protected InputStream getStream(Node resourceNode) {
250         return Exceptions.wrap().get(() -> {
251             Property property = null;
252             if (isTextResource(resourceNode) && resourceNode.hasProperty(TEXT_PROPERTY)) {
253                 property = resourceNode.getProperty(TEXT_PROPERTY);
254             } else if (resourceNode.hasNode(BINARY_NODE_NAME)) {
255                 final Node binaryNode = resourceNode.getNode(BINARY_NODE_NAME);
256                 if (binaryNode.hasProperty(JcrConstants.JCR_DATA)) {
257                     property = binaryNode.getProperty(JcrConstants.JCR_DATA);
258                 }
259             }
260             return property == null ? null : property.getBinary().getStream();
261         });
262     }
263 
264     protected boolean isTextResource(Node resourceNode) {
265         return Optional.ofNullable(resourceNode)
266                 .map(Exceptions.wrap().function(node -> node.hasProperty(TEXT_PROPERTY)))
267                 .orElse(false);
268     }
269 
270     protected boolean isBinaryResource(Node resourceNode) {
271         return Optional.ofNullable(resourceNode)
272                 .map(Exceptions.wrap().function(node -> node.hasNode(BINARY_NODE_NAME)))
273                 .orElse(false);
274     }
275 
276     protected boolean isFile(Node node) {
277         // Resource are mgnl:content, which is mutually exclusive with mgnl:folder (a resource should never be both file and directory)
278         return Exceptions.wrap().get(() -> NodeUtil.isNodeType(node, NodeTypes.Content.NAME));
279     }
280 
281     protected boolean isDirectory(Node node) {
282         return Exceptions.wrap().get(() -> node != null && (node.isNodeType(NodeTypes.Folder.NAME) || node.getDepth() == 0));
283     }
284 
285     // TODO avoid !bypass double negatives --> isEnabled.
286     protected boolean isBypassed(Node node) {
287         return PropertyUtil.getBoolean(node, BYPASS_PROPERTY, false);
288     }
289 
290     private boolean nodeFilter(Node node) {
291         return node != null && !isBypassed(node) && (isDirectory(node) || isFile(node));
292     }
293 
294     /**
295      * The ResourcesObservationListener collects paths for received change events, then invokes the given visitor
296      * with proper corresponding {@link info.magnolia.resourceloader.Resource Resource} objects.
297      */
298     private class ResourcesObservationListener implements EventListener {
299 
300         @Override
301         public void onEvent(EventIterator events) {
302             // Collect changed paths from EventIterator
303             final Map<String, ResourceOriginChange> changes = new LinkedHashMap<>();
304 
305             final ResourceOriginChange.Builder builder = resourceChange().inOrigin(JcrResourceOrigin.this);
306             while (events.hasNext()) {
307                 final Event event = events.nextEvent();
308                 int eventType = event.getType();
309 
310                 try {
311 
312                     String path = event.getPath();
313                     builder.at(path);
314                     if (eventType == Event.NODE_ADDED) {
315                         builder.ofType(ResourceOriginChange.Type.ADDED);
316                         changes.put(path, builder.build());
317                     } else if (eventType == Event.NODE_REMOVED) {
318                         builder.ofType(REMOVED);
319                         changes.put(path, builder.build());
320                     }
321 
322 
323                     final Matcher itemPathMatcher = Pattern.compile("^(?<relatedNodePath>.+)?/(?<name>.+)?$").matcher(event.getPath());
324 
325                     if (itemPathMatcher.matches()) {
326                         final String relatedNode = StringUtils.defaultIfBlank(itemPathMatcher.group("relatedNodePath"), SLASH);
327                         final String name = StringUtils.defaultIfBlank(itemPathMatcher.group("name"), "");
328 
329                         /**
330                          * Handle bypass property change first. If the state of {@value #BYPASS_PROPERTY} has changed, then the following changes might be communicated:
331                          * <ul>
332                          * <li>{@value #BYPASS_PROPERTY} turned to true => resource is not a part of this origin any longer => it is deleted</li>
333                          * <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,
334                          * new resource, hence a corresponding change emission/</li>
335                          * </ul>
336                          */
337                         if (eventType == Event.PROPERTY_ADDED || eventType == Event.PROPERTY_CHANGED || eventType == Event.PROPERTY_REMOVED) {
338                             Optional<ResourceOriginChange> bypassStateChange = tryDetectBypassPropertyChange(relatedNode, name, eventType);
339                             if (bypassStateChange.isPresent()) {
340                                 changes.put(bypassStateChange.get().getRelatedResourcePath(), bypassStateChange.get());
341                                 continue;
342                             }
343                         }
344 
345                         // If a node the changes is not about an added or removed node - signal the event for the parent
346                         changes.put(relatedNode, builder.at(relatedNode).ofType(MODIFIED).build());
347                     }
348                 } catch (RepositoryException e) {
349                     log.error("Failed to process event [{}] due to: {}", event, e.getMessage(), e);
350                 }
351             }
352 
353             for (final ResourceOriginChange change : changes.values()) {
354                 if (change.getType() == MODIFIED || change.getType() == ADDED) {
355                     if (getNode(change.getRelatedResourcePath()).map(JcrResourceOrigin.this::isBypassed).orElse(false)) {
356                         continue;
357                     }
358                 }
359 
360                 dispatchResourceChange(change);
361             }
362             systemContextProvider.get().release();
363         }
364 
365         private Optional<ResourceOriginChange> tryDetectBypassPropertyChange(String relatedNodePath, String name, int eventType) {
366             return getNode(relatedNodePath)
367                     .filter(any -> BYPASS_PROPERTY.equals(name))
368                     .map(resourceNode -> PropertyUtil.getBoolean(resourceNode, BYPASS_PROPERTY, false))
369                     .map(bypassed -> {
370                         if (bypassed) {
371                             return resourceChange().at(relatedNodePath).inOrigin(JcrResourceOrigin.this).ofType(REMOVED).build();
372                         } else if (eventType != Event.PROPERTY_ADDED) {
373                             return resourceChange().at(relatedNodePath).inOrigin(JcrResourceOrigin.this).ofType(ADDED).build();
374                         }
375                         return null;
376                     });
377         }
378     }
379 
380     /**
381      * 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} -
382      * 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.
383      */
384     private final static org.apache.jackrabbit.commons.predicate.Predicate EVENT_LISTENER_FILTER = eventObject -> {
385         if (!JCR_SYSTEM_EXCLUDING_PREDICATE.evaluate(eventObject)) {
386             return false;
387         }
388 
389         final Event event = (Event) eventObject;
390         try {
391             final String relatedPath = event.getPath();
392             return !relatedPath.endsWith(SLASH + NodeTypes.LastModified.LAST_MODIFIED) && !relatedPath.endsWith(SLASH + NodeTypes.LastModified.LAST_MODIFIED_BY);
393         } catch (RepositoryException e) {
394             return false;
395         }
396     };
397 }