1 /*
2  * Licensed to the Apache Software Foundation (ASF) under one or more
3  * contributor license agreements.  See the NOTICE file distributed with
4  * this work for additional information regarding copyright ownership.
5  * The ASF licenses this file to You under the Apache License, Version 2.0
6  * (the "License"); you may not use this file except in compliance with
7  * the License.  You may obtain a copy of the License at
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  * Unless required by applicable law or agreed to in writing, software
12  * distributed under the License is distributed on an "AS IS" BASIS,
13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  * See the License for the specific language governing permissions and
15  * limitations under the License.
16  */

17 package org.apache.catalina.webresources;
18
19 import java.io.ByteArrayInputStream;
20 import java.io.IOException;
21 import java.io.InputStream;
22 import java.net.JarURLConnection;
23 import java.net.MalformedURLException;
24 import java.net.URL;
25 import java.net.URLConnection;
26 import java.net.URLStreamHandler;
27 import java.nio.charset.Charset;
28 import java.security.Permission;
29 import java.security.cert.Certificate;
30 import java.text.Collator;
31 import java.util.Arrays;
32 import java.util.Locale;
33 import java.util.jar.JarEntry;
34 import java.util.jar.JarFile;
35 import java.util.jar.Manifest;
36
37 import org.apache.catalina.WebResource;
38 import org.apache.catalina.WebResourceRoot;
39 import org.apache.juli.logging.Log;
40 import org.apache.juli.logging.LogFactory;
41 import org.apache.tomcat.util.res.StringManager;
42
43 /**
44  * This class is designed to wrap a 'raw' WebResource and providing caching for
45  * expensive operations. Inexpensive operations may be passed through to the
46  * underlying resource.
47  */

48 public class CachedResource implements WebResource {
49
50     private static final Log log = LogFactory.getLog(CachedResource.class);
51     private static final StringManager sm = StringManager.getManager(CachedResource.class);
52
53     // Estimate (on high side to be safe) of average size excluding content
54     // based on profiler data.
55     private static final long CACHE_ENTRY_SIZE = 500;
56
57     private final Cache cache;
58     private final StandardRoot root;
59     private final String webAppPath;
60     private final long ttl;
61     private final int objectMaxSizeBytes;
62     private final boolean usesClassLoaderResources;
63
64     private volatile WebResource webResource;
65     private volatile WebResource[] webResources;
66     private volatile long nextCheck;
67
68     private volatile Long cachedLastModified = null;
69     private volatile String cachedLastModifiedHttp = null;
70     private volatile byte[] cachedContent = null;
71     private volatile Boolean cachedIsFile = null;
72     private volatile Boolean cachedIsDirectory = null;
73     private volatile Boolean cachedExists = null;
74     private volatile Boolean cachedIsVirtual = null;
75     private volatile Long cachedContentLength = null;
76
77
78     public CachedResource(Cache cache, StandardRoot root, String path, long ttl,
79             int objectMaxSizeBytes, boolean usesClassLoaderResources) {
80         this.cache = cache;
81         this.root = root;
82         this.webAppPath = path;
83         this.ttl = ttl;
84         this.objectMaxSizeBytes = objectMaxSizeBytes;
85         this.usesClassLoaderResources = usesClassLoaderResources;
86     }
87
88     protected boolean validateResource(boolean useClassLoaderResources) {
89         // It is possible that some resources will only be visible for a given
90         // value of useClassLoaderResources. Therefore, if the lookup is made
91         // with a different value of useClassLoaderResources than was used when
92         // creating the cache entry, invalidate the entry. This should have
93         // minimal performance impact as it would be unusual for a resource to
94         // be looked up both as a static resource and as a class loader
95         // resource.
96         if (usesClassLoaderResources != useClassLoaderResources) {
97             return false;
98         }
99
100         long now = System.currentTimeMillis();
101
102         if (webResource == null) {
103             synchronized (this) {
104                 if (webResource == null) {
105                     webResource = root.getResourceInternal(
106                             webAppPath, useClassLoaderResources);
107                     getLastModified();
108                     getContentLength();
109                     nextCheck = ttl + now;
110                     // exists() is a relatively expensive check for a file so
111                     // use the fact that we know if it exists at this point
112                     if (webResource instanceof EmptyResource) {
113                         cachedExists = Boolean.FALSE;
114                     } else {
115                         cachedExists = Boolean.TRUE;
116                     }
117                     return true;
118                 }
119             }
120         }
121
122         if (now < nextCheck) {
123             return true;
124         }
125
126         // Assume resources inside WARs will not change
127         if (!root.isPackedWarFile()) {
128             WebResource webResourceInternal = root.getResourceInternal(
129                     webAppPath, useClassLoaderResources);
130             if (!webResource.exists() && webResourceInternal.exists()) {
131                 return false;
132             }
133
134             // If modified date or length change - resource has changed / been
135             // removed etc.
136             if (webResource.getLastModified() != getLastModified() ||
137                     webResource.getContentLength() != getContentLength()) {
138                 return false;
139             }
140
141             // Has a resource been inserted / removed in a different resource set
142             if (webResource.getLastModified() != webResourceInternal.getLastModified() ||
143                     webResource.getContentLength() != webResourceInternal.getContentLength()) {
144                 return false;
145             }
146         }
147
148         nextCheck = ttl + now;
149         return true;
150     }
151
152     protected boolean validateResources(boolean useClassLoaderResources) {
153         long now = System.currentTimeMillis();
154
155         if (webResources == null) {
156             synchronized (this) {
157                 if (webResources == null) {
158                     webResources = root.getResourcesInternal(
159                             webAppPath, useClassLoaderResources);
160                     nextCheck = ttl + now;
161                     return true;
162                 }
163             }
164         }
165
166         if (now < nextCheck) {
167             return true;
168         }
169
170         // Assume resources inside WARs will not change
171         if (root.isPackedWarFile()) {
172             nextCheck = ttl + now;
173             return true;
174         } else {
175             // At this point, always expire the entry and re-populating it is
176             // likely to be as expensive as validating it.
177             return false;
178         }
179     }
180
181     protected long getNextCheck() {
182         return nextCheck;
183     }
184
185     @Override
186     public long getLastModified() {
187         Long cachedLastModified = this.cachedLastModified;
188         if (cachedLastModified == null) {
189             cachedLastModified =
190                     Long.valueOf(webResource.getLastModified());
191             this.cachedLastModified = cachedLastModified;
192         }
193         return cachedLastModified.longValue();
194     }
195
196     @Override
197     public String getLastModifiedHttp() {
198         String cachedLastModifiedHttp = this.cachedLastModifiedHttp;
199         if (cachedLastModifiedHttp == null) {
200             cachedLastModifiedHttp = webResource.getLastModifiedHttp();
201             this.cachedLastModifiedHttp = cachedLastModifiedHttp;
202         }
203         return cachedLastModifiedHttp;
204     }
205
206     @Override
207     public boolean exists() {
208         Boolean cachedExists = this.cachedExists;
209         if (cachedExists == null) {
210             cachedExists = Boolean.valueOf(webResource.exists());
211             this.cachedExists = cachedExists;
212         }
213         return cachedExists.booleanValue();
214     }
215
216     @Override
217     public boolean isVirtual() {
218         Boolean cachedIsVirtual = this.cachedIsVirtual;
219         if (cachedIsVirtual == null) {
220             cachedIsVirtual = Boolean.valueOf(webResource.isVirtual());
221             this.cachedIsVirtual = cachedIsVirtual;
222         }
223         return cachedIsVirtual.booleanValue();
224     }
225
226     @Override
227     public boolean isDirectory() {
228         Boolean cachedIsDirectory = this.cachedIsDirectory;
229         if (cachedIsDirectory == null) {
230             cachedIsDirectory = Boolean.valueOf(webResource.isDirectory());
231             this.cachedIsDirectory = cachedIsDirectory;
232         }
233         return cachedIsDirectory.booleanValue();
234     }
235
236     @Override
237     public boolean isFile() {
238         Boolean cachedIsFile = this.cachedIsFile;
239         if (cachedIsFile == null) {
240             cachedIsFile = Boolean.valueOf(webResource.isFile());
241             this.cachedIsFile = cachedIsFile;
242         }
243         return cachedIsFile.booleanValue();
244     }
245
246     @Override
247     public boolean delete() {
248         boolean deleteResult = webResource.delete();
249         if (deleteResult) {
250             cache.removeCacheEntry(webAppPath);
251         }
252         return deleteResult;
253     }
254
255     @Override
256     public String getName() {
257         return webResource.getName();
258     }
259
260     @Override
261     public long getContentLength() {
262         Long cachedContentLength = this.cachedContentLength;
263         if (cachedContentLength == null) {
264             long result = 0;
265             if (webResource != null) {
266                 result = webResource.getContentLength();
267                 cachedContentLength = Long.valueOf(result);
268                 this.cachedContentLength = cachedContentLength;
269             }
270             return result;
271         }
272         return cachedContentLength.longValue();
273     }
274
275     @Override
276     public String getCanonicalPath() {
277         return webResource.getCanonicalPath();
278     }
279
280     @Override
281     public boolean canRead() {
282         return webResource.canRead();
283     }
284
285     @Override
286     public String getWebappPath() {
287         return webAppPath;
288     }
289
290     @Override
291     public String getETag() {
292         return webResource.getETag();
293     }
294
295     @Override
296     public void setMimeType(String mimeType) {
297         webResource.setMimeType(mimeType);
298     }
299
300     @Override
301     public String getMimeType() {
302         return webResource.getMimeType();
303     }
304
305     @Override
306     public InputStream getInputStream() {
307         byte[] content = getContent();
308         if (content == null) {
309             // Can't cache InputStreams
310             return webResource.getInputStream();
311         }
312         return new ByteArrayInputStream(content);
313     }
314
315     @Override
316     public byte[] getContent() {
317         byte[] cachedContent = this.cachedContent;
318         if (cachedContent == null) {
319             if (getContentLength() > objectMaxSizeBytes) {
320                 return null;
321             }
322             cachedContent = webResource.getContent();
323             this.cachedContent = cachedContent;
324         }
325         return cachedContent;
326     }
327
328     @Override
329     public long getCreation() {
330         return webResource.getCreation();
331     }
332
333     @Override
334     public URL getURL() {
335         /*
336          * We don't want applications using this URL to access the resource
337          * directly as that could lead to inconsistent results when the resource
338          * is updated on the file system but the cache entry has not yet
339          * expired. We saw thisfor example, in JSP compilation.
340          * - last modified time was obtained via
341          *   ServletContext.getResource("path").openConnection().getLastModified()
342          * - JSP content was obtained via
343          *   ServletContext.getResourceAsStream("path")
344          * The result was that the JSP modification was detected but the JSP
345          * content was read from the cache so the non-updated JSP page was
346          * used to generate the .java and .class file
347          *
348          * One option to resolve this issue is to use a custom URL scheme for
349          * resource URLs. This would allow us, via registration of a
350          * URLStreamHandlerFactory, to control how the resources are accessed
351          * and ensure that all access go via the cache We took this approach for
352          * war: URLs so we can use jar:war:file: URLs to reference resources in
353          * unpacked WAR files. However, because URL.setURLStreamHandlerFactory()
354          * may only be caused once, this can cause problems when using other
355          * libraries that also want to use a custom URL scheme.
356          *
357          * The approach below allows us to insert a custom URLStreamHandler
358          * without registering a custom protocol. The only limitation (compared
359          * to registering a custom protocol) is that if the application
360          * constructs the same URL from a String, they will access the resource
361          * directly and not via the cache.
362          */

363         URL resourceURL = webResource.getURL();
364         if (resourceURL == null) {
365             return null;
366         }
367         try {
368             CachedResourceURLStreamHandler handler =
369                     new CachedResourceURLStreamHandler(resourceURL, root, webAppPath, usesClassLoaderResources);
370             URL result = new URL(null, resourceURL.toExternalForm(), handler);
371             handler.setAssociatedURL(result);
372             return result;
373         } catch (MalformedURLException e) {
374             log.error(sm.getString("cachedResource.invalidURL", resourceURL.toExternalForm()), e);
375             return null;
376         }
377     }
378
379     @Override
380     public URL getCodeBase() {
381         return webResource.getCodeBase();
382     }
383
384     @Override
385     public Certificate[] getCertificates() {
386         return webResource.getCertificates();
387     }
388
389     @Override
390     public Manifest getManifest() {
391         return webResource.getManifest();
392     }
393
394     @Override
395     public WebResourceRoot getWebResourceRoot() {
396         return webResource.getWebResourceRoot();
397     }
398
399     WebResource getWebResource() {
400         return webResource;
401     }
402
403     WebResource[] getWebResources() {
404         return webResources;
405     }
406
407     // Assume that the cache entry will always include the content unless the
408     // resource content is larger than objectMaxSizeBytes. This isn't always the
409     // case but it makes tracking the current cache size easier.
410     long getSize() {
411         long result = CACHE_ENTRY_SIZE;
412         if (getContentLength() <= objectMaxSizeBytes) {
413             result += getContentLength();
414         }
415         return result;
416     }
417
418
419     /*
420      * Mimics the behaviour of FileURLConnection.getInputStream for a directory.
421      * Deliberately uses default locale.
422      */

423     private static InputStream buildInputStream(String[] files) {
424         Arrays.sort(files, Collator.getInstance(Locale.getDefault()));
425         StringBuilder result = new StringBuilder();
426         for (String file : files) {
427             result.append(file);
428             // Every entry is followed by \n including the last
429             result.append('\n');
430         }
431         return new ByteArrayInputStream(result.toString().getBytes(Charset.defaultCharset()));
432     }
433
434
435     private static class CachedResourceURLStreamHandler extends URLStreamHandler {
436
437         private final URL resourceURL;
438         private final StandardRoot root;
439         private final String webAppPath;
440         private final boolean usesClassLoaderResources;
441
442         private URL associatedURL = null;
443
444         public CachedResourceURLStreamHandler(URL resourceURL, StandardRoot root, String webAppPath,
445                 boolean usesClassLoaderResources) {
446             this.resourceURL = resourceURL;
447             this.root = root;
448             this.webAppPath = webAppPath;
449             this.usesClassLoaderResources = usesClassLoaderResources;
450         }
451
452         protected void setAssociatedURL(URL associatedURL) {
453             this.associatedURL = associatedURL;
454         }
455
456         @Override
457         protected URLConnection openConnection(URL u) throws IOException {
458             // This deliberately uses ==. If u isn't the URL object this
459             // URLStreamHandler was constructed for we do not want to use this
460             // URLStreamHandler to create a connection.
461             if (associatedURL != null && u == associatedURL) {
462                 if ("jar".equals(associatedURL.getProtocol())) {
463                     return new CachedResourceJarURLConnection(resourceURL, root, webAppPath, usesClassLoaderResources);
464                 } else {
465                     return new CachedResourceURLConnection(resourceURL, root, webAppPath, usesClassLoaderResources);
466                 }
467             } else {
468                 // The stream handler has been inherited by a URL that was
469                 // constructed from a cache URL. We need to break that link.
470                 URL constructedURL = new URL(u.toExternalForm());
471                 return constructedURL.openConnection();
472             }
473         }
474     }
475
476
477     /*
478      * Keep this in sync with CachedResourceJarURLConnection.
479      */

480     private static class CachedResourceURLConnection extends URLConnection {
481
482         private final StandardRoot root;
483         private final String webAppPath;
484         private final boolean usesClassLoaderResources;
485         private final URL resourceURL;
486
487         protected CachedResourceURLConnection(URL resourceURL, StandardRoot root, String webAppPath,
488                 boolean usesClassLoaderResources) {
489             super(resourceURL);
490             this.root = root;
491             this.webAppPath = webAppPath;
492             this.usesClassLoaderResources = usesClassLoaderResources;
493             this.resourceURL = resourceURL;
494         }
495
496         @Override
497         public void connect() throws IOException {
498             // NO-OP
499         }
500
501         @Override
502         public InputStream getInputStream() throws IOException {
503             WebResource resource = getResource();
504             if (resource.isDirectory()) {
505                 return buildInputStream(resource.getWebResourceRoot().list(webAppPath));
506             } else {
507                 return getResource().getInputStream();
508             }
509         }
510
511         @Override
512         public Permission getPermission() throws IOException {
513             // Doesn't trigger a call to connect for file:// URLs
514             return resourceURL.openConnection().getPermission();
515         }
516
517         @Override
518         public long getLastModified() {
519             return getResource().getLastModified();
520         }
521
522         @Override
523         public long getContentLengthLong() {
524             return getResource().getContentLength();
525         }
526
527         private WebResource getResource() {
528             return root.getResource(webAppPath, false, usesClassLoaderResources);
529         }
530     }
531
532
533     /*
534      * Keep this in sync with CachedResourceURLConnection.
535      */

536     private static class CachedResourceJarURLConnection extends JarURLConnection {
537
538         private final StandardRoot root;
539         private final String webAppPath;
540         private final boolean usesClassLoaderResources;
541         private final URL resourceURL;
542
543         protected CachedResourceJarURLConnection(URL resourceURL, StandardRoot root, String webAppPath,
544                 boolean usesClassLoaderResources) throws IOException {
545             super(resourceURL);
546             this.root = root;
547             this.webAppPath = webAppPath;
548             this.usesClassLoaderResources = usesClassLoaderResources;
549             this.resourceURL = resourceURL;
550         }
551
552         @Override
553         public void connect() throws IOException {
554             // NO-OP
555         }
556
557         @Override
558         public InputStream getInputStream() throws IOException {
559             WebResource resource = getResource();
560             if (resource.isDirectory()) {
561                 return buildInputStream(resource.getWebResourceRoot().list(webAppPath));
562             } else {
563                 return getResource().getInputStream();
564             }
565         }
566
567         @Override
568         public Permission getPermission() throws IOException {
569             // Doesn't trigger a call to connect for jar:// URLs
570             return resourceURL.openConnection().getPermission();
571         }
572
573         @Override
574         public long getLastModified() {
575             return getResource().getLastModified();
576         }
577
578         @Override
579         public long getContentLengthLong() {
580             return getResource().getContentLength();
581         }
582
583         private WebResource getResource() {
584             return root.getResource(webAppPath, false, usesClassLoaderResources);
585         }
586
587         @Override
588         public JarFile getJarFile() throws IOException {
589             return ((JarURLConnection) resourceURL.openConnection()).getJarFile();
590         }
591
592         @Override
593         public JarEntry getJarEntry() throws IOException {
594             if (getEntryName() == null) {
595                 return null;
596             } else {
597                 return super.getJarEntry();
598             }
599         }
600     }
601 }
602