diff --git a/src/main/java/com/oggio88/jpacrepo/servlet/AbstractFileServlet.java b/src/main/java/com/oggio88/jpacrepo/servlet/AbstractFileServlet.java new file mode 100644 index 0000000..db2509d --- /dev/null +++ b/src/main/java/com/oggio88/jpacrepo/servlet/AbstractFileServlet.java @@ -0,0 +1,656 @@ +package com.oggio88.jpacrepo.servlet; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.*; +import java.net.URLEncoder; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + *

+ * The well known "BalusC FileServlet", + * as an abstract template, slightly refactored, rewritten and modernized with a.o. fast NIO stuff instead of legacy + * RandomAccessFile. + *

+ * This servlet properly deals with ETag, If-None-Match and If-Modified-Since + * caching requests, hereby improving browser caching. This servlet also properly deals with Range and + * If-Range ranging requests (RFC7233), which is required + * by most media players for proper audio/video streaming, and by webbrowsers and for a proper resume of an paused + * download, and by download accelerators to be able to request smaller parts simultaneously. This servlet is ideal when + * you have large files like media files placed outside the web application and you can't use the default servlet. + *

+ *

Usage

+ *

+ * Just extend this class and override the {@link #getFile(HttpServletRequest)} method to return the desired file. If + * you want to trigger a HTTP 400 "Bad Request" error, simply throw {@link IllegalArgumentException}. If you want to + * trigger a HTTP 404 "Not Found" error, simply return null, or a non-existent file. + *

+ * Here's a concrete example which serves it via an URL like /media/foo.ext: + *

+ *

+ * @WebServlet("/media/*")
+ * public class MediaFileServlet extends FileServlet {
+ *
+ *     private File folder;
+ *
+ *     @Override
+ *     public void init() throws ServletException {
+ *         folder = new File("/var/webapp/media");
+ *     }
+ *
+ *     @Override
+ *     protected File getFile(HttpServletRequest request) throws IllegalArgumentException {
+ *         String pathInfo = request.getPathInfo();
+ *
+ *         if (pathInfo == null || pathInfo.isEmpty() || "/".equals(pathInfo)) {
+ *             throw new IllegalArgumentException();
+ *         }
+ *
+ *         return new File(folder, pathInfo);
+ *     }
+ *
+ * }
+ * 
+ *

+ * You can embed it in e.g. HTML5 video tag as below: + *

+ * <video src="#{request.contextPath}/media/video.mp4" controls="controls" />
+ * 
+ *

+ *

Customizing FileServlet

+ *

+ * If more fine grained control is desired for handling "file not found" error, determining the cache expire time, the + * content type, whether the file should be supplied as an attachment and the attachment's file name, then the developer + * can opt to override one or more of the following protected methods: + *

+ *

+ *

See also: + *

+ * + * @author Bauke Scholtz + * @since 2.2 + */ +public abstract class AbstractFileServlet extends HttpServlet +{ + + // Constants ------------------------------------------------------------------------------------------------------ + + private static final long serialVersionUID = 1L; + + private static final int DEFAULT_STREAM_BUFFER_SIZE = 10240; + private static final String ERROR_UNSUPPORTED_ENCODING = "UTF-8 is apparently not supported on this platform."; + private static final Long DEFAULT_EXPIRE_TIME_IN_SECONDS = TimeUnit.DAYS.toSeconds(30); + private static final long ONE_SECOND_IN_MILLIS = TimeUnit.SECONDS.toMillis(1); + private static final String ETAG = "W/\"%s-%s\""; + private static final Pattern RANGE_PATTERN = Pattern.compile("^bytes=[0-9]*-[0-9]*(,[0-9]*-[0-9]*)*$"); + private static final String CONTENT_DISPOSITION_HEADER = "%s;filename=\"%2$s\"; filename*=UTF-8''%2$s"; + private static final String MULTIPART_BOUNDARY = UUID.randomUUID().toString(); + + private static long stream(InputStream input, OutputStream output) throws IOException + { + try (ReadableByteChannel inputChannel = Channels.newChannel(input); + WritableByteChannel outputChannel = Channels.newChannel(output)) + { + ByteBuffer buffer = ByteBuffer.allocateDirect(DEFAULT_STREAM_BUFFER_SIZE); + long size = 0; + + while (inputChannel.read(buffer) != -1) + { + buffer.flip(); + size += outputChannel.write(buffer); + buffer.clear(); + } + + return size; + } + } + + /** + * Stream a specified range of the given file to the given output via NIO {@link Channels} and a directly allocated + * NIO {@link ByteBuffer}. The output stream will only implicitly be closed after streaming when the specified range + * represents the whole file, regardless of whether an exception is been thrown or not. + * + * @param file The file. + * @param output The output stream. + * @param start The start position (offset). + * @param length The (intented) length of written bytes. + * @return The (actual) length of the written bytes. This may be smaller when the given length is too large. + * @throws IOException When an I/O error occurs. + * @since 2.2 + */ + public static long stream(File file, OutputStream output, long start, long length) throws IOException + { + if (start == 0 && length >= file.length()) + { + return stream(new FileInputStream(file), output); + } + + try (FileChannel fileChannel = (FileChannel) Files.newByteChannel(file.toPath(), StandardOpenOption.READ)) + { + WritableByteChannel outputChannel = Channels.newChannel(output); + ByteBuffer buffer = ByteBuffer.allocateDirect(DEFAULT_STREAM_BUFFER_SIZE); + long size = 0; + + while (fileChannel.read(buffer, start + size) != -1) + { + buffer.flip(); + + if (size + buffer.limit() > length) + { + buffer.limit((int) (length - size)); + } + + size += outputChannel.write(buffer); + + if (size >= length) + { + break; + } + + buffer.clear(); + } + + return size; + } + } + + + private static String encodeURL(String string) + { + if (string == null) + { + return null; + } + + try + { + return URLEncoder.encode(string, UTF_8.name()); + } + catch (UnsupportedEncodingException e) + { + throw new UnsupportedOperationException(ERROR_UNSUPPORTED_ENCODING, e); + } + } + + private static String encodeURI(String string) + { + if (string == null) + { + return null; + } + + return encodeURL(string) + .replace("+", "%20") + .replace("%21", "!") + .replace("%27", "'") + .replace("%28", "(") + .replace("%29", ")") + .replace("%7E", "~"); + } + + // Actions -------------------------------------------------------------------------------------------------------- + + @Override + protected void doHead(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException + { + doRequest(request, response, true); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException + { + doRequest(request, response, false); + } + + private void doRequest(HttpServletRequest request, HttpServletResponse response, boolean head) throws IOException + { + response.reset(); + Resource resource; + + try + { + resource = new Resource(getFile(request)); + } + catch (IllegalArgumentException e) + { + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + if (resource.file == null) + { + handleFileNotFound(request, response); + return; + } + + if (preconditionFailed(request, resource)) + { + response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); + return; + } + + setCacheHeaders(response, resource, getExpireTime(request, resource.file)); + + if (notModified(request, resource)) + { + response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + return; + } + + List ranges = getRanges(request, resource); + + if (ranges == null) + { + response.setHeader("Content-Range", "bytes */" + resource.length); + response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + return; + } + + if (!ranges.isEmpty()) + { + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + } + else + { + ranges.add(new Range(0, resource.length - 1)); // Full content. + } + + String contentType = setContentHeaders(request, response, resource, ranges); + + if (head) + { + return; + } + + writeContent(response, resource, ranges, contentType); + } + + /** + * Returns the file associated with the given HTTP servlet request. + * If this method throws {@link IllegalArgumentException}, then the servlet will return a HTTP 400 error. + * If this method returns null, or if {@link File#isFile()} returns false, then the + * servlet will invoke {@link #handleFileNotFound(HttpServletRequest, HttpServletResponse)}. + * + * @param request The involved HTTP servlet request. + * @return The file associated with the given HTTP servlet request. + * @throws IllegalArgumentException When the request is mangled in such way that it's not recognizable as a valid + * file request. The servlet will then return a HTTP 400 error. + */ + protected abstract File getFile(HttpServletRequest request) throws IllegalArgumentException; + + /** + * Handles the case when the file is not found. + *

+ * The default implementation sends a HTTP 404 error. + * + * @param request The involved HTTP servlet request. + * @param response The involved HTTP servlet response. + * @throws IOException When something fails at I/O level. + * @since 2.3 + */ + protected void handleFileNotFound(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } + + /** + * Returns how long the resource may be cached by the client before it expires, in seconds. + *

+ * The default implementation returns 30 days in seconds. + * + * @param request The involved HTTP servlet request. + * @param file The involved file. + * @return The client cache expire time in seconds (not milliseconds!). + */ + protected long getExpireTime(HttpServletRequest request, File file) + { + return DEFAULT_EXPIRE_TIME_IN_SECONDS; + } + + /** + * Returns the content type associated with the given HTTP servlet request and file. + *

+ * The default implementation delegates {@link File#getName()} to {@link ServletContext#getMimeType(String)} with a + * fallback default value of application/octet-stream. + * + * @param request The involved HTTP servlet request. + * @param file The involved file. + * @return The content type associated with the given HTTP servlet request and file. + */ + protected String getContentType(HttpServletRequest request, File file) + { + String type = request.getServletContext().getMimeType(file.getName()); + if (type != null) + { + return type; + } + else + { + return "application/octet-stream"; + } + } + + /** + * Returns true if we must force a "Save As" dialog based on the given HTTP servlet request and content + * type as obtained from {@link #getContentType(HttpServletRequest, File)}. + *

+ * The default implementation will return true if the content type does not start with + * text or image, and the Accept request header is either null + * or does not match the given content type. + * + * @param request The involved HTTP servlet request. + * @param contentType The content type of the involved file. + * @return true if we must force a "Save As" dialog based on the given HTTP servlet request and content + * type. + */ + protected boolean isAttachment(HttpServletRequest request, String contentType) + { + String accept = request.getHeader("Accept"); + return (!contentType.startsWith("text") && !contentType.startsWith("image")) && (accept == null || !accepts(accept, contentType)); + } + + /** + * Returns the file name to be used in Content-Disposition header. + * This does not need to be URL-encoded as this will be taken care of. + *

+ * The default implementation returns {@link File#getName()}. + * + * @param request The involved HTTP servlet request. + * @param file The involved file. + * @return The file name to be used in Content-Disposition header. + * @since 2.3 + */ + protected String getAttachmentName(HttpServletRequest request, File file) + { + return file.getName(); + } + + // Sub-actions ---------------------------------------------------------------------------------------------------- + + /** + * Returns true if it's a conditional request which must return 412. + */ + private boolean preconditionFailed(HttpServletRequest request, Resource resource) + { + String match = request.getHeader("If-Match"); + long unmodified = request.getDateHeader("If-Unmodified-Since"); + return (match != null) ? !matches(match, resource.eTag) : (unmodified != -1 && modified(unmodified, resource.lastModified)); + } + + /** + * Set cache headers. + */ + private void setCacheHeaders(HttpServletResponse response, Resource resource, long expires) + { + //Servlets.setCacheHeaders(response, expires); + response.setHeader("ETag", resource.eTag); + response.setDateHeader("Last-Modified", resource.lastModified); + } + + /** + * Returns true if it's a conditional request which must return 304. + */ + private boolean notModified(HttpServletRequest request, Resource resource) + { + String noMatch = request.getHeader("If-None-Match"); + long modified = request.getDateHeader("If-Modified-Since"); + return (noMatch != null) ? matches(noMatch, resource.eTag) : (modified != -1 && !modified(modified, resource.lastModified)); + } + + /** + * Get requested ranges. If this is null, then we must return 416. If this is empty, then we must return full file. + */ + private List getRanges(HttpServletRequest request, Resource resource) + { + List ranges = new ArrayList<>(1); + String rangeHeader = request.getHeader("Range"); + + if (rangeHeader == null) + { + return ranges; + } + else if (!RANGE_PATTERN.matcher(rangeHeader).matches()) + { + return null; // Syntax error. + } + + String ifRange = request.getHeader("If-Range"); + + if (ifRange != null && !ifRange.equals(resource.eTag)) + { + try + { + long ifRangeTime = request.getDateHeader("If-Range"); + + if (ifRangeTime != -1 && modified(ifRangeTime, resource.lastModified)) + { + return ranges; + } + } + catch (IllegalArgumentException ifRangeHeaderIsInvalid) + { + return ranges; + } + } + + for (String rangeHeaderPart : rangeHeader.split("=")[1].split(",")) + { + Range range = parseRange(rangeHeaderPart, resource.length); + + if (range == null) + { + return null; // Logic error. + } + + ranges.add(range); + } + + return ranges; + } + + /** + * Parse range header part. Returns null if there's a logic error (i.e. start after end). + */ + private Range parseRange(String range, long length) + { + long start = sublong(range, 0, range.indexOf('-')); + long end = sublong(range, range.indexOf('-') + 1, range.length()); + + if (start == -1) + { + start = length - end; + end = length - 1; + } + else if (end == -1 || end > length - 1) + { + end = length - 1; + } + + if (start > end) + { + return null; // Logic error. + } + + return new Range(start, end); + } + + /** + * Set content headers. + */ + private String setContentHeaders(HttpServletRequest request, HttpServletResponse response, Resource resource, List ranges) + { + String contentType = getContentType(request, resource.file); + String disposition = isAttachment(request, contentType) ? "attachment" : "inline"; + String filename = encodeURI(getAttachmentName(request, resource.file)); + response.setHeader("Content-Disposition", String.format(CONTENT_DISPOSITION_HEADER, disposition, filename)); + response.setHeader("Accept-Ranges", "bytes"); + + if (ranges.size() == 1) + { + Range range = ranges.get(0); + response.setContentType(contentType); + response.setHeader("Content-Length", String.valueOf(range.length)); + + if (response.getStatus() == HttpServletResponse.SC_PARTIAL_CONTENT) + { + response.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + resource.length); + } + } + else + { + response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY); + } + + return contentType; + } + + /** + * Write given file to response with given content type and ranges. + */ + private void writeContent(HttpServletResponse response, Resource resource, List ranges, String contentType) throws IOException + { + ServletOutputStream output = response.getOutputStream(); + + if (ranges.size() == 1) + { + Range range = ranges.get(0); + stream(resource.file, output, range.start, range.length); + } + else + { + for (Range range : ranges) + { + output.println(); + output.println("--" + MULTIPART_BOUNDARY); + output.println("Content-Type: " + contentType); + output.println("Content-Range: bytes " + range.start + "-" + range.end + "/" + resource.length); + stream(resource.file, output, range.start, range.length); + } + + output.println(); + output.println("--" + MULTIPART_BOUNDARY + "--"); + } + } + + // Helpers -------------------------------------------------------------------------------------------------------- + + /** + * Returns true if the given match header matches the given ETag value. + */ + private static boolean matches(String matchHeader, String eTag) + { + String[] matchValues = matchHeader.split("\\s*,\\s*"); + Arrays.sort(matchValues); + return Arrays.binarySearch(matchValues, eTag) > -1 + || Arrays.binarySearch(matchValues, "*") > -1; + } + + /** + * Returns true if the given modified header is older than the given last modified value. + */ + private static boolean modified(long modifiedHeader, long lastModified) + { + return (modifiedHeader + ONE_SECOND_IN_MILLIS <= lastModified); // That second is because the header is in seconds, not millis. + } + + /** + * Returns a substring of the given string value from the given begin index to the given end index as a long. + * If the substring is empty, then -1 will be returned. + */ + private static long sublong(String value, int beginIndex, int endIndex) + { + String substring = value.substring(beginIndex, endIndex); + return substring.isEmpty() ? -1 : Long.parseLong(substring); + } + + /** + * Returns true if the given accept header accepts the given value. + */ + private static boolean accepts(String acceptHeader, String toAccept) + { + String[] acceptValues = acceptHeader.split("\\s*(,|;)\\s*"); + Arrays.sort(acceptValues); + return Arrays.binarySearch(acceptValues, toAccept) > -1 + || Arrays.binarySearch(acceptValues, toAccept.replaceAll("/.*$", "/*")) > -1 + || Arrays.binarySearch(acceptValues, "*/*") > -1; + } + + // Nested classes ------------------------------------------------------------------------------------------------- + + /** + * Convenience class for a file resource. + */ + private static class Resource + { + private final File file; + private final long length; + private final long lastModified; + private final String eTag; + + public Resource(File file) + { + if (file != null && file.isFile()) + { + this.file = file; + length = file.length(); + lastModified = file.lastModified(); + eTag = String.format(ETAG, encodeURL(file.getName()), lastModified); + } + else + { + this.file = null; + length = 0; + lastModified = 0; + eTag = null; + } + } + + } + + /** + * Convenience class for a byte range. + */ + private static class Range + { + private final long start; + private final long end; + private final long length; + + public Range(long start, long end) + { + this.start = start; + this.end = end; + length = end - start + 1; + } + + } + +} + diff --git a/src/main/java/com/oggio88/jpacrepo/servlet/FileServlet.java b/src/main/java/com/oggio88/jpacrepo/servlet/FileServlet.java new file mode 100644 index 0000000..18adff2 --- /dev/null +++ b/src/main/java/com/oggio88/jpacrepo/servlet/FileServlet.java @@ -0,0 +1,30 @@ +package com.oggio88.jpacrepo.servlet; + + +import com.oggio88.jpacrepo.context.ApplicationContext; +import com.oggio88.jpacrepo.context.DefaultConfiguration; + +import javax.inject.Inject; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import java.io.File; + +/** + * Created by walter on 29/07/15. + */ + + +@WebServlet("/archive/*") +public class FileServlet extends AbstractFileServlet +{ + @Inject + @DefaultConfiguration + private ApplicationContext ctx; + + @Override + protected File getFile(HttpServletRequest request) throws IllegalArgumentException + { + return new File(ctx.getSystemProperties().getProperty("RepoFolder"), request.getPathInfo().substring(1)); + } + +} \ No newline at end of file