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:
+ *
+ * - {@link #handleFileNotFound(HttpServletRequest, HttpServletResponse)}
+ *
- {@link #getExpireTime(HttpServletRequest, File)}
+ *
- {@link #getContentType(HttpServletRequest, File)}
+ *
- {@link #isAttachment(HttpServletRequest, String)}
+ *
- {@link #getAttachmentName(HttpServletRequest, File)}
+ *
+ *
+ *
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