001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: http://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: http://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.main;
029
030import org.opencms.util.CmsStringUtil;
031
032import java.io.FileNotFoundException;
033import java.io.IOException;
034import java.io.InputStream;
035import java.io.OutputStream;
036import java.net.URI;
037import java.net.URL;
038import java.net.URLConnection;
039
040import javax.servlet.http.HttpServletRequest;
041import javax.servlet.http.HttpServletResponse;
042
043import org.apache.commons.logging.Log;
044
045/**
046 * Handles the requests for static resources located in the classpath.<p>
047 */
048public class CmsStaticResourceHandler implements I_CmsRequestHandler {
049
050    /** The handler name. */
051    public static final String HANDLER_NAME = "Static";
052
053    /** The static resource prefix '/handleStatic'. */
054    public static final String STATIC_RESOURCE_PREFIX = OpenCmsServlet.HANDLE_PATH + HANDLER_NAME;
055
056    /** The default output buffer size. */
057    private static final int DEFAULT_BUFFER_SIZE = 32 * 1024;
058
059    /** Default cache lifetime in seconds. */
060    private static final int DEFAULT_CACHE_TIME = 3600;
061
062    /** The handler names. */
063    private static final String[] HANDLER_NAMES = new String[] {HANDLER_NAME};
064
065    /** The log object for this class. */
066    private static final Log LOG = CmsLog.getLog(CmsStaticResourceHandler.class);
067
068    /** The regular expression to remove the static resource path prefix. */
069    private static String m_removePrefixRegex;
070
071    /** The regular expression to identify static resource paths. */
072    private static String m_staticResourceRegex;
073
074    /** The opencms path prefix for static resources. */
075    private static final String OPENCMS_PATH_PREFIX = "OPENCMS/";
076
077    /**
078     * Returns the context for static resources served from the class path, e.g. "/opencms/handleStatic/v5976v".<p>
079     *
080     * @param opencmsContext the OpenCms context
081     * @param opencmsVersion the OpenCms version
082     *
083     * @return the static resource context
084     */
085    public static String getStaticResourceContext(String opencmsContext, String opencmsVersion) {
086
087        return opencmsContext + STATIC_RESOURCE_PREFIX + "/v" + opencmsVersion.hashCode() + "v";
088    }
089
090    /**
091     * Returns the URL to a static resource.<p>
092     *
093     * @param resourcePath the static resource path
094     *
095     * @return the resource URL
096     */
097    public static URL getStaticResourceURL(String resourcePath) {
098
099        URL resourceURL = null;
100        if (isStaticResourceUri(resourcePath)) {
101            String path = removeStaticResourcePrefix(resourcePath);
102            path = CmsStringUtil.joinPaths(OPENCMS_PATH_PREFIX, path);
103            resourceURL = OpenCms.getSystemInfo().getClass().getClassLoader().getResource(path);
104        }
105        return resourceURL;
106    }
107
108    /**
109     * Returns if the given URI points to a static resource.<p>
110     *
111     * @param path the path to test
112     *
113     * @return <code>true</code> in case the given URI points to a static resource
114     */
115    public static boolean isStaticResourceUri(String path) {
116
117        return (path != null) && path.matches(getStaticResourceRegex());
118
119    }
120
121    /**
122     * Returns if the given URI points to a static resource.<p>
123     *
124     * @param uri the URI to test
125     *
126     * @return <code>true</code> in case the given URI points to a static resource
127     */
128    public static boolean isStaticResourceUri(URI uri) {
129
130        return (uri != null) && isStaticResourceUri(uri.getPath());
131
132    }
133
134    /**
135     * Removes the static resource path prefix.<p>
136     *
137     * @param path the path
138     *
139     * @return the modified path
140     */
141    public static String removeStaticResourcePrefix(String path) {
142
143        return path.replaceFirst(getRemovePrefixRegex(), "");
144    }
145
146    /**
147     * Returns the regular expression to remove the static resource path prefix.<p>
148     *
149     * @return the regular expression to remove the static resource path prefix
150     */
151    private static String getRemovePrefixRegex() {
152
153        if (m_removePrefixRegex == null) {
154            m_removePrefixRegex = "^("
155                + OpenCms.getStaticExportManager().getVfsPrefix()
156                + ")?"
157                + STATIC_RESOURCE_PREFIX
158                + "(/v-?\\d+v/)?";
159        }
160        return m_removePrefixRegex;
161    }
162
163    /**
164     * Returns the regular expression to identify static resource paths.<p>
165     *
166     * @return the regular expression to identify static resource paths
167     */
168    private static String getStaticResourceRegex() {
169
170        if (m_staticResourceRegex == null) {
171            m_staticResourceRegex = getRemovePrefixRegex() + ".*";
172        }
173        return m_staticResourceRegex;
174    }
175
176    /**
177     * @see org.opencms.main.I_CmsRequestHandler#getHandlerNames()
178     */
179    public String[] getHandlerNames() {
180
181        return HANDLER_NAMES;
182    }
183
184    /**
185     * @see org.opencms.main.I_CmsRequestHandler#handle(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, java.lang.String)
186     */
187    public void handle(HttpServletRequest request, HttpServletResponse response, String name) throws IOException {
188
189        String path = OpenCmsCore.getInstance().getPathInfo(request);
190        URL resourceURL = getStaticResourceURL(path);
191        if (resourceURL != null) {
192            setResponseHeaders(request, response, path, resourceURL);
193            writeStaticResourceResponse(request, response, resourceURL);
194        } else {
195            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
196        }
197    }
198
199    /**
200     * Returns whether this servlet should attempt to serve a precompressed
201     * version of the given static resource. If this method returns true, the
202     * suffix {@code .gz} is appended to the URL and the corresponding resource
203     * is served if it exists. It is assumed that the compression method used is
204     * gzip. If this method returns false or a compressed version is not found,
205     * the original URL is used.<p>
206     *
207     * The base implementation of this method returns true if and only if the
208     * request indicates that the client accepts gzip compressed responses and
209     * the filename extension of the requested resource is .js, .css, or .html.<p>
210     *
211     * @param request the request for the resource
212     * @param url the URL of the requested resource
213     *
214     * @return true if the servlet should attempt to serve a precompressed version of the resource, false otherwise
215     */
216    protected boolean allowServePrecompressedResource(HttpServletRequest request, String url) {
217
218        String accept = request.getHeader("Accept-Encoding");
219        return (accept != null)
220            && accept.contains("gzip")
221            && (url.endsWith(".js") || url.endsWith(".css") || url.endsWith(".html"));
222    }
223
224    /**
225     * Calculates the cache lifetime for the given filename in seconds. By
226     * default filenames containing ".nocache." return 0, filenames containing
227     * ".cache." return one year, all other return the value defined in the
228     * web.xml using resourceCacheTime (defaults to 1 hour).<p>
229     *
230     * @param filename the file name
231     *
232     * @return cache lifetime for the given filename in seconds
233     */
234    protected int getCacheTime(String filename) {
235
236        /*
237         * GWT conventions:
238         *
239         * - files containing .nocache. will not be cached.
240         *
241         * - files containing .cache. will be cached for one year.
242         *
243         * https://developers.google.com/web-toolkit/doc/latest/
244         * DevGuideCompilingAndDebugging#perfect_caching
245         */
246        if (filename.contains(".nocache.")) {
247            return 0;
248        }
249        if (filename.contains(".cache.")) {
250            return 60 * 60 * 24 * 365;
251        }
252        /*
253         * For all other files, the browser is allowed to cache for 1 hour
254         * without checking if the file has changed. This forces browsers to
255         * fetch a new version when the Vaadin version is updated. This will
256         * cause more requests to the servlet than without this but for high
257         * volume sites the static files should never be served through the
258         * servlet.
259         */
260        return DEFAULT_CACHE_TIME;
261    }
262
263    /**
264     * Sets the response headers.<p>
265     *
266     * @param request the request
267     * @param response the response
268     * @param filename the file name
269     * @param resourceURL the resource URL
270     */
271    protected void setResponseHeaders(
272        HttpServletRequest request,
273        HttpServletResponse response,
274        String filename,
275        URL resourceURL) {
276
277        String cacheControl = "public, max-age=0, must-revalidate";
278        int resourceCacheTime = getCacheTime(filename);
279        if (resourceCacheTime > 0) {
280            cacheControl = "max-age=" + String.valueOf(resourceCacheTime);
281        }
282        response.setHeader("Cache-Control", cacheControl);
283        response.setDateHeader("Expires", System.currentTimeMillis() + (resourceCacheTime * 1000));
284
285        // Find the modification timestamp
286        long lastModifiedTime = 0;
287        URLConnection connection = null;
288        try {
289            connection = resourceURL.openConnection();
290            lastModifiedTime = connection.getLastModified();
291            // Remove milliseconds to avoid comparison problems (milliseconds
292            // are not returned by the browser in the "If-Modified-Since"
293            // header).
294            lastModifiedTime = lastModifiedTime - (lastModifiedTime % 1000);
295            response.setDateHeader("Last-Modified", lastModifiedTime);
296
297            if (browserHasNewestVersion(request, lastModifiedTime)) {
298                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
299                return;
300            }
301        } catch (Exception e) {
302            // Failed to find out last modified timestamp. Continue without it.
303            LOG.debug("Failed to find out last modified timestamp. Continuing without it.", e);
304        } finally {
305            try {
306                if (connection != null) {
307                    // Explicitly close the input stream to prevent it
308                    // from remaining hanging
309                    // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4257700
310                    InputStream is = connection.getInputStream();
311                    if (is != null) {
312                        is.close();
313                    }
314                }
315            } catch (Exception e) {
316                LOG.info("Error closing URLConnection input stream", e);
317            }
318        }
319
320        // Set type mime type if we can determine it based on the filename
321        String mimetype = OpenCms.getResourceManager().getMimeType(filename, "UTF-8");
322        if (mimetype != null) {
323            response.setContentType(mimetype);
324        }
325    }
326
327    /**
328     * Writes the contents of the given resourceUrl in the response. Can be
329     * overridden to add/modify response headers and similar.<p>
330     *
331     * @param request the request for the resource
332     * @param response the response
333     * @param resourceUrl the url to send
334     *
335     * @throws IOException in case writing the response fails
336     */
337    protected void writeStaticResourceResponse(
338        HttpServletRequest request,
339        HttpServletResponse response,
340        URL resourceUrl)
341    throws IOException {
342
343        URLConnection connection = null;
344        InputStream is = null;
345        String urlStr = resourceUrl.toExternalForm();
346        try {
347            if (allowServePrecompressedResource(request, urlStr)) {
348                // try to serve a precompressed version if available
349                try {
350                    connection = new URL(urlStr + ".gz").openConnection();
351                    is = connection.getInputStream();
352                    // set gzip headers
353                    response.setHeader("Content-Encoding", "gzip");
354                } catch (Exception e) {
355                    LOG.debug("Unexpected exception looking for gzipped version of resource " + urlStr, e);
356                }
357            }
358            if (is == null) {
359                // precompressed resource not available, get non compressed
360                connection = resourceUrl.openConnection();
361                try {
362                    is = connection.getInputStream();
363                } catch (FileNotFoundException e) {
364                    LOG.debug(e.getMessage(), e);
365                    response.setStatus(HttpServletResponse.SC_NOT_FOUND);
366                    return;
367                }
368            }
369
370            try {
371                @SuppressWarnings("null")
372                int length = connection.getContentLength();
373                if (length >= 0) {
374                    response.setContentLength(length);
375                }
376            } catch (Throwable e) {
377                LOG.debug(e.getMessage(), e);
378                // This can be ignored, content length header is not required.
379                // Need to close the input stream because of
380                // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4257700 to
381                // prevent it from hanging, but that is done below.
382            }
383
384            streamContent(response, is);
385        } finally {
386            if (is != null) {
387                is.close();
388            }
389        }
390    }
391
392    /**
393     * Checks if the browser has an up to date cached version of requested
394     * resource. Currently the check is performed using the "If-Modified-Since"
395     * header. Could be expanded if needed.<p>
396     *
397     * @param request the HttpServletRequest from the browser
398     * @param resourceLastModifiedTimestamp the timestamp when the resource was last modified. 0 if the last modification time is unknown
399     *
400     * @return true if the If-Modified-Since header tells the cached version in the browser is up to date, false otherwise
401     */
402    private boolean browserHasNewestVersion(HttpServletRequest request, long resourceLastModifiedTimestamp) {
403
404        if (resourceLastModifiedTimestamp < 1) {
405            // We do not know when it was modified so the browser cannot have an
406            // up-to-date version
407            return false;
408        }
409        /*
410         * The browser can request the resource conditionally using an
411         * If-Modified-Since header. Check this against the last modification
412         * time.
413         */
414        try {
415            // If-Modified-Since represents the timestamp of the version cached
416            // in the browser
417            long headerIfModifiedSince = request.getDateHeader("If-Modified-Since");
418
419            if (headerIfModifiedSince >= resourceLastModifiedTimestamp) {
420                // Browser has this an up-to-date version of the resource
421                return true;
422            }
423        } catch (Exception e) {
424            // Failed to parse header. Fail silently - the browser does not have
425            // an up-to-date version in its cache.
426        }
427        return false;
428    }
429
430    /**
431     * Streams the input stream to the response.<p>
432     *
433     * @param response the response
434     * @param is the input stream
435     *
436     * @throws IOException in case writing to the response fails
437     */
438    private void streamContent(HttpServletResponse response, InputStream is) throws IOException {
439
440        OutputStream os = response.getOutputStream();
441        try {
442            byte buffer[] = new byte[DEFAULT_BUFFER_SIZE];
443            int bytes;
444            while ((bytes = is.read(buffer)) >= 0) {
445                os.write(buffer, 0, bytes);
446            }
447        } finally {
448            os.close();
449        }
450    }
451}