001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH (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.URL;
037import java.net.URLConnection;
038
039import javax.servlet.http.HttpServletRequest;
040import javax.servlet.http.HttpServletResponse;
041
042import org.apache.commons.logging.Log;
043
044/**
045 * Handles the requests for static resources located in the classpath.<p>
046 */
047public class CmsStaticResourceHandler implements I_CmsRequestHandler {
048
049    /** The handler name. */
050    public static final String HANDLER_NAME = "Static";
051
052    /** The default output buffer size. */
053    private static final int DEFAULT_BUFFER_SIZE = 32 * 1024;
054
055    /** Default cache lifetime in seconds. */
056    private static final int DEFAULT_CACHE_TIME = 3600;
057
058    /** The handler names. */
059    private static final String[] HANDLER_NAMES = new String[] {HANDLER_NAME};
060
061    /** The log object for this class. */
062    private static final Log LOG = CmsLog.getLog(CmsStaticResourceHandler.class);
063
064    /** The opencms path prefix for static resources. */
065    private static final String OPENCMS_PATH_PREFIX = "OPENCMS/";
066
067    /**
068     * Returns the URL to a static resource.<p>
069     *
070     * @param resourcePath the static resource path
071     *
072     * @return the resource URL
073     */
074    public static URL getStaticResourceURL(String resourcePath) {
075
076        URL resourceURL = null;
077        String prefix = OpenCmsServlet.HANDLE_PATH + HANDLER_NAME + "/";
078        int index = resourcePath.indexOf(prefix);
079        if (index >= 0) {
080            String path = resourcePath.substring(index + prefix.length());
081            path = CmsStringUtil.joinPaths(OPENCMS_PATH_PREFIX, path);
082            resourceURL = OpenCms.getSystemInfo().getClass().getClassLoader().getResource(path);
083        }
084        return resourceURL;
085    }
086
087    /**
088     * @see org.opencms.main.I_CmsRequestHandler#getHandlerNames()
089     */
090    public String[] getHandlerNames() {
091
092        return HANDLER_NAMES;
093    }
094
095    /**
096     * @see org.opencms.main.I_CmsRequestHandler#handle(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, java.lang.String)
097     */
098    public void handle(HttpServletRequest request, HttpServletResponse response, String name) throws IOException {
099
100        String path = OpenCmsCore.getInstance().getPathInfo(request);
101        URL resourceURL = getStaticResourceURL(path);
102        if (resourceURL != null) {
103            setResponseHeaders(request, response, path, resourceURL);
104            writeStaticResourceResponse(request, response, resourceURL);
105        } else {
106            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
107        }
108    }
109
110    /**
111     * Returns whether this servlet should attempt to serve a precompressed
112     * version of the given static resource. If this method returns true, the
113     * suffix {@code .gz} is appended to the URL and the corresponding resource
114     * is served if it exists. It is assumed that the compression method used is
115     * gzip. If this method returns false or a compressed version is not found,
116     * the original URL is used.<p>
117     *
118     * The base implementation of this method returns true if and only if the
119     * request indicates that the client accepts gzip compressed responses and
120     * the filename extension of the requested resource is .js, .css, or .html.<p>
121     *
122     * @param request the request for the resource
123     * @param url the URL of the requested resource
124     *
125     * @return true if the servlet should attempt to serve a precompressed version of the resource, false otherwise
126     */
127    protected boolean allowServePrecompressedResource(HttpServletRequest request, String url) {
128
129        String accept = request.getHeader("Accept-Encoding");
130        return (accept != null)
131            && accept.contains("gzip")
132            && (url.endsWith(".js") || url.endsWith(".css") || url.endsWith(".html"));
133    }
134
135    /**
136     * Calculates the cache lifetime for the given filename in seconds. By
137     * default filenames containing ".nocache." return 0, filenames containing
138     * ".cache." return one year, all other return the value defined in the
139     * web.xml using resourceCacheTime (defaults to 1 hour).<p>
140     *
141     * @param filename the file name
142     *
143     * @return cache lifetime for the given filename in seconds
144     */
145    protected int getCacheTime(String filename) {
146
147        /*
148         * GWT conventions:
149         *
150         * - files containing .nocache. will not be cached.
151         *
152         * - files containing .cache. will be cached for one year.
153         *
154         * https://developers.google.com/web-toolkit/doc/latest/
155         * DevGuideCompilingAndDebugging#perfect_caching
156         */
157        if (filename.contains(".nocache.")) {
158            return 0;
159        }
160        if (filename.contains(".cache.")) {
161            return 60 * 60 * 24 * 365;
162        }
163        /*
164         * For all other files, the browser is allowed to cache for 1 hour
165         * without checking if the file has changed. This forces browsers to
166         * fetch a new version when the Vaadin version is updated. This will
167         * cause more requests to the servlet than without this but for high
168         * volume sites the static files should never be served through the
169         * servlet.
170         */
171        return DEFAULT_CACHE_TIME;
172    }
173
174    /**
175     * Sets the response headers.<p>
176     *
177     * @param request the request
178     * @param response the response
179     * @param filename the file name
180     * @param resourceURL the resource URL
181     */
182    protected void setResponseHeaders(
183        HttpServletRequest request,
184        HttpServletResponse response,
185        String filename,
186        URL resourceURL) {
187
188        String cacheControl = "public, max-age=0, must-revalidate";
189        int resourceCacheTime = getCacheTime(filename);
190        if (resourceCacheTime > 0) {
191            cacheControl = "max-age=" + String.valueOf(resourceCacheTime);
192        }
193        response.setHeader("Cache-Control", cacheControl);
194        response.setDateHeader("Expires", System.currentTimeMillis() + (resourceCacheTime * 1000));
195
196        // Find the modification timestamp
197        long lastModifiedTime = 0;
198        URLConnection connection = null;
199        try {
200            connection = resourceURL.openConnection();
201            lastModifiedTime = connection.getLastModified();
202            // Remove milliseconds to avoid comparison problems (milliseconds
203            // are not returned by the browser in the "If-Modified-Since"
204            // header).
205            lastModifiedTime = lastModifiedTime - (lastModifiedTime % 1000);
206            response.setDateHeader("Last-Modified", lastModifiedTime);
207
208            if (browserHasNewestVersion(request, lastModifiedTime)) {
209                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
210                return;
211            }
212        } catch (Exception e) {
213            // Failed to find out last modified timestamp. Continue without it.
214            LOG.debug("Failed to find out last modified timestamp. Continuing without it.", e);
215        } finally {
216            try {
217                if (connection != null) {
218                    // Explicitly close the input stream to prevent it
219                    // from remaining hanging
220                    // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4257700
221                    InputStream is = connection.getInputStream();
222                    if (is != null) {
223                        is.close();
224                    }
225                }
226            } catch (Exception e) {
227                LOG.info("Error closing URLConnection input stream", e);
228            }
229        }
230
231        // Set type mime type if we can determine it based on the filename
232        String mimetype = OpenCms.getResourceManager().getMimeType(filename, "UTF-8");
233        if (mimetype != null) {
234            response.setContentType(mimetype);
235        }
236    }
237
238    /**
239     * Writes the contents of the given resourceUrl in the response. Can be
240     * overridden to add/modify response headers and similar.<p>
241     *
242     * @param request the request for the resource
243     * @param response the response
244     * @param resourceUrl the url to send
245     *
246     * @throws IOException in case writing the response fails
247     */
248    protected void writeStaticResourceResponse(
249        HttpServletRequest request,
250        HttpServletResponse response,
251        URL resourceUrl) throws IOException {
252
253        URLConnection connection = null;
254        InputStream is = null;
255        String urlStr = resourceUrl.toExternalForm();
256        try {
257            if (allowServePrecompressedResource(request, urlStr)) {
258                // try to serve a precompressed version if available
259                try {
260                    connection = new URL(urlStr + ".gz").openConnection();
261                    is = connection.getInputStream();
262                    // set gzip headers
263                    response.setHeader("Content-Encoding", "gzip");
264                } catch (Exception e) {
265                    LOG.debug("Unexpected exception looking for gzipped version of resource " + urlStr, e);
266                }
267            }
268            if (is == null) {
269                // precompressed resource not available, get non compressed
270                connection = resourceUrl.openConnection();
271                try {
272                    is = connection.getInputStream();
273                } catch (FileNotFoundException e) {
274                    LOG.debug(e.getMessage(), e);
275                    response.setStatus(HttpServletResponse.SC_NOT_FOUND);
276                    return;
277                }
278            }
279
280            try {
281                @SuppressWarnings("null")
282                int length = connection.getContentLength();
283                if (length >= 0) {
284                    response.setContentLength(length);
285                }
286            } catch (Throwable e) {
287                LOG.debug(e.getMessage(), e);
288                // This can be ignored, content length header is not required.
289                // Need to close the input stream because of
290                // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4257700 to
291                // prevent it from hanging, but that is done below.
292            }
293
294            streamContent(response, is);
295        } finally {
296            if (is != null) {
297                is.close();
298            }
299        }
300    }
301
302    /**
303     * Checks if the browser has an up to date cached version of requested
304     * resource. Currently the check is performed using the "If-Modified-Since"
305     * header. Could be expanded if needed.<p>
306     *
307     * @param request the HttpServletRequest from the browser
308     * @param resourceLastModifiedTimestamp the timestamp when the resource was last modified. 0 if the last modification time is unknown
309     *
310     * @return true if the If-Modified-Since header tells the cached version in the browser is up to date, false otherwise
311     */
312    private boolean browserHasNewestVersion(HttpServletRequest request, long resourceLastModifiedTimestamp) {
313
314        if (resourceLastModifiedTimestamp < 1) {
315            // We do not know when it was modified so the browser cannot have an
316            // up-to-date version
317            return false;
318        }
319        /*
320         * The browser can request the resource conditionally using an
321         * If-Modified-Since header. Check this against the last modification
322         * time.
323         */
324        try {
325            // If-Modified-Since represents the timestamp of the version cached
326            // in the browser
327            long headerIfModifiedSince = request.getDateHeader("If-Modified-Since");
328
329            if (headerIfModifiedSince >= resourceLastModifiedTimestamp) {
330                // Browser has this an up-to-date version of the resource
331                return true;
332            }
333        } catch (@SuppressWarnings("unused") Exception e) {
334            // Failed to parse header. Fail silently - the browser does not have
335            // an up-to-date version in its cache.
336        }
337        return false;
338    }
339
340    /**
341     * Streams the input stream to the response.<p>
342     *
343     * @param response the response
344     * @param is the input stream
345     *
346     * @throws IOException in case writing to the response fails
347     */
348    private void streamContent(HttpServletResponse response, InputStream is) throws IOException {
349
350        OutputStream os = response.getOutputStream();
351        try {
352            byte buffer[] = new byte[DEFAULT_BUFFER_SIZE];
353            int bytes;
354            while ((bytes = is.read(buffer)) >= 0) {
355                os.write(buffer, 0, bytes);
356            }
357        } finally {
358            os.close();
359        }
360    }
361}