// // ======================================================================== // Copyright (c) 1995-2017 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 javax.servlet.AsyncContext; import javax.servlet.RequestDispatcher; 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<String, List<String>> _preferredEncodingOrderCache = new ConcurrentHashMap<>(); private int _encodingCacheSize=100; private boolean _pathInfoOnly=false; private boolean _etags=false; private HttpField _cacheControl; private List<String> _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<String> getGzipEquivalentFileExtensions() { return _gzipEquivalentFileExtensions; } public void setGzipEquivalentFileExtensions(List<String> gzipEquivalentFileExtensions) { _gzipEquivalentFileExtensions = gzipEquivalentFileExtensions; } /* ------------------------------------------------------------ */ public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String servletPath=null; String pathInfo=null; Enumeration<String> 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?request.getServletPath():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; } // Directory? if (content.getResource().isDirectory()) { sendWelcome(content,pathInContext,endsWithSlash,included,request,response); return; } // Strip slash? if (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; } // Conditional response? if (!included && !passConditionalHeaders(request,response,content)) return; // Precompressed variant available? Map<CompressedContentFormat,? extends HttpContent> 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<String> 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(); } } } private List<String> getPreferredEncodingOrder(HttpServletRequest request) { Enumeration<String> 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<String> 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<String> preferredEncodings, Collection<CompressedContentFormat> 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 (LOG.isDebugEnabled()) LOG.debug("welcome={}",welcome); RequestDispatcher dispatcher=_redirectWelcome?null:request.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); } } else { // 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; } 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<String> reqRanges) { return (reqRanges!=null && reqRanges.hasMoreElements()); } /* ------------------------------------------------------------ */ protected void notFound(HttpServletRequest request, HttpServletResponse response) throws IOException { response.sendError(HttpServletResponse.SC_NOT_FOUND); } /* ------------------------------------------------------------ */ /* 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 (!HttpMethod.HEAD.is(request.getMethod())) { 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) { response.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED); 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) { response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); response.setHeader(HttpHeader.ETAG.asString(),ifnm); return false; } // Handle list of tags QuotedCSV quoted = new QuotedCSV(true,ifnm); for (String tag : quoted) { if (CompressedContentFormat.tagEquals(etag, tag)) { response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); response.setHeader(HttpHeader.ETAG.asString(),tag); 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)) { response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); if (_etags) response.setHeader(HttpHeader.ETAG.asString(),content.getETagValue()); response.flushBuffer(); return false; } long ifmsl=request.getDateHeader(HttpHeader.IF_MODIFIED_SINCE.asString()); if (ifmsl!=-1 && content.getResource().lastModified()/1000 <= ifmsl/1000) { response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); if (_etags) response.setHeader(HttpHeader.ETAG.asString(),content.getETagValue()); response.flushBuffer(); 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<String> reqRanges) throws IOException { final long content_length = content.getContentLengthValue(); // Get the output stream (or writer) OutputStream out =null; 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<InclusiveByteRange> 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.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); response.setHeader(HttpHeader.CONTENT_RANGE.asString(), InclusiveByteRange.to416HeaderRangeString(content_length)); content.getResource().writeTo(out,0,content_length); 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.get(0); long singleLength = singleSatisfiableRange.getSize(content_length); 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(content_length),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()]; for (int i=0;i<ranges.size();i++) { InclusiveByteRange ibr = ranges.get(i); 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(content_length)-ibr.getFirst(content_length))+1; } length+=2+2+multi.getBoundary().length()+2+2; response.setContentLength(length); for (int i=0;i<ranges.size();i++) { InclusiveByteRange ibr = ranges.get(i); multi.startPart(mimetype,new String[]{HttpHeader.CONTENT_RANGE+": "+header[i]}); long start=ibr.getFirst(content_length); long size=ibr.getSize(content_length); if (in!=null) { // Handle non cached resource if (start<pos) { in.close(); in=content.getResource().getInputStream(); pos=0; } if (pos<start) { in.skip(start-pos); pos=start; } IO.copy(in,multi,size); pos+=size; } else // Handle cached resource content.getResource().writeTo(multi,start,size); } if (in!=null) in.close(); multi.close(); } return true; } /* ------------------------------------------------------------ */ protected void putHeaders(HttpServletResponse response,HttpContent content, long contentLength) { if (response instanceof Response) { Response r = (Response)response; r.putHeaders(content,contentLength,_etags); HttpFields f = r.getHttpFields(); if (_acceptRanges) f.put(ACCEPT_RANGES); if (_cacheControl!=null) f.put(_cacheControl); } else { Response.putHeaders(response,content,contentLength,_etags); if (_acceptRanges) response.setHeader(ACCEPT_RANGES.getName(),ACCEPT_RANGES.getValue()); if (_cacheControl!=null) response.setHeader(_cacheControl.getName(),_cacheControl.getValue()); } } /* ------------------------------------------------------------ */ /* ------------------------------------------------------------ */ /* ------------------------------------------------------------ */ public interface WelcomeFactory { /* ------------------------------------------------------------ */ /** * Finds a matching welcome file for the supplied {@link Resource}. * @param pathInContext the path of the request * @return The path of the matching welcome file in context or null. */ String getWelcomeFile(String pathInContext); } }