WARNING - OUTDATED CONTENT!
Since OmniFaces 2.2, the below file servlet has been reworked, modernized and refactored into a highly reusable abstract org.omnifaces.servlet.FileServlet class in JSF utility library OmniFaces.
In the almost 2 year old FileServlet and ImageServlet articles you can find basic examples of a download servlet and an image servlet. It does in fact nothing more than obtaining an InputStream of the desired resource/file and writing it to the OutputStream of the HTTP response along with a set of important response headers. It does not support resumes and effective caching of client side data.
If one downloaded a big file and got network problems on 99% of the file, one wouldn't be happy to discover the need to download the complete file again after getting network back. If a browser decides to check the cached images for changes, it would send a HEAD request to determine under each the unique file identifier and its timestamp or it would send a conditional GET request to determine the response status. If the image isn't changed according to the server response, the client won't re-request the image again to save the network bandwidth and other efforts.
You could leverage the task to a default servlet of the webcontainer/appserver you're using, but most of them doesn't implement all of the performance enhancements, so does for example Tomcat's DefaultServlet not support the Expires header.
To enable download resumes, the server have to send at least the Accept-Ranges, ETag and Last-Modified response headers to the client along with the file.
The Accept-Ranges response header with the value "bytes" informs the client that the server supports byte-range requests. With this the client could request for a specific byte range using the Range request header.
The ETag response header should contain a value which represents an unique identifier of the file in question so that both the server and the client can identify the file. You can use a combination of the file name, file size and file modification timestap for this. Some servers hauls this combination through a MD5 function to get an unique 32 character hexadecimal string. But this is not necessarily unique because two different strings could generate the same MD5 hash, so we won't use it here. The client could resend the obtained ETag back to the server for validation using the If-Match or If-Range request headers.
The Last-Modified response header should contain a date which represents the last modification timestamp of the file as it is at the server side. The client could resend the obtained timestamp back to the server for validation using the If-Unmodified-Since or If-Range request headers. Important note: keep in mind that the timestamp accuracy in server side Java is in milliseconds while the accurancy of the Last-Modified header is in seconds. In Java code you should add 1 second (1000ms) to the value of the If-* request headers to bridge this difference before validation.
Whenever the client sends a partial GET request with a Range request header to the server, then server should intercept on the conditional GET request headers (all headers starting with If) and handle accordingly. Whenever the If-Match or If-Unmodified-Since conditions are negative, the server should send a 412 "Precondition Failed" response back without any content. Whenever the If-Range condition is negative, then the server should ignore the Range header and send the full file back. Whenever the Range header is in invalid format, then the server should send a 416 "Requested Range Not Satisfiable" response back without any content.
If a partial GET request with a valid Range header is sent by the client, then the server should send the specific byte range(s) back as a 206 "Partial Content" response.
The principle is the same as with resume downloads, with the only difference that no Range request header is been sent to the server. The server only have to check and validate any conditional GET request headers and respond accordingly. Usually those are the If-None-Match or If-Modified-Since request headers. The client could also send a HEAD request (for which the server should respond exactly like a GET, but completely without content) and determine the obtained ETag and Last-Modified response headers itself.
Whenever the If-None-Match or If-Modified-Since conditions are positive, the server should send a 304 "Not Modified" response back without any content. If this happens, then the client is allowed to use the content which is already available in the client side cache.
Further on you can use the Expires response header to inform the client how long to keep the content in the client side cache without firing any request about that, even no HEAD requests.
To save more network bandwitch, we could compress text files (text/javascript, text/css, text/xml, text/csv, etcetera) with GZIP. Generally you can save up to 70% of network bandwidth by compressing text files with GZIP. We only need to check if the client accepts GZIP encoding by checking if the Accept-Encoding header contains "gzip". If this is true, and the client is requesting the full file, then the full text file will be compressed. Statistics learn that about 90% of the browsers supports GZIP.
This may also be possible for all files other than text, but as it usually concerns images and another kinds of (large) binary files, it may unnecessarily generate too much overhead to (de)compress them.
OK, enough boring technical background blah, now on to the code!
This fileservlet does everything what it should do based on the request headers as described above. It also supports multipart byte requests (the client could send multiple ranges commaseparated along with the Range header). The whole stuff is targeted on at least Java EE 5 and developed and tested in Eclipse 3.4 with Tomcat 6. It is tested with different webbrowsers (FireFox2/3, IE6/7/8, Opera8/9, Safari2/3 and Chrome) and also with a plain vanilla Java Application using URLConnection.
You can use it for any file types: binary files, text files, images, etcetera. When the requested file is a text file or an image or when its content type is covered by the Accept request header of the client, then it will be displayed inline, otherwise it will be sent as an attachment which will pop up a 'save as' dialogue.
It's almost 485 lines of code of which the nearly half are less or more rudimentary due to comments (read them all though), long-code line breaks and blank lines. You can just copy'n'paste and run it. You're free to make changes whenever needed as long as it's not for commercial use.
package net.balusc.webapp;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.zip.GZIPOutputStream;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class FileServlet extends HttpServlet {
private static final int DEFAULT_BUFFER_SIZE = 10240;
private static final long DEFAULT_EXPIRE_TIME = 604800000L;
private static final String MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES";
private String basePath;
public void init() throws ServletException {
this.basePath = getInitParameter("basePath");
if (this.basePath == null) {
throw new ServletException("FileServlet init param 'basePath' is required.");
} else {
File path = new File(this.basePath);
if (!path.exists()) {
throw new ServletException("FileServlet init param 'basePath' value '"
+ this.basePath + "' does actually not exist in file system.");
} else if (!path.isDirectory()) {
throw new ServletException("FileServlet init param 'basePath' value '"
+ this.basePath + "' is actually not a directory in file system.");
} else if (!path.canRead()) {
throw new ServletException("FileServlet init param 'basePath' value '"
+ this.basePath + "' is actually not readable in file system.");
}
}
}
protected void doHead(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException
{
processRequest(request, response, false);
}
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException
{
processRequest(request, response, true);
}
private void processRequest
(HttpServletRequest request, HttpServletResponse response, boolean content)
throws IOException
{
String requestedFile = request.getPathInfo();
if (requestedFile == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
File file = new File(basePath, URLDecoder.decode(requestedFile, "UTF-8"));
if (!file.exists()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
String fileName = file.getName();
long length = file.length();
long lastModified = file.lastModified();
String eTag = fileName + "_" + length + "_" + lastModified;
long expires = System.currentTimeMillis() + DEFAULT_EXPIRE_TIME;
String ifNoneMatch = request.getHeader("If-None-Match");
if (ifNoneMatch != null && matches(ifNoneMatch, eTag)) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
response.setHeader("ETag", eTag);
response.setDateHeader("Expires", expires);
return;
}
long ifModifiedSince = request.getDateHeader("If-Modified-Since");
if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
response.setHeader("ETag", eTag);
response.setDateHeader("Expires", expires);
return;
}
String ifMatch = request.getHeader("If-Match");
if (ifMatch != null && !matches(ifMatch, eTag)) {
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return;
}
long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since");
if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) {
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return;
}
Range full = new Range(0, length - 1, length);
List<Range> ranges = new ArrayList<Range>();
String range = request.getHeader("Range");
if (range != null) {
if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) {
response.setHeader("Content-Range", "bytes */" + length);
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
String ifRange = request.getHeader("If-Range");
if (ifRange != null && !ifRange.equals(eTag)) {
try {
long ifRangeTime = request.getDateHeader("If-Range");
if (ifRangeTime != -1 && ifRangeTime + 1000 < lastModified) {
ranges.add(full);
}
} catch (IllegalArgumentException ignore) {
ranges.add(full);
}
}
if (ranges.isEmpty()) {
for (String part : range.substring(6).split(",")) {
long start = sublong(part, 0, part.indexOf("-"));
long end = sublong(part, part.indexOf("-") + 1, part.length());
if (start == -1) {
start = length - end;
end = length - 1;
} else if (end == -1 || end > length - 1) {
end = length - 1;
}
if (start > end) {
response.setHeader("Content-Range", "bytes */" + length);
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
ranges.add(new Range(start, end, length));
}
}
}
String contentType = getServletContext().getMimeType(fileName);
boolean acceptsGzip = false;
String disposition = "inline";
if (contentType == null) {
contentType = "application/octet-stream";
}
if (contentType.startsWith("text")) {
String acceptEncoding = request.getHeader("Accept-Encoding");
acceptsGzip = acceptEncoding != null && accepts(acceptEncoding, "gzip");
contentType += ";charset=UTF-8";
}
else if (!contentType.startsWith("image")) {
String accept = request.getHeader("Accept");
disposition = accept != null && accepts(accept, contentType) ? "inline" : "attachment";
}
response.reset();
response.setBufferSize(DEFAULT_BUFFER_SIZE);
response.setHeader("Content-Disposition", disposition + ";filename=\"" + fileName + "\"");
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("ETag", eTag);
response.setDateHeader("Last-Modified", lastModified);
response.setDateHeader("Expires", expires);
RandomAccessFile input = null;
OutputStream output = null;
try {
input = new RandomAccessFile(file, "r");
output = response.getOutputStream();
if (ranges.isEmpty() || ranges.get(0) == full) {
Range r = full;
response.setContentType(contentType);
if (content) {
if (acceptsGzip) {
response.setHeader("Content-Encoding", "gzip");
output = new GZIPOutputStream(output, DEFAULT_BUFFER_SIZE);
} else {
response.setHeader("Content-Length", String.valueOf(r.length));
}
copy(input, output, r.start, r.length);
}
} else if (ranges.size() == 1) {
Range r = ranges.get(0);
response.setContentType(contentType);
response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total);
response.setHeader("Content-Length", String.valueOf(r.length));
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
if (content) {
copy(input, output, r.start, r.length);
}
} else {
response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
if (content) {
ServletOutputStream sos = (ServletOutputStream) output;
for (Range r : ranges) {
sos.println();
sos.println("--" + MULTIPART_BOUNDARY);
sos.println("Content-Type: " + contentType);
sos.println("Content-Range: bytes " + r.start + "-" + r.end + "/" + r.total);
copy(input, output, r.start, r.length);
}
sos.println();
sos.println("--" + MULTIPART_BOUNDARY + "--");
}
}
} finally {
close(output);
close(input);
}
}
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;
}
private static boolean matches(String matchHeader, String toMatch) {
String[] matchValues = matchHeader.split("\\s*,\\s*");
Arrays.sort(matchValues);
return Arrays.binarySearch(matchValues, toMatch) > -1
|| Arrays.binarySearch(matchValues, "*") > -1;
}
private static long sublong(String value, int beginIndex, int endIndex) {
String substring = value.substring(beginIndex, endIndex);
return (substring.length() > 0) ? Long.parseLong(substring) : -1;
}
private static void copy(RandomAccessFile input, OutputStream output, long start, long length)
throws IOException
{
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int read;
if (input.length() == length) {
while ((read = input.read(buffer)) > 0) {
output.write(buffer, 0, read);
}
} else {
input.seek(start);
long toRead = length;
while ((read = input.read(buffer)) > 0) {
if ((toRead -= read) > 0) {
output.write(buffer, 0, read);
} else {
output.write(buffer, 0, (int) toRead + read);
break;
}
}
}
}
private static void close(Closeable resource) {
if (resource != null) {
try {
resource.close();
} catch (IOException ignore) {
}
}
}
protected class Range {
long start;
long end;
long length;
long total;
public Range(long start, long end, long total) {
this.start = start;
this.end = end;
this.length = end - start + 1;
this.total = total;
}
}
}
In order to get the FileServlet to work, add the following entries to the Web Deployment Descriptor web.xml:
<servlet>
<servlet-name>fileServlet</servlet-name>
<servlet-class>net.balusc.webapp.FileServlet</servlet-class>
<init-param>
<param-name>basePath</param-name>
<param-value>/path/to/files</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>fileServlet</servlet-name>
<url-pattern>/files/*</url-pattern>
</servlet-mapping>
The basePath value must represent the absolute path to a folder containing all those files. You can of course change the value of the basePath parameter and the url-pattern of the servlet-mapping to your taste.
Here are some basic use examples:
<a href="files/foo.exe">download foo.exe</a>
<a href="files/bar.zip">download bar.zip</a>
<img src="files/pic.jpg" />
<img src="files/logo.gif" />
<h:outputLink value="files/foo.exe">download foo.exe</h:outputLink>
<h:outputLink value="files/bar.zip">download bar.zip</h:outputLink>
<h:outputLink value="files/#{myBean.fileName}">
<h:outputText value="download #{myBean.fileName}" />
</h:outputLink>
<h:graphicImage value="files/pic.jpg" />
<h:graphicImage value="files/logo.gif" />
<h:graphicImage value="files/#{myBean.imageFileName}" />
Important note: this servlet example does not take the requested file as request parameter, but just as part of the absolute URL, because a certain widely used browser developed by a team in Redmond would take the last part of the servlet URL path as filename during the 'Save As' dialogue instead of the in the headers supplied filename. Using the filename as part of the absolute URL (and thus not as request parameter) will fix this utterly stupid behaviour. As a bonus, the URL's look much nicer without query parameters.
Copyright - GNU Lesser General Public License
(C) February 2009, BalusC