com.meltmedia.cadmium.servlets.BasicFileServlet Maven / Gradle / Ivy
/**
* Copyright 2012 meltmedia
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.meltmedia.cadmium.servlets;
import org.apache.commons.io.IOUtils;
import org.eclipse.jgit.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ServletConfig;
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.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.zip.GZIPOutputStream;
public class BasicFileServlet
extends HttpServlet
{
private final Logger logger = LoggerFactory.getLogger(getClass());
private static final long serialVersionUID = 1L;
public static final String GET_METHOD = "GET";
public static final String HEAD_METHOD = "HEAD";
public static final String ETAG_HEADER = "ETag";
public static final String IF_MATCH_HEADER = "If-Match";
public static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since";
public static final String IF_UNMODIFIED_SINCE_HEADER = "If-Unmodified-Since";
public static final String IF_NONE_MATCH_HEADER = "If-None-Match";
public static final String LAST_MODIFIED_HEADER = "Last-Modified";
public static final String RANGE_HEADER = "Range";
public static final String IF_RANGE_HEADER = "If-Range";
public static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding";
public static final String CONTENT_ENCODING_HEADER = "Content-Encoding";
public static final String CONTENT_TYPE_HEADER = "Content-Type";
public static final String TEXT_HTML_TYPE = "text/html";
public static final String LOCATION_HEADER = "Location";
public static final String CONTENT_DISPOSITION_HEADER = "Content-Disposition";
public static final String ACCEPT_RANGES_HEADER = "Accept-Ranges";
public static final String CONTENT_RANGE_HEADER = "Content-Range";
public static final String CONTENT_LENGTH_HEADER = "Content-Length";
public static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
public static final String ACCEPT_HEADER = "Accept";
public static final String RANGE_BOUNDARY = "RANGES_BOUNDARY_";
protected File contentDir = null;
protected Long lastUpdated = System.currentTimeMillis();
protected List gzipList = new ArrayList();
public BasicFileServlet() {
super();
gzipList.addAll(Arrays.asList(new String [] {"text/*", "application/javascript", "application/x-javascript", "application/json", "application/xml", "application/xslt+xml"}));
}
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
if(this.contentDir == null) {
setBasePath(config.getInitParameter("basePath"));
}
}
protected void setLastUpdated(long lastUpdated) {
this.lastUpdated = lastUpdated;
}
protected void setBasePath(String basePath) throws ServletException {
if(basePath == null) {
throw new ServletException("Please set the base path in init parameter \"basePath\".");
} else {
File contentDir = new File(basePath);
if(!contentDir.exists()) {
throw new ServletException("The basePath \""+basePath+"\" does not exist on the file system.");
} else if(!contentDir.isDirectory()) {
throw new ServletException("The basePath \""+basePath+"\" exists and is not a directory.");
} else if(!contentDir.canRead()) {
throw new ServletException("The basePath\""+basePath+"\" cannot be read.");
}
this.contentDir = contentDir;
}
}
protected String getBasePath() {
return contentDir.toString();
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
processRequest(new FileRequestContext(req, resp, true));
}
@Override
protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
processRequest(new FileRequestContext(req, resp, false));
}
public void processRequest( FileRequestContext context )
throws ServletException, IOException {
try {
// Find the file to serve in the file system. This may redirect for welcome files or send 404 responses.
if(locateFileToServe(context)) return;
// Handle any conditional headers that may be present.
if(handleConditions(context)) return;
// Sets the content type header.
resolveContentType(context);
// Sets compress if Accept-Encoding allows for gzip or identity
if(checkAccepts(context)) return;
try {
if(context.file != null) {
context.response.setHeader(CONTENT_DISPOSITION_HEADER, "inline;filename=\"" + context.file.getName() + "\"");
}
context.response.setHeader(ACCEPT_RANGES_HEADER, "bytes");
if(context.eTag != null) {
context.response.setHeader(ETAG_HEADER, context.eTag);
}
context.response.setDateHeader(LAST_MODIFIED_HEADER, lastUpdated);
if(!context.ranges.isEmpty()) {
context.response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
String rangeBoundary = RANGE_BOUNDARY + UUID.randomUUID().toString();
if(context.ranges.size() > 1) {
context.response.setContentType("multipart/byteranges; boundary=" + rangeBoundary);
context.response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
if(context.sendEntity) {
context.in = new FileInputStream(context.file);
context.out = context.response.getOutputStream();
ServletOutputStream sout = (ServletOutputStream)context.out;
for(Range r : context.ranges) {
sout.println();
sout.println("--"+rangeBoundary);
sout.println("Content-Type: " + context.contentType);
sout.println("Context-Range: bytes " + r.start + "-" + r.end + "/" + context.file.length());
copyPartialContent(context.in, context.out, r);
}
sout.println();
sout.println("--"+rangeBoundary+"--");
}
} else {
Range r = context.ranges.get(0);
context.response.setContentType(context.contentType);
Long rangeLength = calculateRangeLength(context, r);
context.response.setHeader(CONTENT_RANGE_HEADER, "bytes " + r.start + "-" + r.end
+ "/" + context.file.length());
if(context.sendEntity) {
context.in = new FileInputStream(context.file);
context.out = context.response.getOutputStream();
context.response.setHeader(CONTENT_LENGTH_HEADER, rangeLength.toString());
copyPartialContent(context.in, context.out, r);
}
}
} else {
context.response.setStatus(HttpServletResponse.SC_OK);
context.response.setContentType(context.contentType);
if( context.sendEntity ) {
context.response.setHeader(CONTENT_RANGE_HEADER, "bytes 0-" + context.file.length() + "/" + context.file.length());
context.in = new FileInputStream(context.file);
context.out = context.response.getOutputStream();
if( context.compress ) {
context.response.setHeader(CONTENT_ENCODING_HEADER, "gzip");
context.out = new GZIPOutputStream(context.out);
} else context.response.setHeader(CONTENT_LENGTH_HEADER, new Long(context.file.length()).toString());
IOUtils.copy(context.in, context.out);
}
}
}
finally {
IOUtils.closeQuietly(context.in);
IOUtils.closeQuietly(context.out);
}
} catch(IOException ioe) {
logger.error("Received an IOException serving request: " + context.path, ioe);
throw ioe;
} catch(Throwable t) {
logger.error("Failed to serve request: " + context.path, t);
throw new ServletException(t);
}
}
/**
* Copies the given range of bytes from the input stream to the output stream.
*
* @param in
* @param out
* @param r
* @throws IOException
*/
public static void copyPartialContent(InputStream in, OutputStream out, Range r) throws IOException {
IOUtils.copyLarge(in, out, r.start, r.length);
}
/**
* Calculates the length of a given range.
*
* @param context
* @param range
* @return
*/
public static Long calculateRangeLength(FileRequestContext context, Range range) {
if(range.start == -1) range.start = 0;
if(range.end == -1) range.end = context.file.length() - 1;
range.length = range.end - range.start + 1;
return range.length;
}
/**
* Checks the accepts headers and makes sure that we can fulfill the request.
* @param context
* @return
* @throws IOException
*/
protected boolean checkAccepts(FileRequestContext context) throws IOException {
if (!canAccept(context.request.getHeader(ACCEPT_HEADER), false, context.contentType)) {
notAcceptable(context);
return true;
}
if (!(canAccept(context.request.getHeader(ACCEPT_ENCODING_HEADER), false, "identity") || canAccept(context.request.getHeader(ACCEPT_ENCODING_HEADER), false, "gzip"))) {
notAcceptable(context);
return true;
}
if (context.request.getHeader(ACCEPT_ENCODING_HEADER) != null && canAccept(context.request.getHeader(ACCEPT_ENCODING_HEADER), true, "gzip") && shouldGzip(context.contentType)) {
context.compress = true;
}
return false;
}
/**
* Checks for inclusion in the gzipList field. Allows basic wild cards.
* An empty or null field defaults to gzipping of all content types.
* @param contentType
* @return
*/
public boolean shouldGzip(String contentType) {
if(gzipList == null || gzipList.isEmpty()) {
return true;
} else {
return gzipList.contains(contentType) || gzipList.contains(contentType.replaceAll("/[^/]+\\Z", "/*")) || gzipList.contains(contentType.replaceAll("\\A[^/]+/", "*/"));
}
}
/**
* Locates the file to serve. Returns true if locating the file caused the request to be handled.
* @param context
* @return
* @throws IOException
*/
public boolean locateFileToServe( FileRequestContext context ) throws IOException {
context.file = new File( contentDir, context.path);
// if the path is not on the file system, send a 404.
if( !context.file.exists() ) {
context.response.sendError(HttpServletResponse.SC_NOT_FOUND);
return true;
}
// redirect welcome files if needed.
if( handleWelcomeRedirect(context) ) return true;
// if the requested file is a directory, try to find the welcome file.
if( context.file.isDirectory() ) {
context.file = new File(context.file, "index.html");
}
// if the file does not exist, then terminate with a 404.
if( !context.file.exists() ) {
context.response.sendError(HttpServletResponse.SC_NOT_FOUND);
return true;
}
return false;
}
public boolean handleConditions( FileRequestContext context ) throws IOException {
// check the conditions
context.eTag = context.path+"_"+lastUpdated;
context.ifMatch = context.request.getHeader(IF_MATCH_HEADER);
if( context.ifMatch != null && !validateStrong(context.ifMatch, context.eTag) ) {
context.response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return true;
}
context.range = context.request.getHeader(RANGE_HEADER);
try {
context.inRangeDate = context.request.getDateHeader(IF_RANGE_HEADER);
if(context.inRangeDate != -1 && context.inRangeDate >= lastUpdated) {
if(!parseRanges(context)){
invalidRanges(context);
return true;
}
}
} catch(IllegalArgumentException iae) {
logger.error("Invalid range serving request: "+context.path, iae);
context.inRangeETag = context.request.getHeader(IF_RANGE_HEADER);
if(context.inRangeETag != null && validateStrong(context.inRangeETag, context.eTag ) ) {
if(!parseRanges(context)){
invalidRanges(context);
return true;
}
}
}
if(context.inRangeDate == -1 && context.inRangeETag == null) {
if(!parseRanges(context)){
invalidRanges(context);
return true;
}
}
context.ifNoneMatch = context.request.getHeader(IF_NONE_MATCH_HEADER);
if( context.ifNoneMatch != null && validateStrong(context.ifNoneMatch, context.eTag)) {
context.response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
context.response.setHeader(ETAG_HEADER, context.eTag);
context.response.setDateHeader(LAST_MODIFIED_HEADER, lastUpdated);
return true;
}
context.ifModifiedSince = context.request.getDateHeader(IF_MODIFIED_SINCE_HEADER);
if( context.ifModifiedSince != -1 && context.ifModifiedSince >= lastUpdated ) {
context.response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
context.response.setHeader(ETAG_HEADER, context.eTag);
context.response.setDateHeader(LAST_MODIFIED_HEADER, lastUpdated);
return true;
}
context.ifUnmodifiedSince = context.request.getDateHeader(IF_UNMODIFIED_SINCE_HEADER);
if( context.ifUnmodifiedSince != -1 && context.ifUnmodifiedSince < lastUpdated ) {
context.response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return true;
}
return false;
}
/**
* Sets the appropriate response headers and error status for bad ranges
*
* @param context
* @throws IOException
*/
public static void invalidRanges(FileRequestContext context) throws IOException {
context.response.setHeader(CONTENT_RANGE_HEADER, "*/" + context.file.length());
context.response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
}
public static Pattern etagPattern = null;
public static Pattern unescapePattern = null;
public static Pattern rangePattern = null;
static {
try {
etagPattern = Pattern.compile( "(W/)?\"((?:[^\"\\\\]*|\\\\.)*)\"\\s*(?:,\\s*)?.*");
unescapePattern = Pattern.compile("\\\\(.)");
rangePattern = Pattern.compile("\\A\\s*bytes\\s*=\\s*(\\d*-\\d*(,\\d*-\\d*)*)\\s*\\Z");
}
catch( PatternSyntaxException pse ) {
pse.printStackTrace();
}
}
/**
* Parses the Range Header of the request and sets the appropriate ranges list on the {@link FileRequestContext} Object.
*
* @param context
* @return false if the range pattern contains no satisfiable ranges.
*/
public static boolean parseRanges(FileRequestContext context) {
if( !StringUtils.isEmptyOrNull(context.range) ) {
Matcher rangeMatcher = rangePattern.matcher(context.range);
if(rangeMatcher.matches()) {
String ranges[] = rangeMatcher.group(1).split(",");
for(String range : ranges) {
long startBound = -1;
int hyphenIndex = range.indexOf('-');
if( hyphenIndex > 0 ) {
startBound = Long.parseLong(range.substring(0, hyphenIndex));
}
long endBound = -1;
if( hyphenIndex >= 0 && (hyphenIndex + 1) < range.length() ) {
endBound = Long.parseLong(range.substring(hyphenIndex + 1));
}
Range newRange = new Range(startBound, endBound);
if(!(startBound != -1 && endBound != -1 && startBound > endBound) && !(startBound == -1 && endBound == -1)) {
context.ranges.add(newRange);
}
}
return !context.ranges.isEmpty();
}
}
return true;
}
/**
* Parses an Accept header value and checks to see if the type is acceptable.
* @param headerValue The value of the header.
* @param strict Forces the type to be in the header.
* @param type The token that we need in order to be acceptable.
* @return
*/
public static boolean canAccept(String headerValue, boolean strict, String type) {
if(headerValue != null && type != null) {
String availableTypes[] = headerValue.split(",");
for(String availableType : availableTypes) {
String typeParams[] = availableType.split(";");
double qValue = 1.0d;
if(typeParams.length > 0) {
for(int i=1; i parseETagList( String value ) {
List etags = new ArrayList();
value = value.trim();
if( "*".equals(value) ) {
etags.add(value);
}
else {
Matcher etagMatcher = etagPattern.matcher(value);
while( etagMatcher.lookingAt() ) {
etags.add(unescapePattern.matcher(etagMatcher.group(2)).replaceAll("$1"));
etagMatcher.region(etagMatcher.start()+etagMatcher.group().length(), etagMatcher.end());
}
}
return etags;
}
public static boolean validateStrong( String condition, String eTag ) {
List parsed = parseETagList(condition);
if( parsed.size() == 1 && "*".equals(parsed.get(0)) ) return true;
else return parsed.contains(eTag);
}
/**
* Forces requests for index files to not use the file name.
*
* @param context
* @return true if the request was handled, false otherwise.
* @throws IOException
*/
public boolean handleWelcomeRedirect( FileRequestContext context ) throws IOException {
if( context.file.isFile() && context.file.getName().equals("index.html")) {
resolveContentType(context);
String location = context.path.replaceFirst("/index.html\\Z", "");
if( location.isEmpty() ) location = "/";
if( context.request.getQueryString() != null ) location = location + "?" + context.request.getQueryString();
sendPermanentRedirect(context, location);
return true;
}
return false;
}
/**
* Looks up the mime type based on file extension and if found sets it on the FileRequestContext.
*
* @param context
*/
public void resolveContentType(FileRequestContext context) {
String contentType = lookupMimeType(context.file.getName());
if(contentType != null) {
context.contentType = contentType;
if(contentType.equals("text/html")) {
context.contentType += ";charset=UTF-8";
}
}
}
public static void sendPermanentRedirect( FileRequestContext context, String location ) throws IOException {
context.response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
context.response.setContentType(context.contentType);
context.response.setHeader(LOCATION_HEADER, location);
context.response.getOutputStream().close();
}
public String lookupMimeType( String path ) {
return getServletContext().getMimeType(path);
}
public static class FileRequestContext
{
public String range;
public List ranges = new ArrayList();
public long inRangeDate = -1;
public String inRangeETag;
public long ifUnmodifiedSince;
public long ifModifiedSince;
public String ifNoneMatch;
public String eTag;
HttpServletRequest request = null;
HttpServletResponse response = null;
File file = null;
InputStream in = null;
OutputStream out = null;
boolean compress = false;
String path = null;
private boolean sendEntity = true;
String contentType = DEFAULT_CONTENT_TYPE;
public String ifMatch;
public FileRequestContext( HttpServletRequest request, HttpServletResponse response, boolean sendEntity ) {
this.request = request;
this.response = response;
this.sendEntity = sendEntity;
this.path = request.getRequestURI();
}
}
public static class Range {
public long start = -1l;
public long end = -1l;
public long length;
public Range(long start, long end) {
this.start = start;
this.end = end;
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy