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