org.thingsplode.synapse.endpoint.handlers.FileRequestHandler.java Source code

Java tutorial

Introduction

Here is the source code for org.thingsplode.synapse.endpoint.handlers.FileRequestHandler.java

Source

/*
 * Copyright 2016 tamas.csaba@gmail.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.thingsplode.synapse.endpoint.handlers;

import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandler.Sharable;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.DefaultFileRegion;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.DefaultHttpResponse;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpChunkedInput;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaderValues;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpUtil;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.stream.ChunkedFile;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.InputStream;
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.HashMap;
import java.util.Locale;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.activation.MimetypesFileTypeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.thingsplode.synapse.endpoint.swagger.Loader;
import org.thingsplode.synapse.core.FileRequest;
import org.thingsplode.synapse.core.Request;
import org.thingsplode.synapse.core.Request.RequestHeader;
import org.thingsplode.synapse.util.Util;

/**
 *
 * @author Csaba Tamas
 */
//todo: concurrent large file support
//todo: file upload
@Sharable
public final class FileRequestHandler extends SimpleChannelInboundHandler<FileRequest> {
    private static final Logger logger = LoggerFactory.getLogger(FileRequestHandler.class);
    private final Pattern urlParamPattern = Pattern.compile("\\{(.*?)\\}", Pattern.CASE_INSENSITIVE);
    private static final String HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
    private static final String HTTP_DATE_GMT_TIMEZONE = "GMT";
    private static final int HTTP_CACHE_SECONDS = 60;
    private static final Pattern INSECURE_URI_PATTERN = Pattern.compile(".*[<>&\"].*");
    private static final String MIME_TYPES_FILE = "/META-INF/server.mime.types";
    private static MimetypesFileTypeMap MIME_TYPES_MAP;
    private File webroot = null;
    private final HashMap<Pattern, String> redirects = new HashMap<>();

    public FileRequestHandler(String webroot) throws FileNotFoundException {
        this();
        setWebroot(webroot);
    }

    public void setWebroot(String webroot) throws FileNotFoundException {
        File f = new File(webroot);
        if (f.exists() && f.isDirectory() && f.canRead()) {
            this.webroot = f;
            logger.info("Web root initialized at [" + f.getAbsolutePath() + "]");
        } else {
            throw new FileNotFoundException(
                    "The folder " + webroot + " cannot be found, not a directory or cannot be read;");
        }
    }

    public FileRequestHandler() throws FileNotFoundException {
        synchronized (this) {
            if (MIME_TYPES_MAP == null) {
                InputStream is = this.getClass().getResourceAsStream(MIME_TYPES_FILE);
                if (is != null) {
                    MIME_TYPES_MAP = new MimetypesFileTypeMap(is);
                } else {
                    throw new FileNotFoundException(MIME_TYPES_FILE + " couldn't be found.");
                }
            }
        }
    }

    public void addRedirect(Pattern p, String url) {
        this.redirects.put(p, url);
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FileRequest req) throws Exception {

        Optional<String> uri = getSanitizedPath(req.getHeader().getUri().getPath());
        if (!uri.isPresent()) {
            HttpResponseHandler.sendError(ctx, HttpResponseStatus.FORBIDDEN, "Path is not available.");
            return;
        }

        if (!this.redirects.isEmpty()) {
            for (Entry<Pattern, String> e : this.redirects.entrySet()) {
                if (e.getKey().matcher(uri.get()).matches()) {
                    HttpResponseHandler.sendRedirect(ctx, createRedirectUrl(req.getHeader(), e.getValue()),
                            req.getHeader());
                    break;
                }
            }
        }

        if (uri.get().endsWith("/")) {
            HttpResponseHandler.sendRedirect(ctx, uri.get() + "index.html", req.getHeader());
            return;
        }

        File file = null;
        if (webroot != null) {
            file = new File(webroot, uri.get());
        }
        //if ((!file.exists()) && "/index.html".equals(msg.getHeader().getUri().getPath())) {
        //    file = new File(sanitizeUri("/index.html"));
        //}

        RandomAccessFile raf = null;
        if (file == null || !file.exists() || file.isHidden() || file.isDirectory()) {
            raf = Loader.extractResource(uri.get());
            if (raf == null) {
                HttpResponseHandler.sendError(ctx, HttpResponseStatus.NOT_FOUND, "File not found.");
                return;
            }
        }

        if (file == null && raf == null) {
            HttpResponseHandler.sendError(ctx, HttpResponseStatus.NOT_FOUND, "File not found.");
            return;
        }

        if (raf == null && !file.isFile()) {
            HttpResponseHandler.sendError(ctx, HttpResponseStatus.FORBIDDEN, "Is not a file.");
            return;
        }

        String contentType = MIME_TYPES_MAP.getContentType(file.getPath());
        try {
            if (raf == null) {
                raf = new RandomAccessFile(file, "r");
            }
        } catch (FileNotFoundException ex) {
            HttpResponseHandler.sendError(ctx, HttpResponseStatus.NOT_FOUND, ex.getMessage());
            return;
        }
        //don't send apps
        //if ("application/octet-stream".equals(contentType)) {
        //    file = new File(sanitizeUri("/index.html"));
        //}

        // Cache Validation
        if (file.exists()) {
            Optional<String> ifModifiedSinceOpt = req.getHeader()
                    .getProperty(HttpHeaderNames.IF_MODIFIED_SINCE.toString());
            if (ifModifiedSinceOpt.isPresent() && !Util.isEmpty(ifModifiedSinceOpt.get())) {
                SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
                Date ifModifiedSinceDate = dateFormatter.parse(ifModifiedSinceOpt.get());

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

        long fileLength = raf.length();

        HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
        HttpUtil.setContentLength(response, fileLength);
        setContentTypeHeader(response, file);
        setDateAndCacheHeaders(response, file);
        if (isKeepAlive(req.getHeader())) {
            response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderNames.CONNECTION);
        }
        // Write the initial line and the header.
        ctx.write(response);
        // Write the content.
        ChannelFuture sendFileFuture;
        ChannelFuture lastContentFuture;
        if (ctx.pipeline().get(SslHandler.class) == null) {
            sendFileFuture = ctx.write(new DefaultFileRegion(raf.getChannel(), 0, fileLength),
                    ctx.newProgressivePromise());
            // Write the end marker.
            lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
        } else {
            sendFileFuture = ctx.write(new HttpChunkedInput(new ChunkedFile(raf, 0, fileLength, 8192)),
                    ctx.newProgressivePromise());
            // HttpChunkedInput will write the end marker (LastHttpContent) for us.
            lastContentFuture = sendFileFuture;
        }

    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {

        logger.error("Error while serving File Request: " + cause.getMessage(), cause);
        HttpResponseHandler.sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
                cause.getClass().getSimpleName() + ": " + cause.getMessage());
    }

    private String createRedirectUrl(Request.RequestHeader header, String originalPath) {
        Matcher m = urlParamPattern.matcher(originalPath);
        if (m.find()) {
            for (int i = 0; i < m.groupCount(); i++) {
                String matchedValue = m.group(i);
                String headerIdentifier = matchedValue.substring(1, matchedValue.length() - 1);
                Optional<String> propOpt = header.getProperty(headerIdentifier);
                if (propOpt.isPresent()) {
                    originalPath = originalPath.replaceAll("\\{" + headerIdentifier + "\\}", propOpt.get());
                }
            }
        }
        return originalPath;
    }

    private boolean isKeepAlive(RequestHeader header) {
        Optional<String> connOpt = header.getProperty(HttpHeaderNames.CONNECTION.toString());
        if (connOpt.isPresent() && HttpHeaderValues.CLOSE.contentEqualsIgnoreCase(connOpt.get())) {
            return false;
        }

        //        if (header.protocolVersion().isKeepAliveDefault()) {
        //            return !HttpHeaderValues.CLOSE.contentEqualsIgnoreCase(connection);
        //        } else {
        //            return HttpHeaderValues.KEEP_ALIVE.contentEqualsIgnoreCase(connection);
        //        }
        return true;
    }

    /**
     * When file timestamp is the same as what the browser is sending up, send a
     * "304 Not Modified"
     *
     * @param ctx Context
     */
    private void sendNotModified(ChannelHandlerContext ctx) {
        FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
                HttpResponseStatus.NOT_MODIFIED);
        setDateHeader(response);

        // Close the connection as soon as the error message is sent.
        ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
    }

    /**
     * Sets the Date header for the HTTP response
     *
     * @param response HTTP response
     */
    public void setDateHeader(FullHttpResponse response) {
        SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
        dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));

        Calendar time = new GregorianCalendar();
        response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime()));
    }

    /**
     * Sets the content type header for the HTTP Response
     *
     * @param response HTTP response
     * @param file file to extract content type
     */
    public void setContentTypeHeader(HttpResponse response, File file) {
        response.headers().set(HttpHeaderNames.CONTENT_TYPE, MIME_TYPES_MAP.getContentType(file.getPath()));
    }

    /**
     * Sets the Date and Cache headers for the HTTP Response
     *
     * @param response HTTP response
     * @param fileToCache file to extract content type
     */
    public 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.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime()));

        // Add cache headers
        time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
        response.headers().set(HttpHeaderNames.EXPIRES, dateFormatter.format(time.getTime()));
        response.headers().set(HttpHeaderNames.CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
        response.headers().set(HttpHeaderNames.LAST_MODIFIED,
                dateFormatter.format(new Date(fileToCache.lastModified())));
    }

    private Optional<String> getSanitizedPath(String uri) {
        // Decode the path.
        try {
            uri = URLDecoder.decode(uri, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw new Error(e);
        }

        if (Util.isEmpty(uri)) {
            return Optional.empty();
        }

        if (!uri.startsWith("/")) {
            uri = "/" + uri;
        }

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

        if (uri.contains(File.separator + '.') || uri.contains('.' + File.separator) || uri.charAt(0) == '.'
                || uri.charAt(uri.length() - 1) == '.' || INSECURE_URI_PATTERN.matcher(uri).matches()) {
            return Optional.empty();
        }
        return Optional.of(uri);
    }

}