View Javadoc
1   /**
2    * This file Copyright (c) 2015 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.classpath;
35  
36  import info.magnolia.classpathwatch.ClasspathScannerService;
37  import info.magnolia.cms.util.ClasspathResourcesUtil;
38  import info.magnolia.init.MagnoliaConfigurationProperties;
39  import info.magnolia.resourceloader.AbstractResourceOrigin;
40  import info.magnolia.resourceloader.ResourceOriginFactory;
41  import info.magnolia.resourceloader.ResourceVisitor;
42  
43  import java.io.File;
44  import java.io.IOException;
45  import java.io.InputStream;
46  import java.net.JarURLConnection;
47  import java.net.URL;
48  import java.net.URLClassLoader;
49  import java.net.URLConnection;
50  import java.nio.charset.Charset;
51  import java.util.Collection;
52  import java.util.LinkedHashMap;
53  import java.util.List;
54  import java.util.Map;
55  import java.util.Set;
56  
57  import javax.servlet.ServletContext;
58  
59  import org.apache.commons.lang3.StringUtils;
60  import org.reflections.Reflections;
61  import org.reflections.scanners.ResourcesScanner;
62  import org.reflections.util.ClasspathHelper;
63  import org.reflections.util.ConfigurationBuilder;
64  import org.reflections.util.FilterBuilder;
65  import org.slf4j.Logger;
66  import org.slf4j.LoggerFactory;
67  
68  import com.google.auto.factory.AutoFactory;
69  import com.google.auto.factory.Provided;
70  import com.google.common.base.Joiner;
71  import com.google.common.base.Predicate;
72  import com.google.common.base.Predicates;
73  import com.google.common.collect.Collections2;
74  import com.google.common.collect.FluentIterable;
75  import com.google.common.collect.Sets;
76  import com.google.common.io.Resources;
77  
78  /**
79   * A {@link info.magnolia.resourceloader.ResourceOrigin} which loads resources from the classpath.
80   * <p>
81   * Because the classpath does actually not know about folders but only about classes, this origin constructs a virtual folder/file structure of {@link ClasspathResource}s. <br/>
82   * For this to work the origin relies on the following assumptions:
83   * <ul>
84   * <li>There is only one root path and it is "/"</li>
85   * <li>All paths begin with "/"</li>
86   * <li>Paths to folders never end with "/"</li>
87   * <li>File or folder names do not contain "/"</li>
88   * </ul>
89   */
90  @AutoFactory(implementing = ResourceOriginFactory.class)
91  public class ClasspathResourceOrigin extends AbstractResourceOrigin<ClasspathResource> {
92  
93      private static final Logger log = LoggerFactory.getLogger(ClasspathResourceOrigin.class);
94      private static final String OBSERVED_FILES_PATTERN = ".*\\.(ftl|yaml)$";
95  
96      private final ClasspathScannerService classpathScannerService;
97      private final ServletContext servletContext;
98      private final MagnoliaConfigurationProperties magnoliaProperties;
99  
100     private Map<String, ClasspathResource> resourceCache;
101 
102     ClasspathResourceOrigin(@Provided ClasspathScannerService classpathScannerService, String name, @Provided ServletContext ctx, @Provided MagnoliaConfigurationProperties magnoliaProperties) {
103         super(name);
104         this.classpathScannerService = classpathScannerService;
105         this.servletContext = ctx;
106         this.magnoliaProperties = magnoliaProperties;
107         collectResources();
108     }
109 
110     @Override
111     public ClasspathResource getRoot() {
112         return getByPath("/");
113     }
114 
115     @Override
116     public void watchForChanges(ResourceVisitor visitor) {
117         if (!classpathScannerService.isEnabled()) {
118             return;
119         }
120 
121         String observedResourcesPattern = observedResourcesPattern();
122         log.info("Setting up {} to watch for resource changes with pattern {}.", classpathScannerService, observedResourcesPattern);
123 
124         /*
125          * Propagate ClasspathResourceOrigin's Reflections config (filtering predicates for resources and URLs) to ClasspathScanner,
126          * yet further restricting periodic scanning to YAML files only.
127          */
128         final FilterBuilder resourcesFilter = new FilterBuilder();
129         // /!\ if there is an include, needs to be the first one in FilterBuilder
130         resourcesFilter.include(observedResourcesPattern);
131         applyResourceExclusions(resourcesFilter);
132 
133         final Predicate<? super URL> urlsFilter = urlsFilter();
134 
135         classpathScannerService.watch(resourcesFilter, urlsFilter, new VisitorFunction(this, visitor));
136     }
137 
138     protected String observedResourcesPattern() {
139         String pattern = magnoliaProperties.getProperty("magnolia.resources.classpath.observation.pattern");
140         if (StringUtils.isNotBlank(pattern)) {
141             return pattern;
142         } else {
143             log.info("Classpath observation pattern is not being set explicitly, current observation is made for only ftl + yaml files");
144             return OBSERVED_FILES_PATTERN;
145         }
146     }
147 
148     @Override
149     public ClasspathResource getByPath(String path) {
150         String decoratedPath = decoratePath(path);
151         String validatedPath = validatePath(decoratedPath);
152         if (resourceCache.containsKey(validatedPath)) {
153             return resourceCache.get(validatedPath);
154         }
155 
156         // If the given resource is not found in the cache, then we are searching for it
157         ClasspathResource resource = getResourceFor(validatedPath);
158         if (resource == null) {
159             throw new ResourceNotFoundException(this, validatedPath);
160         }
161 
162         resourceCache.put(validatedPath, resource);
163         return resource;
164     }
165 
166     ClasspathResource getResourceFor(String path) {
167         String strippedPath = stripLeadingSlash(path);
168         if (!hasPath(path) || !resourcesFilter().apply(strippedPath)) {
169             return null;
170         }
171 
172         boolean isFile = isResourceFile(strippedPath);
173         return newClasspathResource(path, isFile);
174     }
175 
176     @Override
177     public boolean hasPath(String path) {
178         final String decoratedPath = decoratePath(path);
179         return resourceCache.containsKey(validatePath(decoratedPath)) || getResourceUrl(stripLeadingSlash(decoratedPath)) != null;
180     }
181 
182     private URL getResourceUrl(String path) {
183         URLConnection connection = null;
184         try {
185             URL resource = Resources.getResource(path);
186             // We have to verify whether it's possible to open resource stream, since it may be deleted.
187             connection = resource.openConnection();
188             connection.getInputStream();
189             return resource;
190         } catch (IllegalArgumentException | IOException e) {
191             return null;
192         } finally {
193             if (connection != null) {
194                 tryToCloseConnection(connection);
195             }
196         }
197     }
198 
199     private void tryToCloseConnection(URLConnection connection) {
200         if (connection instanceof JarURLConnection) {
201             JarURLConnection jar = (JarURLConnection) connection;
202             try {
203                 if (jar.getUseCaches()) {
204                     jar.getJarFile().close();
205                 }
206             } catch (IOException ignored) {
207                 // This is ignored, because we might not get the inputstream and thus cannot close it.
208                 // This is a known java bug, https://bugs.openjdk.java.net/browse/JDK-8080094
209             }
210         } else {
211             try {
212                 InputStream inputStream = connection.getInputStream();
213                 if (inputStream != null) {
214                     inputStream.close();
215                 }
216             } catch (IOException e) {
217                 log.debug("Stream could not be closed", e);
218             }
219         }
220     }
221 
222     /**
223      * This method checks if a given path represents a resource file or folder,
224      * To identify this, we simply check if  {@code path + "/"} exists or not.
225      * If {@code true}, then we know that its a folder if not then its a simple file.
226      */
227     boolean isResourceFile(String path) {
228         String strippedPath = stripLastSlash(path);
229 
230         URL filePath = Resources.getResource(path);
231         File file = new File(filePath.getFile());
232         if (file.exists()) {
233             return file.isFile();
234         }
235 
236         try {
237             Resources.getResource(strippedPath + "/");
238             return false;
239         } catch (IllegalArgumentException e) {
240             return true;
241         }
242     }
243 
244     /**
245      * This method is used for stripping the last slash if its present.
246      */
247     private String stripLastSlash(String path) {
248         String strippedPath;
249         if (path.endsWith("/")) {
250             strippedPath = StringUtils.chop(path);
251         } else {
252             strippedPath = path;
253         }
254         return strippedPath;
255     }
256 
257     @Override
258     protected boolean isFile(ClasspathResource resource) {
259         throw shouldNotBeCalled();
260     }
261 
262     @Override
263     protected boolean isDirectory(ClasspathResource resource) {
264         throw shouldNotBeCalled();
265     }
266 
267     @Override
268     protected String getPath(ClasspathResource resource) {
269         return resource.getRealPath();
270     }
271 
272     @Override
273     protected String getName(ClasspathResource resource) {
274         return StringUtils.substringAfterLast(resource.getRealPath(), "/");
275     }
276 
277     @Override
278     protected long getLastModified(ClasspathResource resource) {
279         try {
280             final URL resourceUrl = getUrl(resource);
281             return resourceUrl.openConnection().getLastModified();
282         } catch (IOException e) {
283             throw new RuntimeException("Last modified time could not be retrieved for path " + resource + " : " + e, e);
284         }
285     }
286 
287     /**
288      * Lists children of a given resource, only if the given resource is an actual directory.
289      * Basically this method will search for the direct children both files and folders.
290      * <p><code>
291      * given(resource).withPath("/foo")<br>
292      * given(resource).withPath("/foo/bar")<br>
293      * given(resource).withPath("/foo/bar/foo")<br>
294      * when(listChildren("/foo"))<br>
295      * then(foundChildren).is("/foo/bar")
296      * </code></p>
297      */
298     @Override
299     protected List<ClasspathResource> listChildren(ClasspathResource resource) {
300         if (resource != null && resource.isFile()) {
301             throw new IllegalArgumentException(resource.getRealPath() + " is not a directory.");
302         }
303 
304         Set<ClasspathResource> children = Sets.newHashSet();
305         ChildFinder childFinder = new ChildFinder(resource.getRealPath());
306 
307         for (String childPath : childFinder.getDirectChildren()) {
308             URL resourceUrl = getResourceUrl(stripLeadingSlash(childPath));
309             if (resourceUrl == null) {
310                 resourceCache.remove(childPath);
311             } else {
312                 children.add(resourceCache.get(validatePath(childPath)));
313             }
314         }
315 
316         for (String path : childFinder.getDirectories()) {
317             children.add(newClasspathResource(path, false));
318         }
319 
320         return FluentIterable.from(children).toList();
321     }
322 
323     protected ClasspathResource updateResourceFor(String resourcePath) {
324         URL resource = getResourceUrl(resourcePath);
325         String validatedPath = validatePath(resourcePath);
326         if (resource != null) {
327             // Resource is present, hence it had been added, then we should be caching it as well.
328             return newClasspathResource(validatedPath, true);
329         } else {
330             removeFromCache(validatedPath);
331             return new ClasspathResource(this, validatedPath, true);
332         }
333     }
334 
335     private void removeFromCache(String resourcePath) {
336         resourceCache.remove(validatePath(resourcePath));
337     }
338 
339     @Override
340     protected ClasspathResource getParent(ClasspathResource resource) {
341         if ("/".equals(resource.getRealPath())) {
342             return null;
343         }
344 
345         String parentPath = StringUtils.substringBeforeLast(resource.getRealPath(), "/");
346         if (StringUtils.isNotBlank(parentPath)) {
347             return getByPath(parentPath);
348         } else {
349             return getRoot();
350         }
351     }
352 
353     @Override
354     protected InputStream doOpenStream(ClasspathResource resource) throws IOException {
355         final URL url = getUrl(resource);
356         // TODO prevent on directories ? (looks like it returns a list of resources ?)
357         return url.openStream();
358     }
359 
360     @Override
361     protected Charset getCharsetFor(ClasspathResource resource) {
362         return Charset.forName("UTF-8"); // TODO ?
363     }
364 
365     protected URL getUrl(ClasspathResource resource) {
366         final URL url = ClasspathResourcesUtil.getResource(resource.getRealPath());
367         if (url == null) {
368             throw new IllegalStateException("Can't open stream for " + resource);
369         }
370         return url;
371     }
372 
373     protected void collectResources() {
374         // Scan classpath for resources
375         final long start = System.currentTimeMillis();
376         final Predicate<String> resourcesFilter = resourcesFilter();
377         final Collection<URL> classpathUrls = classpathUrls();
378         final Reflections reflections = new Reflections(new ConfigurationBuilder()
379                 .setScanners(new ResourcesScanner())
380                 .setUrls(classpathUrls)
381                 .filterInputsBy(resourcesFilter)
382         );
383 
384         final Set<String> resources = reflections.getResources(Predicates.<String>alwaysTrue()); // predicate here is used for matching only the "simple name" of the resource; we do the pattern filtering upfront in ConfigurationBuilder anyway
385         long scanDone = System.currentTimeMillis();
386         log.debug("Took {}ms to find {} resources", scanDone - start, resources.size());
387 
388         // Build virtual directory structure of ClasspathResources and index it into resources map
389         this.resourceCache = new LinkedHashMap<>();
390         for (String resource : resources) {
391             newClasspathResource(resource, true);
392         }
393         log.debug("Took {}ms to build virtual directory structure", System.currentTimeMillis() - scanDone);
394     }
395 
396     protected Collection<URL> classpathUrls() {
397         final ClassLoader contextClassLoader = ClasspathHelper.contextClassLoader();
398         final Collection<URL> allURLs;
399         if (contextClassLoader instanceof URLClassLoader) {
400             allURLs = ClasspathHelper.forClassLoader(contextClassLoader);
401         } else {
402             // Some app servers (e.g. JBoss EAP/Wildfly) have complex classloader hierarchies and the context classloader
403             // does not provide the resource search path URLs. For such a case in order to make sure we still get the relevant
404             // URLs from the WEB-INF/lib we fall back to resolving them via a servlet context.
405             allURLs = ClasspathHelper.forWebInfLib(servletContext);
406         }
407         Predicate<? super URL> excludedExtensions = urlsFilter();
408         return Collections2.filter(allURLs, excludedExtensions);
409     }
410 
411     protected Predicate<? super URL> urlsFilter() {
412         final String excludedUrlExtensionsPattern = extensionsPattern(excludedUrlExtensions());
413         return URLPredicate.excludedExtensions(excludedUrlExtensionsPattern);
414     }
415 
416     protected Predicate<String> resourcesFilter() {
417         final FilterBuilder filterBuilder = new FilterBuilder();
418         return applyResourceExclusions(filterBuilder);
419     }
420 
421     /**
422      * Applies exclusions "standalone" as we they need to use them in {@link #watchForChanges(ResourceVisitor)} as well, in different ways.
423      */
424     private FilterBuilder applyResourceExclusions(FilterBuilder filterBuilder) {
425         // "mgnl-resources" is re-included by LegacyClasspathResourceOrigin, we don't want any other mgnl-* folder for now.
426         filterBuilder.exclude("mgnl-.*");
427 
428         filterBuilder.exclude(extensionsPattern(excludedResourcesExtensions()));
429 
430         final String[] excludedPackages = excludedPackages();
431         for (String excludedPackage : excludedPackages) {
432             filterBuilder.excludePackage(excludedPackage);
433         }
434         return filterBuilder;
435     }
436 
437     protected String extensionsPattern(String[] extensions) {
438         return ".*\\.(" + Joiner.on('|').join(extensions) + ")$";
439     }
440 
441     protected String[] excludedUrlExtensions() {
442         return new String[]{
443                 // native libs
444                 "dylib", "dll", "jnilib",
445                 // some people end up with .pom files on their classpaths
446                 "pom"
447         };
448     }
449 
450     protected String[] excludedResourcesExtensions() {
451         return new String[]{
452                 "package\\.html", "java", "jar",
453                 // native libs (quite possibly covered by the com.* excludes but eh)
454                 "dylib", "dll", "jnilib"
455         };
456     }
457 
458     protected String[] excludedPackages() {
459         return new String[]{
460                 "META-INF",
461                 "com.oracle.java",
462                 "com.oracle.tools",
463                 "com.sun",
464                 "sun",
465                 "oracle", // Only found /oracle/jrockit/jfr/settings/jfc.xsd in my tests but eh
466                 "java",
467                 "javax",
468                 "jdk",
469                 "org.apache", // TODO is this too strict or arbitrary ?
470                 "lombok",
471                 "VAADIN",
472                 "gwt-unitCache"
473         };
474     }
475 
476     /**
477      * This method is not creating the directory structure anymore.
478      *
479      * @deprecated since 5.4.4 - use {@link #newClasspathResource(String, boolean)} instead.
480      */
481     @Deprecated
482     protected ClasspathResource createResourcesFor(final String resourcePath) {
483         return newClasspathResource(resourcePath, true);
484     }
485 
486     protected ClasspathResource newClasspathResource(String path, boolean isFile) {
487         final String validatedPath = validatePath(path);
488         final ClasspathResource resource = new ClasspathResource(this, validatedPath, isFile);
489         resourceCache.put(validatedPath, resource);
490         return resource;
491     }
492 
493     /**
494      * Verifies the given path is as-expected by this origin, and "prepares" it.
495      *
496      * <p>{@link ClasspathResourceOrigin} expects a leading {@code "/"} and therefore, if leading {@code "/"} is not
497      * present, then this method will add it.</p>
498      */
499     protected String validatePath(String resource) {
500         if (resource.startsWith("/")) {
501             return resource;
502         } else {
503             return "/" + resource;
504         }
505     }
506 
507     private class ChildFinder {
508 
509         private final String parentPath;
510         private final Set<String> directories = Sets.newHashSet();
511         private final Set<String> directChildren = Sets.newHashSet();
512 
513         private ChildFinder(String initialParentPath) {
514             if (StringUtils.isNotEmpty(initialParentPath)) {
515                 this.parentPath = initialParentPath.endsWith("/") ? initialParentPath : initialParentPath + "/";
516             } else {
517                 this.parentPath = initialParentPath;
518             }
519 
520             doIterate();
521         }
522 
523         private void doIterate() {
524             for (String resourcePath : resourceCache.keySet()) {
525                 if (isDirectChild(resourcePath)) {
526                     directChildren.add(decoratePath(resourcePath));
527                 }
528             }
529         }
530 
531         private boolean isDirectChild(String path) {
532             String relativePath = path;
533             if (!parentPath.isEmpty()) {
534                 if (!relativePath.startsWith(parentPath)) {
535                     return false;
536                 }
537                 // make relative
538                 relativePath = StringUtils.substringAfter(path, parentPath);
539             }
540 
541             if (relativePath.contains("/")) {
542                 directories.add(decoratePath(parentPath + StringUtils.substringBefore(relativePath, "/")));
543                 return false;
544             }
545 
546             return !path.equals(parentPath);
547         }
548 
549         Set<String> getDirectories() {
550             return directories;
551         }
552 
553         Set<String> getDirectChildren() {
554             return directChildren;
555         }
556     }
557 
558     /**
559      * This method is adding the prefix to given resource path if its required.
560      *
561      * <p>For {@link ClasspathResourceOrigin}, there will be no prefix added, however, for other origins, it might be
562      * needed, e.g. for origin {@link LegacyClasspathResourceOrigin} prefix
563      * {@link LegacyClasspathResourceOrigin#LEGACY_PREFIX} will be added.</p>
564      */
565     String decoratePath(String resourcePath) {
566         return resourcePath;
567     }
568 
569     private IllegalStateException shouldNotBeCalled() {
570         return new IllegalStateException("This method should not be called, it is implemented directly by ClasspathResource");
571     }
572 
573     /**
574      * This method is used for stripping the leading slash if its present.
575      *
576      * <p>{@link Resources#getResource(String)} expects resources path without a leading slash, therefore this is
577      * conflicting with origin {@link ClasspathResourceOrigin} logic of having a leading slash.</p>
578      */
579     private String stripLeadingSlash(String path) {
580         if (path.startsWith("/")) {
581             return path.substring(1);
582         }
583         return path;
584     }
585 }