com.github.zhanhb.ckfinder.download.PathPartial.java Source code

Java tutorial

Introduction

Here is the source code for com.github.zhanhb.ckfinder.download.PathPartial.java

Source

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 com.github.zhanhb.ckfinder.download;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.StringTokenizer;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;

public class PathPartial {

    private static final Logger log = LoggerFactory.getLogger(PathPartial.class);

    private static final boolean HAS_METHOD_CONTENT_LENGTH_LONG;

    /**
     * Full range marker.
     */
    private static final Range[] FULL = {};

    // ----------------------------------------------------- Static Initializer
    /**
     * MIME multipart separation string
     */
    private static final String MIME_SEPARATION = "PATH_PARTIAL_MIME_BOUNDARY";

    static {
        boolean hasContentLong;
        try {
            HttpServletResponse.class.getMethod("setContentLengthLong", long.class);
            hasContentLong = true;
        } catch (NoSuchMethodException | SecurityException ex) {
            hasContentLong = false;
        }
        HAS_METHOD_CONTENT_LENGTH_LONG = hasContentLong;
    }

    public static PathPartialBuilder builder() {
        return new PathPartialBuilder();
    }

    private final boolean useAcceptRanges;
    private final ContentDisposition contentDisposition;
    private final ETag eTag;
    private final ContentTypeResolver contentTypeResolver;
    private final NotFoundHandler notFound;

    PathPartial(boolean useAcceptRanges, @Nonnull ContentDisposition contentDisposition, @Nonnull ETag eTag,
            @Nonnull ContentTypeResolver contentType, @Nonnull NotFoundHandler notFound) {
        this.useAcceptRanges = useAcceptRanges;
        this.contentDisposition = Objects.requireNonNull(contentDisposition, "contentDisposition");
        this.eTag = Objects.requireNonNull(eTag, "eTag");
        this.contentTypeResolver = Objects.requireNonNull(contentType, "contentType");
        this.notFound = Objects.requireNonNull(notFound, "notFound");
    }

    public void service(HttpServletRequest request, HttpServletResponse response, Path path)
            throws IOException, ServletException {
        serveResource(request, response, !"HEAD".equals(request.getMethod()), path);
    }

    /**
     * Process a HEAD request for the specified resource.
     *
     * @param request The servlet request we are processing
     * @param response The servlet response we are creating
     * @param path path of the resource
     * @exception IOException if an input/output error occurs
     * @throws ServletException if servlet exception occurs
     */
    public void doHead(HttpServletRequest request, HttpServletResponse response, Path path)
            throws IOException, ServletException {
        // Serve the requested resource, without the data content
        serveResource(request, response, false, path);
    }

    /**
     * Process a GET request for the specified resource.
     *
     * @param request The servlet request we are processing
     * @param response The servlet response we are creating
     * @param path path of the resource
     * @exception IOException if an input/output error occurs
     * @throws ServletException if servlet exception occurs
     */
    public void doGet(HttpServletRequest request, HttpServletResponse response, Path path)
            throws IOException, ServletException {
        serveResource(request, response, true, path);
    }

    /**
     * Check if the conditions specified in the optional If headers are satisfied.
     *
     * @param request The servlet request we are processing
     * @param response The servlet response we are creating
     * @param attr The resource information
     * @param etag ETag of the entity
     * @return boolean true if the resource meets all the specified conditions,
     * and false if any of the conditions is not satisfied, in which case request
     * processing is stopped
     */
    private boolean checkIfHeaders(HttpServletRequest request, HttpServletResponse response,
            BasicFileAttributes attr, String etag) throws IOException {
        try {
            checkIfMatch(request, etag);
            checkIfUnmodifiedSince(request, attr);
            checkIfNoneMatch(request, etag);
            checkIfModifiedSince(request, attr);
            return true;
        } catch (UncheckException ex) {
            if (ex.isError()) {
                response.sendError(ex.getCode());
            } else {
                response.setStatus(ex.getCode());
                response.setHeader(HttpHeaders.ETAG, etag);
            }
            return false;
        }
    }

    /**
     * Serve the specified resource, optionally including the data content.
     *
     * @param request The servlet request we are processing
     * @param response The servlet response we are creating
     * @param content Should the content be included?
     * @param path the resource to serve
     *
     * @exception IOException if an input/output error occurs
     */
    private void serveResource(HttpServletRequest request, HttpServletResponse response, boolean content, Path path)
            throws IOException, ServletException {
        ActionContext context = new ActionContext().put(HttpServletRequest.class, request)
                .put(HttpServletResponse.class, response).put(ServletContext.class, request.getServletContext())
                .put(Path.class, path);
        if (path == null) {
            notFound.handle(context);
            return;
        }
        BasicFileAttributes attr;
        try {
            attr = Files.readAttributes(path, BasicFileAttributes.class);
        } catch (IOException ex) {
            notFound.handle(context);
            return;
        }
        context.put(BasicFileAttributes.class, attr);

        boolean isError = response.getStatus() >= HttpServletResponse.SC_BAD_REQUEST;
        // Check if the conditions specified in the optional If headers are
        // satisfied.
        // Checking If headers
        boolean included = (request.getAttribute(RequestDispatcher.INCLUDE_CONTEXT_PATH) != null);
        String etag = this.eTag.getValue(context);
        if (!included && !isError && !checkIfHeaders(request, response, attr, etag)) {
            return;
        }
        // Find content type.
        String contentType = contentTypeResolver.getValue(context);
        // Get content length
        long contentLength = attr.size();
        // Special case for zero length files, which would cause a
        // (silent) ISE
        boolean serveContent = content && contentLength != 0;
        Range[] ranges = null;
        if (!isError) {
            if (useAcceptRanges) {
                // Accept ranges header
                response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
            }
            // Parse range specifier
            ranges = serveContent ? parseRange(request, response, attr, etag) : FULL;
            // ETag header
            response.setHeader(HttpHeaders.ETAG, etag);
            // Last-Modified header
            response.setDateHeader(HttpHeaders.LAST_MODIFIED, attr.lastModifiedTime().toMillis());
        }
        ServletOutputStream ostream = null;
        if (serveContent) {
            ostream = response.getOutputStream();
        }

        String disposition = contentDisposition.getValue(context);
        if (disposition != null) {
            response.setHeader(HttpHeaders.CONTENT_DISPOSITION, disposition);
        }

        // Check to see if a Filter, Valve of wrapper has written some content.
        // If it has, disable range requests and setting of a content length
        // since neither can be done reliably.
        if (isError || ranges == FULL) {
            // Set the appropriate output headers
            if (contentType != null) {
                log.debug("serveFile: contentType='{}'", contentType);
                response.setContentType(contentType);
            }
            if (contentLength >= 0) {
                setContentLengthLong(response, contentLength);
            }
            // Copy the input stream to our output stream (if requested)
            if (serveContent) {
                log.trace("Serving bytes");
                Files.copy(path, ostream);
            }
        } else if (ranges != null && ranges.length != 0) {
            // Partial content response.
            response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
            if (ranges.length == 1) {
                Range range = ranges[0];
                response.addHeader(HttpHeaders.CONTENT_RANGE, range.toString());
                long length = range.end - range.start + 1;
                setContentLengthLong(response, length);
                if (contentType != null) {
                    log.debug("serveFile: contentType='{}'", contentType);
                    response.setContentType(contentType);
                }
                if (serveContent) {
                    try (InputStream stream = Files.newInputStream(path)) {
                        copyRange(stream, ostream, range, new byte[Math.min((int) length, 8192)]);
                    }
                }
            } else {
                response.setContentType("multipart/byteranges; boundary=" + MIME_SEPARATION);
                if (serveContent) {
                    copy(path, ostream, ranges, contentType, new byte[Math.min((int) contentLength, 8192)]);
                }
            }
        }
    }

    /**
     * Parse the range header.
     *
     * @param request The servlet request we are processing
     * @param response The servlet response we are creating
     * @param attr File attributes
     * @param etag ETag of the entity
     * @return array of ranges
     */
    @Nullable
    @SuppressWarnings("ReturnOfCollectionOrArrayField")
    private Range[] parseRange(HttpServletRequest request, HttpServletResponse response, BasicFileAttributes attr,
            String etag) throws IOException {
        // Checking If-Range
        String headerValue = request.getHeader(HttpHeaders.IF_RANGE);
        if (headerValue != null) {
            long headerValueTime = -1;
            try {
                headerValueTime = request.getDateHeader(HttpHeaders.IF_RANGE);
            } catch (IllegalArgumentException e) {
                // Ignore
            }
            // If the ETag the client gave does not match the entity
            // eTag, then the entire entity is returned.
            if (headerValueTime == -1 && !headerValue.trim().equals(etag)
                    || attr.lastModifiedTime().toMillis() > headerValueTime + 1000) {
                // If the timestamp of the entity the client got is older than
                // the last modification date of the entity, the entire entity
                // is returned.
                return FULL;
            }
        }
        long fileLength = attr.size();
        if (fileLength == 0) {
            return FULL;
        }
        // Retrieving the range header (if any is specified
        String rangeHeader = request.getHeader(HttpHeaders.RANGE);
        if (rangeHeader == null) {
            return FULL;
        }
        // bytes is the only range unit supported (and I don't see the point
        // of adding new ones).
        if (!rangeHeader.startsWith("bytes=")) {
            return FULL;
        }
        // List which will contain all the ranges which are successfully
        // parsed.
        List<Range> result = new ArrayList<>(4);
        // Parsing the range list
        // "bytes=".length() = 6
        for (int index, last = 6;; last = index + 1) {
            index = rangeHeader.indexOf(',', last);
            boolean isLast = index == -1;
            final String rangeDefinition = (isLast ? rangeHeader.substring(last)
                    : rangeHeader.substring(last, index)).trim();
            final int dashPos = rangeDefinition.indexOf('-');
            if (dashPos == -1) {
                break;
            }
            final Range currentRange = new Range(fileLength);
            try {
                if (dashPos == 0) {
                    final long offset = Long.parseLong(rangeDefinition);
                    if (offset == 0) { // -0, --0
                        break;
                    }
                    currentRange.start = Math.max(fileLength + offset, 0);
                } else {
                    currentRange.start = Long.parseLong(rangeDefinition.substring(0, dashPos));
                    if (dashPos < rangeDefinition.length() - 1) {
                        currentRange.end = Long
                                .parseLong(rangeDefinition.substring(dashPos + 1, rangeDefinition.length()));
                    }
                }
            } catch (NumberFormatException e) {
                break;
            }
            if (!currentRange.validate()) {
                break;
            }
            result.add(currentRange);
            if (isLast) {
                int size = result.size();
                if (size == 0) {
                    break;
                }
                return result.toArray(new Range[size]);
            }
        }
        response.addHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + fileLength);
        response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
        return null;
    }

    /**
     * Check if the if-match condition is satisfied.
     *
     * @param request The servlet request we are processing
     * @param etag ETag of the entity
     */
    private void checkIfMatch(HttpServletRequest request, String etag) {
        String headerValue = request.getHeader(HttpHeaders.IF_MATCH);
        if (headerValue != null && headerValue.indexOf('*') == -1 && !anyMatches(headerValue, etag)) {
            // If none of the given ETags match, 412 Precodition failed is
            // sent back
            throw new UncheckException(HttpServletResponse.SC_PRECONDITION_FAILED);
        }
    }

    /**
     * Check if the if-modified-since condition is satisfied.
     *
     * @param request The servlet request we are processing
     * @param attr File attributes
     */
    @SuppressWarnings("NestedAssignment")
    private void checkIfModifiedSince(HttpServletRequest request, BasicFileAttributes attr) {
        try {
            long headerValue;
            // If an If-None-Match header has been specified, if modified since
            // is ignored.
            if (request.getHeader(HttpHeaders.IF_NONE_MATCH) == null
                    && (headerValue = request.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE)) != -1
                    && attr.lastModifiedTime().toMillis() < headerValue + 1000) {
                // The entity has not been modified since the date
                // specified by the client. This is not an error case.
                throw new UncheckException(HttpServletResponse.SC_NOT_MODIFIED);
            }
        } catch (IllegalArgumentException ex) {
        }
    }

    /**
     * Check if the if-none-match condition is satisfied.
     *
     * @param request The servlet request we are processing
     * @param etag ETag of the entity
     */
    private void checkIfNoneMatch(HttpServletRequest request, String etag) {
        String headerValue = request.getHeader(HttpHeaders.IF_NONE_MATCH);
        if (headerValue != null && (headerValue.equals("*") || anyMatches(headerValue, etag))) {
            // For GET and HEAD, we should respond with
            // 304 Not Modified.
            // For every other method, 412 Precondition Failed is sent
            // back.
            String method = request.getMethod();
            if ("GET".equals(method) || "HEAD".equals(method)) {
                throw new UncheckException(HttpServletResponse.SC_NOT_MODIFIED);
            } else {
                throw new UncheckException(HttpServletResponse.SC_PRECONDITION_FAILED);
            }
        }
    }

    /**
     * Check if the if-unmodified-since condition is satisfied.
     *
     * @param request The servlet request we are processing
     * @param attr File attributes
     */
    private void checkIfUnmodifiedSince(HttpServletRequest request, BasicFileAttributes attr) {
        if (request.getHeader(HttpHeaders.IF_MATCH) == null) {
            try {
                long lastModified = attr.lastModifiedTime().toMillis();
                long headerValue = request.getDateHeader(HttpHeaders.IF_UNMODIFIED_SINCE);
                if (headerValue != -1 && lastModified >= headerValue + 1000) {
                    // The entity has not been modified since the date
                    // specified by the client. This is not an error case.
                    throw new UncheckException(HttpServletResponse.SC_PRECONDITION_FAILED);
                }
            } catch (IllegalArgumentException ex) {
                throw new UncheckException(HttpServletResponse.SC_PRECONDITION_FAILED);
            }
        }
    }

    /**
     * Copy the contents of the specified input stream to the specified output
     * stream, and ensure that both streams are closed before returning (even in
     * the face of an exception).
     *
     * @param path The cache entry for the source resource
     * @param ostream The output stream to write to
     * @param ranges Enumeration of the ranges the client wanted to retrieve
     * @param contentType Content type of the resource
     * @param buffer buffer to copy the resource
     * @exception IOException if an input/output error occurs
     */
    private void copy(Path path, ServletOutputStream ostream, Range[] ranges, String contentType, byte[] buffer)
            throws IOException {
        IOException exception = null;
        for (Range currentRange : ranges) {
            try (InputStream stream = Files.newInputStream(path)) {
                // Writing MIME header.
                ostream.println();
                ostream.println("--" + MIME_SEPARATION);
                if (contentType != null) {
                    ostream.println(HttpHeaders.CONTENT_TYPE + ": " + contentType);
                }
                ostream.println(HttpHeaders.CONTENT_RANGE + ": " + currentRange);
                ostream.println();
                // Printing content
                copyRange(stream, ostream, currentRange, buffer);
            } catch (IOException ex) {
                exception = ex;
            }
        }
        ostream.println();
        ostream.print("--" + MIME_SEPARATION + "--");
        if (exception != null) {
            throw exception;
        }
    }

    /**
     * Copy the contents of the specified input stream to the specified output
     * stream, and ensure that both streams are closed before returning (even in
     * the face of an exception).
     *
     * @param istream The input stream to read from
     * @param ostream The output stream to write to
     * @param range the range
     * @param buffer buffer to copy the resource
     */
    private void copyRange(InputStream istream, OutputStream ostream, Range range, byte[] buffer)
            throws IOException {
        long start = range.start, end = range.end;
        log.trace("Serving bytes: {}-{}", start, end);
        IOUtils.copy(istream, ostream, start, end + 1 - start, buffer);
    }

    private boolean anyMatches(String headerValue, String etag) {
        StringTokenizer tokenizer = new StringTokenizer(headerValue, ",");
        while (tokenizer.hasMoreTokens()) {
            if (tokenizer.nextToken().trim().equals(etag)) {
                return true;
            }
        }
        return false;
    }

    private void setContentLengthLong(HttpServletResponse response, long length) {
        if (HAS_METHOD_CONTENT_LENGTH_LONG) {
            response.setContentLengthLong(length);
        } else if (length <= Integer.MAX_VALUE) {
            response.setContentLength((int) length);
        } else {
            response.setHeader(HttpHeaders.CONTENT_LENGTH, Long.toString(length));
        }
    }

    private static class Range {

        public long start;
        public long end;
        public long total;

        Range(long length) {
            this.end = length - 1;
            this.total = length;
        }

        /**
         * Validate range.
         */
        public boolean validate() {
            end = Math.min(end, total - 1);
            return start <= end;
        }

        @Override
        public String toString() {
            return "bytes " + start + "-" + end + "/" + total;
        }

    }

    private static class UncheckException extends RuntimeException {

        private static final long serialVersionUID = 1L;

        private final int code;

        UncheckException(int code) {
            this.code = code;
        }

        public int getCode() {
            return code;
        }

        public boolean isError() {
            return code >= HttpServletResponse.SC_BAD_REQUEST;
        }

        @Override
        public Throwable fillInStackTrace() {
            return this;
        }

    }

}