ZipServlet.java
package com.ziesemer.utils.web.zipServlet;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* <p>{@link HttpServlet} to serve content from within a specified Zip (or Jar) file.</p>
*
* <p>Follows the standards described in
* <a href="http://www.w3.org/Protocols/rfc2616/rfc2616.html">RFC 2616</a>
* as applicable. In particular, this implementation supports
* last modified checking and partial content requests.</p>
*
* <p>Considered items better solved elsewhere:<p>
* <ul>
* <li>Filters to restrict access to/from specified paths/files.
* Chain with a {@link javax.servlet.Filter} instead.</li>
* <li>gzip/deflate compression. Again, chain with a {@link javax.servlet.Filter} instead,
* e.g. {@link com.ziesemer.utils.web.compressFilter.CompressFilter}.
* <ul><li>While it would be ideal to read and send the compressed bytes directly to the client
* from the Zip file as-is when possible (without uncompression or recompression),
* the {@link java.util.zip} APIs don't currently provide for this possibility.</li></ul></li>
* </ul>
*
* <p>Accepted {@link ServletConfig} parameters:</p>
* <ul>
* <li><code>zipFile</code> (Required): Local path of zip file to serve. (Is effectively passed to {@link ZipFile#ZipFile(String)}.)</li>
* <li><code>zipPrefix</code> (Optional): Redefines the "root directory" in the zip file to serve from.</li>
* <li><code>comboEnabled</code> (Optional): Enables or disables the functionality available by
* <a href="http://developer.yahoo.com/yui/articles/hosting/#combo">YUI's Combo Handling</a>.
* <ul>
* <li>Value must be a valid argument to {@link Boolean#parseBoolean(String)}.</li>
* <li>Defaults to {@link Boolean#TRUE}.</li>
* </ul>
* </li>
* <li><code>comboPath</code> (Optional): Path on which combination requests will be answered.
* <ul>
* <li>Defaults to "<code>combo</code>".</li>
* <li>An empty string is valid.</li>
* <li>Any passed query parameters are treated as files to lookup in the zip file and send.
* If there are no query parameters, this functionality is ignored, and an attempt will be made to serve the file
* named by the value of <code>comboPath</code> from the zip file (like the basic functionality,
* as if the combination functionality was disabled).</li>
* </ul>
* </li>
* <li><code>dateMode</code> (Optional): Configures what date to use for the Last-Modified headers and conditional checks.
* <ul>
* <li>"<code>zipEntry</code>", default: Value of {@link ZipEntry#getTime()}.</li>
* <li>"<code>zipFile</code>": Value of the zip file's {@link File#lastModified()}.</li>
* <li>"<code>servlet</code>": Value of {@link System#currentTimeMillis()} upon {@link #init()}.</li>
* </ul>
* </li>
* </ul>
*
* @author Mark A. Ziesemer
* <a href="http://www.ziesemer.com."><www.ziesemer.com></a>
*/
public class ZipServlet extends HttpServlet{
private static final long serialVersionUID = -8477598957089980118L;
protected static final Logger LOGGER = LoggerFactory.getLogger(ZipServlet.class);
// http://www.and.org/texts/server-http
protected static final Pattern RANGE_PATTERN = Pattern.compile(
"\\s*bytes\\s*=\\s*(\\d*)\\s*-\\s*(\\d*)\\s*");
protected static final Pattern COMBO_SPLIT_PATTERN = Pattern.compile(
"&");
protected transient ZipFile zipFile;
protected String zipPrefix;
protected boolean comboEnabled = true;
protected String comboPath = "combo";
protected String dateMode;
protected long initDate;
// Cache for performance
protected String servletName;
@Override
public void init(final ServletConfig config) throws ServletException{
super.init(config);
servletName = config.getServletName();
initZipFile(config);
initZipPrefix(config);
initComboPath(config);
initDateMode(config);
}
protected void initZipFile(final ServletConfig config) throws ServletException{
final String path = getServletContext().getRealPath(config.getInitParameter("file"));
LOGGER.info("({}) Loading: {}", servletName, path);
try{
final File file = new File(path);
initDate = file.lastModified();
zipFile = new ZipFile(file);
}catch(final IOException ioe){
throw new ServletException(ioe);
}
}
protected void initZipPrefix(final ServletConfig config) throws ServletException{
zipPrefix = config.getInitParameter("zipPrefix");
if(zipPrefix == null){
zipPrefix = "";
}else{
if(!(zipPrefix.endsWith("/") || zipPrefix.endsWith("\\"))){
zipPrefix = zipPrefix + "/";
}
LOGGER.debug("({}) zipPrefix: {}", servletName, zipPrefix);
}
}
protected void initComboPath(final ServletConfig config) throws ServletException{
final String comboEnabledStr = config.getInitParameter("comboEnabled");
if(comboEnabledStr != null){
this.comboEnabled = Boolean.parseBoolean(comboEnabledStr);
}
final String comboPath = config.getInitParameter("comboPath");
if(comboPath != null){
this.comboPath = comboPath;
}
}
protected void initDateMode(final ServletConfig config) throws ServletException{
dateMode = config.getInitParameter("dateMode");
if("servlet".equalsIgnoreCase(dateMode)){
initDate = System.currentTimeMillis();
}else if("zipFile".equalsIgnoreCase(dateMode)){
// Valid, nothing else to set.
// initDate already set from the zip file in initZipFile(...).
}else{
// Set default.
dateMode = "zipEntry";
}
// Round off milliseconds. (Not passed in request/response.)
initDate = (initDate / 1000) * 1000;
}
@Override
protected void doHead(final HttpServletRequest request, final HttpServletResponse response)
throws ServletException, IOException{
doGet(request, response, false);
}
@Override
protected void doGet(final HttpServletRequest request, final HttpServletResponse response)
throws ServletException, IOException{
doGet(request, response, true);
}
protected void doGet(final HttpServletRequest request, final HttpServletResponse response, final boolean sendContent)
throws ServletException, IOException{
try{
final String path = request.getPathInfo();
if(comboEnabled && (
(path == null && comboPath.length() == 0)
|| (path != null && comboPath.equals(path.substring(1)))
)){
getComboFiles(request, response, sendContent);
}else{
getSingleFile(request, response, sendContent);
}
}catch(final RequestException re){
// Expected. Assume already handled.
}
}
protected void getSingleFile(final HttpServletRequest request, final HttpServletResponse response, final boolean sendContent)
throws ServletException, IOException{
String path = request.getPathInfo();
if(path == null){
LOGGER.warn("({}) Received empty request.", servletName);
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
// Remove default "/" from request path.
path = zipPrefix + path.substring(1);
LOGGER.debug("({}) Serving: {}", servletName, path);
final ZipEntry entry = zipFile.getEntry(path);
check(entry, response, path, path);
final RequestContext rc = new RequestContext(entry);
if(setHeaders(rc, request, response) && sendContent){
LOGGER.info("({}) Transmitting: {}", servletName, path);
streamResource(rc, request, response);
}
}
protected void getComboFiles(final HttpServletRequest request, final HttpServletResponse response, final boolean sendContent)
throws ServletException, IOException{
doOptions(request, response);
long maxEntryTime = -1;
long contentLength = 0;
String contentType = null;
// Parse the query string ourselves to guarantee files are sent in the requested order.
// (The servlet API doesn't specify nor guarantee the order of getParameterNames().)
final String[] files = COMBO_SPLIT_PATTERN.split(request.getQueryString());
final ZipEntry[] entries = new ZipEntry[files.length];
for(int i=0; i < files.length; i++){
String file = URLDecoder.decode(files[i], "UTF-8");
if(file.startsWith("/")){
file = file.substring(1);
}
final String path = zipPrefix + file;
final ZipEntry entry = entries[i] = zipFile.getEntry(path);
check(entry, response, path, file);
maxEntryTime = Math.max(maxEntryTime, entry.getTime());
contentLength += entry.getSize();
final String tmpContentType = getServletContext().getMimeType(entry.getName());
if(contentType == null){
contentType = tmpContentType;
}else if(!contentType.equals(tmpContentType)){
LOGGER.warn("({}) Incompatible content types: {}", servletName,
contentType + ", " + tmpContentType);
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Incompatible content types starting with: " + file);
return;
}
}
final long checkDate = getCheckDate(maxEntryTime);
if(!checkModifiedDates(checkDate, request, response)){
return;
}
setContentLength(response, contentLength);
response.setContentType(contentType);
if(sendContent){
LOGGER.info("({}) Transmitting combo: {} files", servletName, entries);
for(final ZipEntry entry : entries){
LOGGER.debug("({}) Transmitting combo file: {}", servletName, entry.getName());
streamResource(entry, request, response);
}
}
}
protected void check(final ZipEntry entry, final HttpServletResponse response, final String path, final String file)
throws IOException{
verifyExists(entry, response, path, file);
denyDirectory(entry, response, path);
}
protected void verifyExists(final ZipEntry entry, final HttpServletResponse response, final String path, final String file)
throws IOException{
if(entry == null){
LOGGER.warn("({}) Not Found: {}", servletName, path);
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Not found: " + file);
throw new RequestException();
}
}
/**
* {@link ZipFile#getEntry(String)} returns a {@link ZipEntry} for a directory,
* even if the passed name doesn't end with '/'. However, this entry's
* {@link ZipEntry#isDirectory()} still returns false, so this needs to be
* checked for.
*/
protected void denyDirectory(final ZipEntry entry, final HttpServletResponse response, final String path)
throws IOException{
if(entry.isDirectory() || (entry.getSize() == 0
&& zipFile.getEntry(entry.getName() + "/") != null)){
LOGGER.warn("({}) Directory forbidden: {}", servletName, path);
response.sendError(HttpServletResponse.SC_FORBIDDEN);
throw new RequestException();
}
}
protected boolean setHeaders(final RequestContext rc, final HttpServletRequest request, final HttpServletResponse response)
throws ServletException, IOException{
final ZipEntry entry = rc.entry;
doOptions(request, response);
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.5
if(!response.containsHeader("Content-Encoding")){
response.setHeader("Accept-Ranges", "bytes");
}
// else, Ranges refer to ranges within the encoded entity, and must be handled by the encoder.
final long checkDate = getCheckDate(entry.getTime());
if(!checkModifiedDates(checkDate, request, response)){
return false;
}
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.27
final long rangeDate = request.getDateHeader("If-Range");
// Range, http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
final Range range = rc.range;
if(rangeDate < 0 || rangeDate == checkDate){
final String reqRange = request.getHeader("Range");
if(reqRange != null && !response.containsHeader("Content-Encoding")){
final Matcher m = RANGE_PATTERN.matcher(reqRange);
if(m.matches()){
boolean rangeError = false;
if(m.group(2).length() > 0){
range.end = Long.parseLong(m.group(2));
range.end = Math.min(range.end, entry.getSize() - 1);
}
if(m.group(1).length() > 0){
range.begin = Long.parseLong(m.group(1));
}else if(m.group(2).length() > 0){
// Range is for the last x bytes only.
range.begin = entry.getSize() - range.end;
range.end = entry.getSize() - 1;
}else{
rangeError = true;
}
if(range.begin >= range.end){
rangeError = true;
}
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16
if(rangeError){
response.setHeader("Content-Range", "bytes */" + entry.getSize());
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return false;
}
final String contentRangeHeader = "bytes " + range.begin + "-" + range.end + "/" + entry.getSize();
LOGGER.debug("Sending Content-Range: {}", contentRangeHeader);
response.setHeader("Content-Range", contentRangeHeader);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
}else{
LOGGER.warn("Invalid Content-Range received: {}", reqRange);
}
}
}
if(range.getLength() >= 0){
setContentLength(response, range.getLength());
}
// Content-Type, http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.17
final String mimeType = getServletContext().getMimeType(entry.getName());
if(mimeType != null){
response.setContentType(mimeType);
}
return true;
}
protected long getCheckDate(final long time){
long checkDate = initDate;
if("zipEntry".equalsIgnoreCase(dateMode) && time > 0){
checkDate = (time / 1000) * 1000;
}
return checkDate;
}
protected boolean checkModifiedDates(final long checkDate, final HttpServletRequest request, final HttpServletResponse response){
// Last-Modified
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.29
// As this servlet holds an open reference to the Zip file, the contents
// shouldn't change while this servlet is still active, so there shouldn't
// be a need to combine with the ZipEntry dates (or use ETAGs).
response.setDateHeader("Last-Modified", checkDate);
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.25
if(request.getDateHeader("If-Modified-Since") >= checkDate){
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return false;
}
return true;
}
/**
* Content-Length, <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.13"
* >http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.13</a>
*/
protected void setContentLength(final HttpServletResponse response, final long length){
// http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4187336
if(length <= Integer.MAX_VALUE){
response.setContentLength((int)length);
}else{
response.setHeader("Content-Length", Long.toString(length));
}
}
protected void streamResource(final RequestContext rc, final HttpServletRequest request, final HttpServletResponse response) throws IOException{
final Range range = rc.range;
final OutputStream os = response.getOutputStream();
final InputStream zis = zipFile.getInputStream(rc.entry);
zis.skip(range.begin);
final byte[] buffer = createResponseBuffer(response);
long remain = range.getLength();
int bufRead;
while(((bufRead = zis.read(buffer, 0, Math.min(buffer.length, (int)remain))) != -1) && (remain > 0)){
os.write(buffer, 0, bufRead);
remain -= bufRead;
}
}
protected void streamResource(final ZipEntry entry, final HttpServletRequest request, final HttpServletResponse response) throws IOException{
final OutputStream os = response.getOutputStream();
final InputStream zis = zipFile.getInputStream(entry);
final byte[] buffer = createResponseBuffer(response);
int bufRead;
while(((bufRead = zis.read(buffer)) != -1)){
os.write(buffer, 0, bufRead);
}
}
protected byte[] createResponseBuffer(final HttpServletResponse response){
int bufferSize = response.getBufferSize();
if(bufferSize == 0){
bufferSize = 1 << 10;
}
return new byte[bufferSize];
}
@Override
public void destroy(){
LOGGER.info("({}) destroy() called.", servletName);
try{
zipFile.close();
}catch(final IOException ioe){
LOGGER.warn(ioe.toString(), ioe);
}finally{
super.destroy();
}
}
}