org.robotbrains.support.web.server.netty.NettyStaticContentHandler.java Source code

Java tutorial

Introduction

Here is the source code for org.robotbrains.support.web.server.netty.NettyStaticContentHandler.java

Source

/*
 * Copyright (C) 2012 Google Inc.
 * Copyright (C) 2015 Keith M. Hughes.
 *
 * 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.robotbrains.support.web.server.netty;

import static io.netty.handler.codec.http.HttpHeaders.addHeader;
import static io.netty.handler.codec.http.HttpHeaders.getHeader;
import static io.netty.handler.codec.http.HttpHeaders.isKeepAlive;
import static io.netty.handler.codec.http.HttpHeaders.setContentLength;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelProgressiveFuture;
import io.netty.channel.ChannelProgressiveFutureListener;
import io.netty.channel.DefaultFileRegion;
import io.netty.channel.FileRegion;
import io.netty.handler.codec.http.DefaultHttpResponse;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.handler.codec.http.cookie.ServerCookieEncoder;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.stream.ChunkedFile;
import io.smartspaces.SimpleSmartSpacesException;
import io.smartspaces.util.io.FileSupport;
import io.smartspaces.util.io.FileSupportImpl;
import io.smartspaces.util.web.MimeResolver;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.net.HttpCookie;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.robotbrains.support.web.server.HttpStaticContentRequestHandler;

import com.google.common.base.Function;
import com.google.common.collect.Collections2;
import com.google.common.collect.Maps;

/**
 * Handle static web content using Netty.
 *
 * @author Keith M. Hughes
 */
public class NettyStaticContentHandler implements NettyHttpGetRequestHandler, HttpStaticContentRequestHandler {

    /**
     * The first portion of a content range header.
     */
    private static final String CONTENT_RANGE_PREFIX = "bytes ";

    /**
     * The separator between the start and end of the range.
     */
    private static final String CONTENT_RANGE_RANGE_SEPARATOR = "-";

    /**
     * The separator between the range and the file size in a content range
     * header.
     */
    private static final String CONTENT_RANGE_RANGE_SIZE_SEPARATOR = "/";

    /**
     * Chunk size to use for copying content.
     */
    private static final int COPY_CHUNK_SIZE = 8192;

    /**
     * Regex for an HTTP range header.
     */
    private static final Pattern RANGE_HEADER_REGEX = Pattern.compile("bytes=(\\d+)\\-(\\d+)?");

    /**
     * The parent content handler for this handler.
     */
    private NettyWebServerHandler parentHandler;

    /**
     * Fallback handler to use in case of missing target.
     */
    private NettyHttpDynamicGetRequestHandlerHandler fallbackHandler;

    /**
     * The URI prefix to be handled by this handler.
     */
    private String uriPrefix;

    /**
     * Base directory for content served by this handler.
     */
    private File baseDir;

    /**
     * Extra headers to add to the response.
     */
    private Map<String, String> extraHttpContentHeaders = Maps.newHashMap();

    /**
     * Should this web-server allow links to be accessed? (Wander outside the root
     * filesystem.) Useful for debugging & development.
     */
    private boolean allowLinks;

    /**
     * The MIME resolver to use for responding to requests.
     *
     * <p>
     * Can be {@code null}.
     */
    private MimeResolver mimeResolver;

    /**
     * The file support to use.
     */
    private FileSupport fileSupport = FileSupportImpl.INSTANCE;

    /**
     * Create a new instance.
     *
     * @param parentHandler
     *          parent handler of this handler
     * @param uriPrefix
     *          uri prefix for this handler
     * @param baseDir
     *          base directory for static content
     * @param extraHttpContentHeaders
     *          extra http headers to use, can be {@code null}
     * @param fallbackHandler
     *          fallback handler to use, can be {@code null}
     */
    public NettyStaticContentHandler(NettyWebServerHandler parentHandler, String uriPrefix, File baseDir,
            Map<String, String> extraHttpContentHeaders, NettyHttpDynamicGetRequestHandlerHandler fallbackHandler) {
        this.parentHandler = parentHandler;
        this.fallbackHandler = fallbackHandler;

        if (extraHttpContentHeaders != null) {
            this.extraHttpContentHeaders.putAll(extraHttpContentHeaders);
        }

        StringBuilder sanitizedUriPrefix = new StringBuilder();
        if (!uriPrefix.startsWith(CONTENT_RANGE_RANGE_SIZE_SEPARATOR)) {
            sanitizedUriPrefix.append('/');
        }
        sanitizedUriPrefix.append(uriPrefix);
        if (!uriPrefix.endsWith(CONTENT_RANGE_RANGE_SIZE_SEPARATOR)) {
            sanitizedUriPrefix.append('/');
        }
        this.uriPrefix = sanitizedUriPrefix.toString();

        this.baseDir = baseDir;
    }

    @Override
    public void setMimeResolver(MimeResolver resolver) {
        mimeResolver = resolver;
    }

    @SuppressWarnings("unchecked")
    @Override
    public <T extends MimeResolver> T getMimeResolver() {
        return (T) mimeResolver;
    }

    @Override
    public boolean isHandledBy(HttpRequest request) {
        if (request.getUri().startsWith(uriPrefix)) {
            HttpMethod method = request.getMethod();
            return method == HttpMethod.GET || method == HttpMethod.HEAD;
        } else {
            return false;
        }
    }

    @Override
    public void handleWebRequest(ChannelHandlerContext ctx, HttpRequest request, Set<HttpCookie> cookiesToAdd)
            throws IOException {
        String url = request.getUri();
        String originalUrl = url;

        // Strip off query parameters, if any, as we don't care.
        int pos = url.indexOf('?');
        if (pos != -1) {
            url = url.substring(0, pos);
        }

        int luriPrefixLength = uriPrefix.length();
        String filepath = URLDecoder.decode(url.substring(url.indexOf(uriPrefix) + luriPrefixLength),
                StandardCharsets.UTF_8.name());

        File file = new File(baseDir, filepath);

        // Refuse to process if the path wanders outside of the base directory.
        if (!allowLinks && !fileSupport.isParent(baseDir, file)) {
            HttpResponseStatus status = HttpResponseStatus.FORBIDDEN;
            parentHandler.getWebServer().getLog().warn(String.format(
                    "HTTP [%s] %s --> (Path attempts to leave base directory)", status.code(), originalUrl));
            parentHandler.sendError(ctx, status);
            return;
        }

        RandomAccessFile raf;
        try {
            raf = new RandomAccessFile(file, "r");
        } catch (FileNotFoundException fnfe) {
            if (fallbackHandler != null) {
                fallbackHandler.handleWebRequest(ctx, request, cookiesToAdd);
            } else {
                HttpResponseStatus status = HttpResponseStatus.NOT_FOUND;
                parentHandler.getWebServer().getLog()
                        .warn(String.format("HTTP [%s] %s --> (File Not Found)", status.code(), originalUrl));
                parentHandler.sendError(ctx, status);
            }
            return;
        }
        long fileLength = raf.length();

        // Start with an initial OK response which will be modified as needed.
        HttpResponse response = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.OK);

        setMimeType(filepath, response);

        parentHandler.addHttpResponseHeaders(response, extraHttpContentHeaders);
        parentHandler.addHeaderIfNotExists(response, HttpHeaders.Names.ACCEPT_RANGES, HttpHeaders.Values.BYTES);

        if (cookiesToAdd != null) {

            addHeader(response, HttpHeaders.Names.SET_COOKIE, ServerCookieEncoder.STRICT
                    .encode(Collections2.transform(cookiesToAdd, new Function<HttpCookie, Cookie>() {
                        @Override
                        public Cookie apply(HttpCookie cookie) {
                            return NettyHttpResponse.createNettyCookie(cookie);
                        }
                    })));
        }

        RangeRequest rangeRequest = null;
        try {
            rangeRequest = parseRangeRequest(request, fileLength);
        } catch (Exception e) {
            try {
                HttpResponseStatus status = HttpResponseStatus.REQUESTED_RANGE_NOT_SATISFIABLE;
                parentHandler.getWebServer().getLog()
                        .error(String.format("[%s] HTTP %s --> %s", status.code(), originalUrl, e.getMessage()));
                response.setStatus(status);
                parentHandler.sendError(ctx, status);
            } finally {
                try {
                    raf.close();
                } catch (Exception e1) {
                    parentHandler.getWebServer().getLog().warn("Unable to close static content file", e1);
                }
            }
            return;
        }

        HttpResponseStatus status = HttpResponseStatus.OK;
        if (rangeRequest == null) {
            setContentLength(response, fileLength);
        } else {
            setContentLength(response, rangeRequest.getRangeLength());
            addHeader(response, HttpHeaders.Names.CONTENT_RANGE,
                    CONTENT_RANGE_PREFIX + rangeRequest.begin + CONTENT_RANGE_RANGE_SEPARATOR + rangeRequest.end
                            + CONTENT_RANGE_RANGE_SIZE_SEPARATOR + fileLength);
            status = HttpResponseStatus.PARTIAL_CONTENT;
            response.setStatus(status);
        }

        Channel ch = ctx.channel();

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

        // Write the content if there have been no errors and we are a GET request.
        if (HttpMethod.GET == request.getMethod()) {
            if (ch.pipeline().get(SslHandler.class) != null) {
                // Cannot use zero-copy with HTTPS.
                writeFuture = ch.write(new ChunkedFile(raf, 0, fileLength, COPY_CHUNK_SIZE));
            } else {
                // No encryption - use zero-copy.
                final FileRegion region = new DefaultFileRegion(raf.getChannel(),
                        rangeRequest != null ? rangeRequest.begin : 0,
                        rangeRequest != null ? rangeRequest.getRangeLength() : fileLength);
                writeFuture = ch.write(region);
                writeFuture.addListener(new ChannelProgressiveFutureListener() {
                    @Override
                    public void operationComplete(ChannelProgressiveFuture future) throws Exception {
                        region.release();
                    }

                    @Override
                    public void operationProgressed(ChannelProgressiveFuture future, long progress, long total)
                            throws Exception {
                        // Do nothng
                    }
                });
            }
        }

        // 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);
        }

        parentHandler.getWebServer().getLog()
                .trace(String.format("[%s] HTTP %s --> %s", status.code(), originalUrl, file.getPath()));
    }

    /**
     * Set the MIME type of the content, if we can.
     *
     * @param filepath
     *          the filepath for the content
     * @param response
     *          the HTTP response
     */
    private void setMimeType(String filepath, HttpResponse response) {
        if (mimeResolver != null) {
            String mimeType = mimeResolver.resolve(filepath);
            if (mimeType != null) {
                HttpHeaders.setHeader(response, HttpHeaders.Names.CONTENT_TYPE, mimeType);
            }
        }
    }

    /**
     * Get a range header from the request, if there is one.
     *
     * @param request
     *          the request
     * @param availableLength
     *          the available number of bytes for the file requested
     *
     * @return a parsed range header, or {@code null} if there is no range request
     *         header or there was some sort of error
     */
    private RangeRequest parseRangeRequest(HttpRequest request, long availableLength) {
        String rangeHeader = getHeader(request, HttpHeaders.Names.RANGE);
        if (rangeHeader == null || rangeHeader.trim().isEmpty()) {
            return null;
        }

        Matcher m = RANGE_HEADER_REGEX.matcher(rangeHeader);
        if (!m.matches()) {
            throw new SimpleSmartSpacesException(
                    String.format("Unsupported HTTP range header, illegal syntax: %s", rangeHeader));
        }

        RangeRequest range = new RangeRequest();
        range.begin = Long.parseLong(m.group(1));
        String endMatch = m.group(2);
        if (endMatch != null && !endMatch.trim().isEmpty()) {
            range.end = Long.parseLong(endMatch);
        } else {
            range.end = availableLength - 1;
        }

        if (range.end < range.begin) {
            return null;
        }

        if (range.end >= availableLength) {
            throw new SimpleSmartSpacesException(String.format(
                    "Unsupported HTTP range header, length requested is more than actual length: %s", rangeHeader));
        }

        return range;
    }

    /**
     * Allow files linked outside the root filesystem to be accessed.
     *
     * @param allowLinks
     *          {@code true} if following links should be allowed
     */
    public void setAllowLinks(boolean allowLinks) {
        this.allowLinks = allowLinks;
    }

    /**
     * An HTTP range request.
     *
     * @author Keith M. Hughes
     */
    public static class RangeRequest {

        /**
         * The position of the first byte in the request.
         */
        private long begin;

        /**
         * The position of the last byte in the request.
         */
        private long end;

        /**
         * Get the number of bytes in the range.
         *
         * @return the number of bytes in the range
         */
        long getRangeLength() {
            return end - begin + 1;
        }
    }
}