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 &quot;root directory&quot; 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 &quot;<code>combo</code>&quot;.</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>&quot;<code>zipEntry</code>&quot;, default: Value of {@link ZipEntry#getTime()}.</li>
 * 			<li>&quot;<code>zipFile</code>&quot;: Value of the zip file's {@link File#lastModified()}.</li>
 * 			<li>&quot;<code>servlet</code>&quot;: Value of {@link System#currentTimeMillis()} upon {@link #init()}.</li>
 * 		</ul>
 * 	</li>
 * </ul>
 *
 * @author Mark A. Ziesemer
 * <a href="http://www.ziesemer.com.">&lt;www.ziesemer.com&gt;</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();
		}
	}
	
}