org.eclipse.jetty.server.ResourceService Maven / Gradle / Ivy
The newest version!
//
// ========================================================================
// Copyright (c) 1995-2018 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.server;
import static java.util.Arrays.stream;
import static java.util.Collections.emptyList;
import static org.eclipse.jetty.http.HttpHeaderValue.IDENTITY;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import javax.servlet.AsyncContext;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.http.CompressedContentFormat;
import org.eclipse.jetty.http.DateParser;
import org.eclipse.jetty.http.HttpContent;
import org.eclipse.jetty.http.HttpField;
import org.eclipse.jetty.http.HttpFields;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.PreEncodedHttpField;
import org.eclipse.jetty.http.QuotedCSV;
import org.eclipse.jetty.http.QuotedQualityCSV;
import org.eclipse.jetty.io.WriterOutputStream;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.MultiPartOutputStream;
import org.eclipse.jetty.util.URIUtil;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.resource.Resource;
/**
* Abstract resource service, used by DefaultServlet and ResourceHandler
*
*/
public class ResourceService
{
private static final Logger LOG = Log.getLogger(ResourceService.class);
private static final PreEncodedHttpField ACCEPT_RANGES = new PreEncodedHttpField(HttpHeader.ACCEPT_RANGES, "bytes");
private HttpContent.ContentFactory _contentFactory;
private WelcomeFactory _welcomeFactory;
private boolean _acceptRanges=true;
private boolean _dirAllowed=true;
private boolean _redirectWelcome=false;
private CompressedContentFormat[] _precompressedFormats=new CompressedContentFormat[0];
private String[] _preferredEncodingOrder =new String[0];
private final Map> _preferredEncodingOrderCache = new ConcurrentHashMap<>();
private int _encodingCacheSize=100;
private boolean _pathInfoOnly=false;
private boolean _etags=false;
private HttpField _cacheControl;
private List _gzipEquivalentFileExtensions;
public HttpContent.ContentFactory getContentFactory()
{
return _contentFactory;
}
public void setContentFactory(HttpContent.ContentFactory contentFactory)
{
_contentFactory = contentFactory;
}
public WelcomeFactory getWelcomeFactory() {
return _welcomeFactory;
}
public void setWelcomeFactory(WelcomeFactory welcomeFactory) {
_welcomeFactory = welcomeFactory;
}
public boolean isAcceptRanges()
{
return _acceptRanges;
}
public void setAcceptRanges(boolean acceptRanges)
{
_acceptRanges = acceptRanges;
}
public boolean isDirAllowed()
{
return _dirAllowed;
}
public void setDirAllowed(boolean dirAllowed)
{
_dirAllowed = dirAllowed;
}
public boolean isRedirectWelcome()
{
return _redirectWelcome;
}
public void setRedirectWelcome(boolean redirectWelcome)
{
_redirectWelcome = redirectWelcome;
}
public CompressedContentFormat[] getPrecompressedFormats()
{
return _precompressedFormats;
}
public void setPrecompressedFormats(CompressedContentFormat[] precompressedFormats)
{
_precompressedFormats = precompressedFormats;
_preferredEncodingOrder = stream(_precompressedFormats).map(f->f._encoding).toArray(String[]::new);
}
public void setEncodingCacheSize(int encodingCacheSize)
{
_encodingCacheSize = encodingCacheSize;
}
public int getEncodingCacheSize()
{
return _encodingCacheSize;
}
public boolean isPathInfoOnly()
{
return _pathInfoOnly;
}
public void setPathInfoOnly(boolean pathInfoOnly)
{
_pathInfoOnly = pathInfoOnly;
}
public boolean isEtags()
{
return _etags;
}
public void setEtags(boolean etags)
{
_etags = etags;
}
public HttpField getCacheControl()
{
return _cacheControl;
}
public void setCacheControl(HttpField cacheControl)
{
_cacheControl = cacheControl;
}
public List getGzipEquivalentFileExtensions()
{
return _gzipEquivalentFileExtensions;
}
public void setGzipEquivalentFileExtensions(List gzipEquivalentFileExtensions)
{
_gzipEquivalentFileExtensions = gzipEquivalentFileExtensions;
}
/* ------------------------------------------------------------ */
public boolean doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException
{
String servletPath=null;
String pathInfo=null;
Enumeration reqRanges = null;
boolean included =request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI)!=null;
if (included)
{
servletPath= _pathInfoOnly?"/":(String)request.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH);
pathInfo=(String)request.getAttribute(RequestDispatcher.INCLUDE_PATH_INFO);
if (servletPath==null)
{
servletPath=request.getServletPath();
pathInfo=request.getPathInfo();
}
}
else
{
servletPath = _pathInfoOnly?"/":request.getServletPath();
pathInfo = request.getPathInfo();
// Is this a Range request?
reqRanges = request.getHeaders(HttpHeader.RANGE.asString());
if (!hasDefinedRange(reqRanges))
reqRanges = null;
}
String pathInContext=URIUtil.addPaths(servletPath,pathInfo);
boolean endsWithSlash=(pathInfo==null?servletPath:pathInfo).endsWith(URIUtil.SLASH);
boolean checkPrecompressedVariants=_precompressedFormats.length > 0 && !endsWithSlash && !included && reqRanges==null;
HttpContent content=null;
boolean release_content=true;
try
{
// Find the content
content=_contentFactory.getContent(pathInContext,response.getBufferSize());
if (LOG.isDebugEnabled())
LOG.info("content={}",content);
// Not found?
if (content==null || !content.getResource().exists())
{
if (included)
throw new FileNotFoundException("!" + pathInContext);
notFound(request,response);
return response.isCommitted();
}
// Directory?
if (content.getResource().isDirectory())
{
sendWelcome(content,pathInContext,endsWithSlash,included,request,response);
return true;
}
// Strip slash?
if (!included && endsWithSlash && pathInContext.length()>1)
{
String q=request.getQueryString();
pathInContext=pathInContext.substring(0,pathInContext.length()-1);
if (q!=null&&q.length()!=0)
pathInContext+="?"+q;
response.sendRedirect(response.encodeRedirectURL(URIUtil.addPaths(request.getContextPath(),pathInContext)));
return true;
}
// Conditional response?
if (!included && !passConditionalHeaders(request,response,content))
return true;
// Precompressed variant available?
Map precompressedContents = checkPrecompressedVariants?content.getPrecompressedContents():null;
if (precompressedContents!=null && precompressedContents.size()>0)
{
// Tell caches that response may vary by accept-encoding
response.addHeader(HttpHeader.VARY.asString(),HttpHeader.ACCEPT_ENCODING.asString());
List preferredEncodings = getPreferredEncodingOrder(request);
CompressedContentFormat precompressedContentEncoding = getBestPrecompressedContent(preferredEncodings, precompressedContents.keySet());
if (precompressedContentEncoding!=null)
{
HttpContent precompressedContent = precompressedContents.get(precompressedContentEncoding);
if (LOG.isDebugEnabled())
LOG.debug("precompressed={}",precompressedContent);
content=precompressedContent;
response.setHeader(HttpHeader.CONTENT_ENCODING.asString(),precompressedContentEncoding._encoding);
}
}
// TODO this should be done by HttpContent#getContentEncoding
if (isGzippedContent(pathInContext))
response.setHeader(HttpHeader.CONTENT_ENCODING.asString(),"gzip");
// Send the data
release_content=sendData(request,response,included,content,reqRanges);
}
catch(IllegalArgumentException e)
{
LOG.warn(Log.EXCEPTION,e);
if(!response.isCommitted())
response.sendError(500, e.getMessage());
}
finally
{
if (release_content)
{
if (content!=null)
content.release();
}
}
return true;
}
private List getPreferredEncodingOrder(HttpServletRequest request)
{
Enumeration headers = request.getHeaders(HttpHeader.ACCEPT_ENCODING.asString());
if (!headers.hasMoreElements())
return emptyList();
String key = headers.nextElement();
if (headers.hasMoreElements())
{
StringBuilder sb = new StringBuilder(key.length()*2);
do
{
sb.append(',').append(headers.nextElement());
} while (headers.hasMoreElements());
key = sb.toString();
}
List values=_preferredEncodingOrderCache.get(key);
if (values==null)
{
QuotedQualityCSV encodingQualityCSV = new QuotedQualityCSV(_preferredEncodingOrder);
encodingQualityCSV.addValue(key);
values=encodingQualityCSV.getValues();
// keep cache size in check even if we get strange/malicious input
if (_preferredEncodingOrderCache.size()>_encodingCacheSize)
_preferredEncodingOrderCache.clear();
_preferredEncodingOrderCache.put(key,values);
}
return values;
}
private CompressedContentFormat getBestPrecompressedContent(List preferredEncodings, Collection availableFormats)
{
if (availableFormats.isEmpty())
return null;
for (String encoding : preferredEncodings)
{
for (CompressedContentFormat format : availableFormats)
if (format._encoding.equals(encoding))
return format;
if ("*".equals(encoding))
return availableFormats.iterator().next();
if (IDENTITY.asString().equals(encoding))
return null;
}
return null;
}
protected void sendWelcome(HttpContent content, String pathInContext, boolean endsWithSlash, boolean included, HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException
{
// Redirect to directory
if (!endsWithSlash || (pathInContext.length()==1 && request.getAttribute("org.eclipse.jetty.server.nullPathInfo")!=null))
{
StringBuffer buf=request.getRequestURL();
synchronized(buf)
{
int param=buf.lastIndexOf(";");
if (param<0)
buf.append('/');
else
buf.insert(param,'/');
String q=request.getQueryString();
if (q!=null&&q.length()!=0)
{
buf.append('?');
buf.append(q);
}
response.setContentLength(0);
response.sendRedirect(response.encodeRedirectURL(buf.toString()));
}
return;
}
// look for a welcome file
String welcome=_welcomeFactory==null?null:_welcomeFactory.getWelcomeFile(pathInContext);
if (welcome!=null)
{
if (_pathInfoOnly)
welcome = URIUtil.addPaths(request.getServletPath(),welcome);
if (LOG.isDebugEnabled())
LOG.debug("welcome={}",welcome);
ServletContext context = request.getServletContext();
if (_redirectWelcome || context==null)
{
// Redirect to the index
response.setContentLength(0);
String uri = URIUtil.encodePath(URIUtil.addPaths(request.getContextPath(),welcome));
String q=request.getQueryString();
if (q!=null&&!q.isEmpty())
uri+="?"+q;
response.sendRedirect(response.encodeRedirectURL(uri));
return;
}
RequestDispatcher dispatcher=context.getRequestDispatcher(welcome);
if (dispatcher!=null)
{
// Forward to the index
if (included)
dispatcher.include(request,response);
else
{
request.setAttribute("org.eclipse.jetty.server.welcome",welcome);
dispatcher.forward(request,response);
}
}
return;
}
if (included || passConditionalHeaders(request,response, content))
sendDirectory(request,response,content.getResource(),pathInContext);
}
/* ------------------------------------------------------------ */
protected boolean isGzippedContent(String path)
{
if (path == null || _gzipEquivalentFileExtensions==null)
return false;
for (String suffix:_gzipEquivalentFileExtensions)
if (path.endsWith(suffix))
return true;
return false;
}
/* ------------------------------------------------------------ */
private boolean hasDefinedRange(Enumeration reqRanges)
{
return (reqRanges!=null && reqRanges.hasMoreElements());
}
/* ------------------------------------------------------------ */
protected void notFound(HttpServletRequest request, HttpServletResponse response) throws IOException
{
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
protected void sendStatus(HttpServletResponse response, int status, Supplier etag) throws IOException
{
response.setStatus(status);
if (_etags && etag!=null)
response.setHeader(HttpHeader.ETAG.asString(),etag.get());
response.flushBuffer();
}
/* ------------------------------------------------------------ */
/* Check modification date headers.
*/
protected boolean passConditionalHeaders(HttpServletRequest request,HttpServletResponse response, HttpContent content)
throws IOException
{
try
{
String ifm=null;
String ifnm=null;
String ifms=null;
long ifums=-1;
if (request instanceof Request)
{
// Find multiple fields by iteration as an optimization
HttpFields fields = ((Request)request).getHttpFields();
for (int i=fields.size();i-->0;)
{
HttpField field=fields.getField(i);
if (field.getHeader() != null)
{
switch (field.getHeader())
{
case IF_MATCH:
ifm=field.getValue();
break;
case IF_NONE_MATCH:
ifnm=field.getValue();
break;
case IF_MODIFIED_SINCE:
ifms=field.getValue();
break;
case IF_UNMODIFIED_SINCE:
ifums=DateParser.parseDate(field.getValue());
break;
default:
}
}
}
}
else
{
ifm=request.getHeader(HttpHeader.IF_MATCH.asString());
ifnm=request.getHeader(HttpHeader.IF_NONE_MATCH.asString());
ifms=request.getHeader(HttpHeader.IF_MODIFIED_SINCE.asString());
ifums=request.getDateHeader(HttpHeader.IF_UNMODIFIED_SINCE.asString());
}
if (_etags)
{
String etag=content.getETagValue();
if (ifm!=null)
{
boolean match=false;
if (etag!=null)
{
QuotedCSV quoted = new QuotedCSV(true,ifm);
for (String tag : quoted)
{
if (CompressedContentFormat.tagEquals(etag, tag))
{
match=true;
break;
}
}
}
if (!match)
{
sendStatus(response,HttpServletResponse.SC_PRECONDITION_FAILED,null);
return false;
}
}
if (ifnm!=null && etag!=null)
{
// Handle special case of exact match OR gzip exact match
if (CompressedContentFormat.tagEquals(etag, ifnm) && ifnm.indexOf(',')<0)
{
sendStatus(response,HttpServletResponse.SC_NOT_MODIFIED,ifnm::toString);
return false;
}
// Handle list of tags
QuotedCSV quoted = new QuotedCSV(true,ifnm);
for (String tag : quoted)
{
if (CompressedContentFormat.tagEquals(etag, tag))
{
sendStatus(response,HttpServletResponse.SC_NOT_MODIFIED,tag::toString);
return false;
}
}
// If etag requires content to be served, then do not check if-modified-since
return true;
}
}
// Handle if modified since
if (ifms!=null)
{
//Get jetty's Response impl
String mdlm=content.getLastModifiedValue();
if (mdlm!=null && ifms.equals(mdlm))
{
sendStatus(response,HttpServletResponse.SC_NOT_MODIFIED,content::getETagValue);
return false;
}
long ifmsl=request.getDateHeader(HttpHeader.IF_MODIFIED_SINCE.asString());
if (ifmsl!=-1 && content.getResource().lastModified()/1000 <= ifmsl/1000)
{
sendStatus(response,HttpServletResponse.SC_NOT_MODIFIED,content::getETagValue);
return false;
}
}
// Parse the if[un]modified dates and compare to resource
if (ifums!=-1 && content.getResource().lastModified()/1000 > ifums/1000)
{
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return false;
}
}
catch(IllegalArgumentException iae)
{
if(!response.isCommitted())
response.sendError(400, iae.getMessage());
throw iae;
}
return true;
}
/* ------------------------------------------------------------------- */
protected void sendDirectory(HttpServletRequest request,
HttpServletResponse response,
Resource resource,
String pathInContext)
throws IOException
{
if (!_dirAllowed)
{
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
byte[] data=null;
String base = URIUtil.addEncodedPaths(request.getRequestURI(),URIUtil.SLASH);
String dir = resource.getListHTML(base,pathInContext.length()>1);
if (dir==null)
{
response.sendError(HttpServletResponse.SC_FORBIDDEN,
"No directory");
return;
}
data=dir.getBytes("utf-8");
response.setContentType("text/html;charset=utf-8");
response.setContentLength(data.length);
response.getOutputStream().write(data);
}
/* ------------------------------------------------------------ */
protected boolean sendData(HttpServletRequest request,
HttpServletResponse response,
boolean include,
final HttpContent content,
Enumeration reqRanges)
throws IOException
{
final long content_length = content.getContentLengthValue();
// Get the output stream (or writer)
OutputStream out;
boolean written;
try
{
out = response.getOutputStream();
// has something already written to the response?
written = out instanceof HttpOutput
? ((HttpOutput)out).isWritten()
: true;
}
catch(IllegalStateException e)
{
out = new WriterOutputStream(response.getWriter());
written=true; // there may be data in writer buffer, so assume written
}
if (LOG.isDebugEnabled())
LOG.debug(String.format("sendData content=%s out=%s async=%b",content,out,request.isAsyncSupported()));
if ( reqRanges == null || !reqRanges.hasMoreElements() || content_length<0)
{
// if there were no ranges, send entire entity
if (include)
{
// write without headers
content.getResource().writeTo(out,0,content_length);
}
// else if we can't do a bypass write because of wrapping
else if (written || !(out instanceof HttpOutput))
{
// write normally
putHeaders(response,content,written?-1:0);
ByteBuffer buffer = content.getIndirectBuffer();
if (buffer!=null)
BufferUtil.writeTo(buffer,out);
else
content.getResource().writeTo(out,0,content_length);
}
// else do a bypass write
else
{
// write the headers
putHeaders(response,content,0);
// write the content asynchronously if supported
if (request.isAsyncSupported() && content.getContentLengthValue()>response.getBufferSize())
{
final AsyncContext context = request.startAsync();
context.setTimeout(0);
((HttpOutput)out).sendContent(content,new Callback()
{
@Override
public void succeeded()
{
context.complete();
content.release();
}
@Override
public void failed(Throwable x)
{
if (x instanceof IOException)
LOG.debug(x);
else
LOG.warn(x);
context.complete();
content.release();
}
@Override
public String toString()
{
return String.format("ResourceService@%x$CB", ResourceService.this.hashCode());
}
});
return false;
}
// otherwise write content blocking
((HttpOutput)out).sendContent(content);
}
}
else
{
// Parse the satisfiable ranges
List ranges =InclusiveByteRange.satisfiableRanges( reqRanges, content_length);
// if there are no satisfiable ranges, send 416 response
if (ranges==null || ranges.size()==0)
{
putHeaders(response,content,0);
response.setHeader(HttpHeader.CONTENT_RANGE.asString(),
InclusiveByteRange.to416HeaderRangeString(content_length));
sendStatus(response,HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE,null);
return true;
}
// if there is only a single valid range (must be satisfiable
// since were here now), send that range with a 216 response
if ( ranges.size()== 1)
{
InclusiveByteRange singleSatisfiableRange = ranges.iterator().next();
long singleLength = singleSatisfiableRange.getSize();
putHeaders(response,content,singleLength);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
if (!response.containsHeader(HttpHeader.DATE.asString()))
response.addDateHeader(HttpHeader.DATE.asString(),System.currentTimeMillis());
response.setHeader(HttpHeader.CONTENT_RANGE.asString(),
singleSatisfiableRange.toHeaderRangeString(content_length));
content.getResource().writeTo(out,singleSatisfiableRange.getFirst(),singleLength);
return true;
}
// multiple non-overlapping valid ranges cause a multipart
// 216 response which does not require an overall
// content-length header
//
putHeaders(response,content,-1);
String mimetype=(content==null?null:content.getContentTypeValue());
if (mimetype==null)
LOG.warn("Unknown mimetype for "+request.getRequestURI());
MultiPartOutputStream multi = new MultiPartOutputStream(out);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
if (!response.containsHeader(HttpHeader.DATE.asString()))
response.addDateHeader(HttpHeader.DATE.asString(),System.currentTimeMillis());
// If the request has a "Request-Range" header then we need to
// send an old style multipart/x-byteranges Content-Type. This
// keeps Netscape and acrobat happy. This is what Apache does.
String ctp;
if (request.getHeader(HttpHeader.REQUEST_RANGE.asString())!=null)
ctp = "multipart/x-byteranges; boundary=";
else
ctp = "multipart/byteranges; boundary=";
response.setContentType(ctp+multi.getBoundary());
InputStream in=content.getResource().getInputStream();
long pos=0;
// calculate the content-length
int length=0;
String[] header = new String[ranges.size()];
int i = 0;
for (InclusiveByteRange ibr:ranges)
{
header[i]=ibr.toHeaderRangeString(content_length);
length+=
((i>0)?2:0)+
2+multi.getBoundary().length()+2+
(mimetype==null?0:HttpHeader.CONTENT_TYPE.asString().length()+2+mimetype.length())+2+
HttpHeader.CONTENT_RANGE.asString().length()+2+header[i].length()+2+
2+
(ibr.getLast()-ibr.getFirst())+1;
i++;
}
length+=2+2+multi.getBoundary().length()+2+2;
response.setContentLength(length);
i=0;
for (InclusiveByteRange ibr:ranges)
{
multi.startPart(mimetype,new String[]{HttpHeader.CONTENT_RANGE+": "+header[i]});
long start=ibr.getFirst();
long size=ibr.getSize();
if (in!=null)
{
// Handle non cached resource
if (start