org.chililog.server.workbench.StaticFileRequestHandler.java Source code

Java tutorial

Introduction

Here is the source code for org.chililog.server.workbench.StaticFileRequestHandler.java

Source

//
// Copyright 2010 Cinch Logic Pty Ltd.
//
// http://www.chililog.com
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

package org.chililog.server.workbench;

import static org.jboss.netty.handler.codec.http.HttpHeaders.*;
import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*;
import static org.jboss.netty.handler.codec.http.HttpResponseStatus.*;
import static org.jboss.netty.handler.codec.http.HttpVersion.*;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.RandomAccessFile;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;

import javax.activation.MimetypesFileTypeMap;

import org.apache.commons.lang.StringUtils;
import org.chililog.server.common.AppProperties;
import org.chililog.server.common.Log4JLogger;
import org.jboss.netty.buffer.ChannelBuffers;
import org.jboss.netty.channel.Channel;
import org.jboss.netty.channel.ChannelFuture;
import org.jboss.netty.channel.ChannelFutureListener;
import org.jboss.netty.channel.ChannelHandlerContext;
import org.jboss.netty.channel.MessageEvent;
import org.jboss.netty.handler.codec.http.DefaultHttpResponse;
import org.jboss.netty.handler.codec.http.HttpHeaders;
import org.jboss.netty.handler.codec.http.HttpMethod;
import org.jboss.netty.handler.codec.http.HttpRequest;
import org.jboss.netty.handler.codec.http.HttpResponse;
import org.jboss.netty.handler.codec.http.HttpResponseStatus;
import org.jboss.netty.handler.stream.ChunkedFile;
import org.jboss.netty.util.CharsetUtil;

/**
 * <p>
 * Static file service serves static files stored on the file system.
 * </p>
 * <p>
 * The root directory under which to search for file is specified by the <code>web.static_files.directory</code> in the
 * <code>app.properties</code> file.
 * </p>
 * <p>
 * The number of seconds that browsers are expected to cache all files is specified by the
 * <code>web.static_files.cache_seconds</code> in the <code>app.properties</code> file. This is how caching works
 * </p>
 * 
 * <pre>
 * Request #1 Headers
 * ===================
 * GET /static/file1.txt HTTP/1.1
 * 
 * Response #1 Headers
 * ===================
 * HTTP/1.1 200 OK
 * Date:               Tue, 01 Mar 2011 22:44:26 GMT
 * Last-Modified:      Wed, 30 Jun 2010 21:36:48 GMT
 * Expires:            Tue, 01 Mar 2012 22:44:26 GMT
 * Cache-Control:      private, max-age=31536000
 * 
 * Request #2 Headers
 * ===================
 * GET /static/file1.txt HTTP/1.1
 * If-Modified-Since:  Wed, 30 Jun 2010 21:36:48 GMT
 * 
 * Response #2 Headers
 * ===================
 * HTTP/1.1 304 Not Modified
 * Date:               Tue, 01 Mar 2011 22:44:28 GMT
 * 
 * </pre>
 * 
 * <p>
 * Compression is turned off for all files except those that:
 * <ul>
 * <li>have an extension of ".html", ".txt", ".json", ".js", ".xml" or ".css", and</li>
 * <li>are between 4K and 1MB in size.</li>
 * </ul>
 * File extension restrictions are put in as these types of text files are the most common and compress well. We don't
 * want to compress files that are too small because the result can be bigger than the original. Also, we don't want to
 * compress files that are too big because it can waste CPU.
 * </p>
 * <p>
 * This code is based on the Netty HTTP File Server sample (http://www.jboss.org/netty/documentation.html).
 * </p>
 */
public class StaticFileRequestHandler extends WorkbenchRequestHandler {

    private static Log4JLogger _logger = Log4JLogger.getLogger(StaticFileRequestHandler.class);

    /**
     * Process the message
     */
    @Override
    public void processMessage(ChannelHandlerContext ctx, MessageEvent e) throws Exception {
        HttpRequest request = (HttpRequest) e.getMessage();

        // We don't handle 100 Continue because we only allow GET method.
        if (request.getMethod() != HttpMethod.GET) {
            sendError(ctx, e, METHOD_NOT_ALLOWED, null);
            return;
        }

        // Check
        final String filePath = convertUriToPhysicalFilePath(request.getUri());
        if (filePath == null) {
            sendError(ctx, e, FORBIDDEN, null);
            return;
        }
        File file = new File(filePath);
        if (file.isHidden() || !file.exists()) {
            sendError(ctx, e, NOT_FOUND, String.format("%s not exist", file.getCanonicalPath()));
            return;
        }
        if (!file.isFile()) {
            sendError(ctx, e, FORBIDDEN, String.format("%s not a file", file.getCanonicalPath()));
            return;
        }

        // Cache Validation
        String ifModifiedSince = request.getHeader(HttpHeaders.Names.IF_MODIFIED_SINCE);
        if (!StringUtils.isBlank(ifModifiedSince)) {
            SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
            Date ifModifiedSinceDate = dateFormatter.parse(ifModifiedSince);

            // Only compare up to the second because the datetime format we send to the client does not have milliseconds 
            long ifModifiedSinceDateSeconds = ifModifiedSinceDate.getTime() / 1000;
            long fileLastModifiedSeconds = file.lastModified() / 1000;
            if (ifModifiedSinceDateSeconds == fileLastModifiedSeconds) {
                sendNotModified(ctx, e);
                return;
            }
        }

        // Open file for sending back
        RandomAccessFile raf;
        try {
            raf = new RandomAccessFile(file, "r");
        } catch (FileNotFoundException fnfe) {
            sendError(ctx, e, NOT_FOUND, null);
            return;
        }
        long fileLength = raf.length();

        // Log
        writeLogEntry(e, OK, null);

        // Create the response
        HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
        setContentLength(response, fileLength);
        setContentTypeHeader(response, file);
        setDateAndCacheHeaders(response, file);

        // Write the content.
        Channel ch = e.getChannel();
        ChannelFuture writeFuture;
        if (AppProperties.getInstance().getWorkbenchSslEnabled()) {
            // Cannot use zero-copy with HTTPS

            // Write the initial line and the header.
            ch.write(response);

            // Write chunks
            writeFuture = ch.write(new ChunkedFile(raf, 0, fileLength, 8192));
        } else {
            // Now that we are using Execution Handlers, we cannot do zero-copy.
            // Do as per with compression (which is what most browser will ask for)
            byte[] buffer = new byte[(int) fileLength];
            raf.readFully(buffer);
            raf.close();

            response.setContent(ChannelBuffers.copiedBuffer(buffer));
            writeFuture = ch.write(response);

            /*
             * // No encryption - use zero-copy. // However zero-copy does not seem to work with compression // Only use
             * zero-copy for large files like movies and music // Write the initial line and the header.
             * ch.write(response); // Zero-copy final FileRegion region = new DefaultFileRegion(raf.getChannel(), 0,
             * fileLength); writeFuture = ch.write(region); writeFuture.addListener(new ChannelFutureProgressListener()
             * { public void operationComplete(ChannelFuture future) { region.releaseExternalResources(); } public void
             * operationProgressed(ChannelFuture future, long amount, long current, long total) {
             * _logger.debug("Zero-Coping file %s: %d / %d (+%d) bytes", filePath, current, total, amount); } });
             */
        }

        // Decide whether to close the connection or not.
        if (!isKeepAlive(request)) {
            // Close the connection when the whole content is written out.
            writeFuture.addListener(ChannelFutureListener.CLOSE);
        }
    }

    /**
     * Converts the request URI to a file path
     * 
     * @param uri
     * @return
     * @throws UnsupportedEncodingException
     */
    private String convertUriToPhysicalFilePath(String uri) throws UnsupportedEncodingException {
        // Decode the path.
        try {
            uri = URLDecoder.decode(uri, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            uri = URLDecoder.decode(uri, "ISO-8859-1");
        }

        // Remove the initial /static/ or /workbench/ prefix
        int idx = uri.indexOf('/', 1);
        if (idx < 0) {
            return null;
        }
        uri = uri.substring(idx);
        if (StringUtils.isBlank(uri)) {
            return null;
        }

        // Remove query string if any
        idx = uri.indexOf('?');
        if (idx > 0) {
            uri = uri.substring(0, idx);
        }

        // Convert file separators.
        uri = uri.replace('/', File.separatorChar);

        // Simplistic dumb security check.
        // You will have to do something serious in the production environment.
        if (uri.contains(File.separator + ".") || uri.contains("." + File.separator) || uri.startsWith(".")
                || uri.endsWith(".")) {
            return null;
        }

        // Convert to absolute path.
        return AppProperties.getInstance().getWorkbenchStaticFilesDirectory() + uri;
    }

    /**
     * Send error to client
     * 
     * @param ctx
     *            Context
     * @param e
     *            Message Event
     * @param status
     *            HTTP response status
     * @param moreInfo
     *            More details of the error to log
     */
    private void sendError(ChannelHandlerContext ctx, MessageEvent e, HttpResponseStatus status, String moreInfo) {
        writeLogEntry(e, status, moreInfo);

        HttpResponse response = new DefaultHttpResponse(HTTP_1_1, status);
        setDateHeader(response);

        // Send error back as plain text in the body
        response.setHeader(CONTENT_TYPE, "text/plain; charset=UTF-8");
        response.setContent(
                ChannelBuffers.copiedBuffer("Failure: " + status.toString() + "\r\n", CharsetUtil.UTF_8));

        ChannelFuture writeFuture = ctx.getChannel().write(response);

        // Decide whether to close the connection or not.
        if (!isKeepAlive((HttpRequest) e.getMessage())) {
            writeFuture.addListener(ChannelFutureListener.CLOSE);
        }
    }

    /**
     * When file timestamp is the same as what the browser is sending up, send a "304 Not Modified"
     * 
     * @param ctx
     *            Context
     * @param e
     *            Message Event
     */
    private void sendNotModified(ChannelHandlerContext ctx, MessageEvent e) {
        writeLogEntry(e, HttpResponseStatus.NOT_MODIFIED, null);

        HttpResponse response = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.NOT_MODIFIED);
        setDateHeader(response);

        ChannelFuture writeFuture = ctx.getChannel().write(response);

        // Decide whether to close the connection or not.
        if (!isKeepAlive((HttpRequest) e.getMessage())) {
            writeFuture.addListener(ChannelFutureListener.CLOSE);
        }
    }

    /**
     * Sets the content type header for the HTTP Response
     * 
     * @param response
     *            HTTP response
     * @param file
     *            file to extract content type
     */
    private void setContentTypeHeader(HttpResponse response, File file) {
        String mimeType = null;
        String filePath = file.getPath();

        int idx = filePath.lastIndexOf('.');
        if (idx == -1) {
            mimeType = "application/octet-stream";
        } else {
            String fileExtension = filePath.substring(idx).toLowerCase();

            // Try common types first
            if (fileExtension.equals(".html")) {
                mimeType = "text/html";
            } else if (fileExtension.equals(".css")) {
                mimeType = "text/css";
            } else if (fileExtension.equals(".js")) {
                mimeType = "application/javascript";
            } else if (fileExtension.equals(".gif")) {
                mimeType = "image/gif";
            } else if (fileExtension.equals(".png")) {
                mimeType = "image/png";
            } else if (fileExtension.equals(".txt")) {
                mimeType = "text/plain";
            } else if (fileExtension.equals(".xml")) {
                mimeType = "application/xml";
            } else if (fileExtension.equals(".json")) {
                mimeType = "application/json";
            } else {
                MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
                mimeType = mimeTypesMap.getContentType(file.getPath());
            }
        }

        response.setHeader(HttpHeaders.Names.CONTENT_TYPE, mimeType);
    }

    /**
     * Sets the Date header for the HTTP response
     * 
     * @param response
     *            HTTP response
     * @param file
     *            file to extract content type
     */
    private void setDateHeader(HttpResponse response) {
        SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
        dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));

        Calendar time = new GregorianCalendar();
        response.setHeader(HttpHeaders.Names.DATE, dateFormatter.format(time.getTime()));
    }

    /**
     * Sets the Date and Cache headers for the HTTP Response
     * 
     * @param response
     *            HTTP response
     * @param file
     *            file to extract content type
     */
    private void setDateAndCacheHeaders(HttpResponse response, File filetoCache) {
        SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
        dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));

        // Date header
        Calendar time = new GregorianCalendar();
        response.setHeader(HttpHeaders.Names.DATE, dateFormatter.format(time.getTime()));

        // Add cache headers
        time.add(Calendar.SECOND, AppProperties.getInstance().getWorkbenchStaticFilesCacheSeconds());
        response.setHeader(HttpHeaders.Names.EXPIRES, dateFormatter.format(time.getTime()));

        response.setHeader(HttpHeaders.Names.CACHE_CONTROL,
                "private, max-age=" + AppProperties.getInstance().getWorkbenchStaticFilesCacheSeconds());

        response.setHeader(HttpHeaders.Names.LAST_MODIFIED,
                dateFormatter.format(new Date(filetoCache.lastModified())));
    }

    /**
     * Write audit log entry
     * 
     * @param e
     *            Message Event
     */
    private void writeLogEntry(MessageEvent e, HttpResponseStatus status, String moreInfo) {
        HttpRequest request = (HttpRequest) e.getMessage();
        _logger.info("GET %s REMOTE_IP=%s STATUS=%s CHANNEL=%s %s", request.getUri(),
                e.getRemoteAddress().toString(), status, e.getChannel().getId(),
                moreInfo == null ? StringUtils.EMPTY : moreInfo);

        StringBuilder sb = new StringBuilder("Request Headers\n");
        List<java.util.Map.Entry<String, String>> headers = request.getHeaders();
        for (java.util.Map.Entry<String, String> entry : headers) {
            sb.append(entry.getKey()).append("=").append(entry.getValue()).append("\n");
        }
        _logger.debug(sb.toString());

        return;
    }
}