/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You 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 org.apache.sling.servlets.get.impl.helpers; import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED; import static org.apache.sling.api.servlets.HttpConstants.HEADER_IF_MODIFIED_SINCE; import static org.apache.sling.api.servlets.HttpConstants.HEADER_LAST_MODIFIED; import java.io.BufferedInputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Date; import java.util.Iterator; import java.util.StringTokenizer; import javax.servlet.RequestDispatcher; import javax.servlet.ServletException; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.jackrabbit.JcrConstants; import org.apache.sling.api.SlingConstants; import org.apache.sling.api.SlingHttpServletRequest; import org.apache.sling.api.SlingHttpServletResponse; import org.apache.sling.api.request.RequestDispatcherOptions; import org.apache.sling.api.resource.Resource; import org.apache.sling.api.resource.ResourceMetadata; import org.apache.sling.api.resource.ResourceNotFoundException; import org.apache.sling.api.resource.ResourceResolver; import org.apache.sling.api.resource.ResourceUtil; import org.apache.sling.api.resource.ValueMap; import org.apache.sling.api.servlets.HttpConstants; import org.apache.sling.api.servlets.SlingSafeMethodsServlet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * The <code>StreamRendererServlet</code> streams the current resource to the * client on behalf of the * {@link org.apache.sling.servlets.get.impl.DefaultGetServlet}. If the current * resource cannot be streamed it is rendered using the * {@link PlainTextRendererServlet}. */ public class StreamRendererServlet extends SlingSafeMethodsServlet { public static final String EXT_RES = "res"; private static final long serialVersionUID = -1L; /** * MIME multipart separation string */ private static final String mimeSeparation = "SLING_MIME_BOUNDARY"; // size threshold for sending an Accept-Ranges header back in the response (100KB) private static final int ACCEPT_RANGES_THRESHOLD = 100 * 1024; // Accept-Ranges header name private static final String ACCEPT_RANGES_HEADER = "Accept-Ranges"; // Accept-Ranges header value private static final String ACCEPT_RANGES_BYTES = "bytes"; /** * Full range marker. */ private static ArrayList<Range> FULL = new ArrayList<>(0); static final int IO_BUFFER_SIZE = 2048; /** default log */ private final Logger log = LoggerFactory.getLogger(getClass()); private boolean index; private String[] indexFiles; public StreamRendererServlet(boolean index, String[] indexFiles) { this.index = index; this.indexFiles = indexFiles; } @Override protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response) throws ServletException, IOException { processRequest(request, response); } @Override protected void doHead(SlingHttpServletRequest request, SlingHttpServletResponse response) throws ServletException, IOException { processRequest(request, response); } private void processRequest(SlingHttpServletRequest request, SlingHttpServletResponse response) throws ServletException, IOException { // whether this servlet is called as of a request include final boolean included = request.getAttribute(SlingConstants.ATTR_REQUEST_SERVLET) != null; // ensure no extension or "res" String ext = request.getRequestPathInfo().getExtension(); if (ext != null && !ext.equals(EXT_RES)) { request.getRequestProgressTracker().log( "StreamRendererServlet does not support for extension " + ext); if (included || response.isCommitted()) { log.error( "StreamRendererServlet does not support extension {}", ext); } else { response.sendError(HttpServletResponse.SC_NOT_FOUND); } return; } Resource resource = request.getResource(); if (ResourceUtil.isNonExistingResource(resource)) { throw new ResourceNotFoundException("No data to render."); } // trailing slash on url means directory listing if ("/".equals(request.getRequestPathInfo().getSuffix())) { renderDirectory(request, response, included); return; } // check the last modification time and If-Modified-Since header if (!included) { ResourceMetadata meta = resource.getResourceMetadata(); long modifTime = meta.getModificationTime(); if (unmodified(request, modifTime)) { response.setStatus(SC_NOT_MODIFIED); return; } } // fall back to plain text rendering if the resource has no stream if (resource.getResourceType().equals(JcrConstants.NT_LINKEDFILE)) { final ValueMap vm = resource.adaptTo(ValueMap.class); final String actualResourcePath = vm.get(JcrConstants.JCR_CONTENT, String.class); resource = request.getResourceResolver().getResource(actualResourcePath); } InputStream stream = resource.adaptTo(InputStream.class); if (stream != null) { if (isHeadRequest(request)) { setContentLength(response, resource.getResourceMetadata().getContentLength()); setHeaders(resource, response); return; } streamResource(resource, stream, included, request, response); } else { // the resource is the root, do not redirect, immediately index if (isRootResourceRequest(resource)) { renderDirectory(request, response, included); } else if (included || response.isCommitted() ) { // request is included or committed, not redirecting request.getRequestProgressTracker().log( "StreamRendererServlet: Not redirecting with trailing slash, response is committed or request included"); log.warn("StreamRendererServlet: Not redirecting with trailing slash, response is committed or request included"); } else { // redirect to this with trailing slash to render the index String url = request.getResourceResolver().map(request, resource.getPath()) + "/"; response.sendRedirect(url); } } } private boolean isRootResourceRequest(Resource resource) { return ("/".equals(resource.getPath())) || ("/".equals(resource.getResourceResolver().map(resource.getPath()))); } private boolean isHeadRequest(HttpServletRequest request) { return HttpConstants.METHOD_HEAD.equals(request.getMethod()); } /** * Returns <code>true</code> if the request has a * <code>If-Modified-Since</code> header whose date value is later than the * last modification time given as <code>modifTime</code>. * * @param request The <code>ComponentRequest</code> checked for the * <code>If-Modified-Since</code> header. * @param modifTime The last modification time to compare the header to. * @return <code>true</code> if the <code>modifTime</code> is less than or * equal to the time of the <code>If-Modified-Since</code> header. */ private boolean unmodified(HttpServletRequest request, long modifTime) { if (modifTime > 0) { long modTime = modifTime / 1000; // seconds long ims = request.getDateHeader(HEADER_IF_MODIFIED_SINCE) / 1000; return modTime <= ims; } // we have no modification time value, assume modified return false; } private void streamResource(final Resource resource, final InputStream stream, final boolean included, final SlingHttpServletRequest request, final SlingHttpServletResponse response) throws IOException { // finally stream the resource try { final ArrayList<Range> ranges; if (included) { // no range support on included requests ranges = FULL; } else { // parse optional ranges ranges = parseRange(request, response, resource.getResourceMetadata()); if (ranges == null) { // there was something wrong, the parseRange has sent a // response and we are done return; } // set various response headers, unless the request is included setHeaders(resource, response); } ServletOutputStream out = response.getOutputStream(); if (ranges == FULL) { // return full resource setContentLength(response, resource.getResourceMetadata().getContentLength()); byte[] buf = new byte[IO_BUFFER_SIZE]; int rd; while ((rd = stream.read(buf)) >= 0) { out.write(buf, 0, rd); } } else { // return ranges of the resource response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); if (ranges.size() == 1) { Range range = ranges.get(0); response.addHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + range.length); setContentLength(response, range.end - range.start + 1); copy(stream, out, range); } else { response.setContentType("multipart/byteranges; boundary=" + mimeSeparation); copy(resource, out, ranges.iterator()); } } } finally { closeSilently(stream); } } private void renderDirectory(final SlingHttpServletRequest request, final SlingHttpServletResponse response, final boolean included) throws ServletException, IOException { // request is included or committed, not rendering index if (included || response.isCommitted()) { request.getRequestProgressTracker().log( "StreamRendererServlet: Not rendering index, response is committed or request included"); log.warn("StreamRendererServlet: Not rendering index, response is committed or request included"); return; } Resource resource = request.getResource(); ResourceResolver resolver = request.getResourceResolver(); // check for an index file for (String index : indexFiles) { Resource fileRes = resolver.getResource(resource, index); if (fileRes != null && !ResourceUtil.isSyntheticResource(fileRes)) { setHeaders(fileRes, response); if (isHeadRequest(request)) { return; } // include the index resource with no suffix and selectors ! RequestDispatcherOptions rdo = new RequestDispatcherOptions(); rdo.setReplaceSuffix(""); rdo.setReplaceSelectors(""); RequestDispatcher dispatcher; if (index.indexOf('.') < 0) { String filePath = fileRes.getPath() + ".html"; dispatcher = request.getRequestDispatcher(filePath, rdo); } else { dispatcher = request.getRequestDispatcher(fileRes, rdo); } dispatcher.include(request, response); return; } } if (index) { if (isHeadRequest(request)) { setHeaders(resource, response); return; } renderIndex(resource, response); } else { response.sendError(HttpServletResponse.SC_FORBIDDEN); } } /** * @param resource * @param response */ private void setHeaders(Resource resource, SlingHttpServletResponse response) { final ResourceMetadata meta = resource.getResourceMetadata(); final long modifTime = meta.getModificationTime(); if (modifTime > 0) { response.setDateHeader(HEADER_LAST_MODIFIED, modifTime); } final String defaultContentType = "application/octet-stream"; String contentType = meta.getContentType(); if (contentType == null || defaultContentType.equals(contentType)) { // if repository doesn't provide a content-type, or // provides the // default one, // try to do better using our servlet context final String ct = getServletContext().getMimeType( resource.getPath()); if (ct != null) { contentType = ct; } } if (contentType == null) { contentType = defaultContentType; } response.setContentType(contentType); String encoding = meta.getCharacterEncoding(); if (encoding != null) { response.setCharacterEncoding(encoding); } // announce support for ranges if we know the size to be larger than 100KB if (meta.getContentLength() > ACCEPT_RANGES_THRESHOLD) { response.setHeader(ACCEPT_RANGES_HEADER, ACCEPT_RANGES_BYTES); } } /** * Set the <code>Content-Length</code> header to the give value. If the * length is larger than <code>Integer.MAX_VALUE</code> it is converted to a * string and the <code>setHeader(String, String)</code> method is called * instead of the <code>setContentLength(int)</code> method. * * @param response The response on which to set the * <code>Content-Length</code> header. * @param length The content length to be set. If this value is equal to or * less than zero, the header is not set. */ private void setContentLength(final HttpServletResponse response, final long length) { if (length > 0) { if (length < Integer.MAX_VALUE) { response.setContentLength((int) length); } else { response.setHeader("Content-Length", String.valueOf(length)); } } } private void renderIndex(Resource resource, SlingHttpServletResponse response) throws IOException { response.setContentType("text/html"); response.setCharacterEncoding("UTF-8"); String path = resource.getPath(); PrintWriter pw = response.getWriter(); pw.println("<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 3.2 Final//EN\">"); pw.println("<html>"); pw.println("<head>"); pw.println("<title>Index of " + path + "</title>"); pw.println("</head>"); pw.println("<body>"); pw.println("<h1>Index of " + path + "</h1>"); pw.println("<pre>"); pw.println("Name Last modified Size Description"); pw.println("<hr>"); if (!"/".equals(path)) { pw.println("<a href='../'>../</a> - Parent"); } // render the children Iterator<Resource> children = ResourceUtil.listChildren(resource); while (children.hasNext()) { renderChild(pw, children.next()); } pw.println("</pre>"); pw.println("</body>"); pw.println("</html>"); } private void renderChild(PrintWriter pw, Resource resource) { String name = ResourceUtil.getName(resource.getPath()); InputStream ins = resource.adaptTo(InputStream.class); if (ins == null) { name += "/"; } else { closeSilently(ins); } String displayName = name; String suffix; if (displayName.length() >= 32) { displayName = displayName.substring(0, 29) + "..."; suffix = ""; } else { suffix = " ".substring( 0, 32 - displayName.length()); } pw.printf("<a href='%s'>%s</a>%s", name, displayName, suffix); ResourceMetadata meta = resource.getResourceMetadata(); long lastModified = meta.getModificationTime(); pw.print(" " + new Date(lastModified) + " "); long length = meta.getContentLength(); if (length > 0) { pw.print(length); } else { pw.print('-'); } pw.println(); } //---------- Range header support // The following code is copy-derived from the Tomcate DefaultServlet // http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/servlets/DefaultServlet.java?view=markup /** * Copies a number of ranges from the given resource to the output stream. * Copy the contents of the specified input stream to the specified output * stream, and ensure that both streams are closed before returning (even in * the face of an exception). * * @param resource The resource from which to send ranges * @param ostream The output stream to write to * @param ranges Iterator of the ranges the client wanted to retrieve * @exception IOException if an input/output error occurs */ private void copy(Resource resource, ServletOutputStream ostream, Iterator<Range> ranges) throws IOException { String contentType = resource.getResourceMetadata().getContentType(); IOException exception = null; while ((exception == null) && (ranges.hasNext())) { InputStream resourceInputStream = resource.adaptTo(InputStream.class); InputStream istream = new BufferedInputStream(resourceInputStream, IO_BUFFER_SIZE); try { Range currentRange = ranges.next(); // Writing MIME header. ostream.println(); ostream.println("--" + mimeSeparation); if (contentType != null) { ostream.println("Content-Type: " + contentType); } ostream.println("Content-Range: bytes " + currentRange.start + "-" + currentRange.end + "/" + currentRange.length); ostream.println(); // Copy content try { copy(istream, ostream, currentRange); } catch(IOException e) { exception = e; } } finally { closeSilently(istream); } } ostream.println(); ostream.print("--" + mimeSeparation + "--"); if(exception != null) { throw exception; } } /** * Copy the contents of the specified input stream to the specified * output stream. * * @param istream The input stream to read from * @param ostream The output stream to write to * @param range Range the client wanted to retrieve * @exception IOException if an input/output error occurs */ private void copy(InputStream istream, OutputStream ostream, Range range) throws IOException { // HTTP Range 0-9 means "byte 9 included" final long endIndex = range.end + 1; log.debug("copy: Serving bytes: {}-{}", range.start, endIndex); staticCopyRange(istream, ostream, range.start, endIndex); } // static, package-private method to make unit testing easier static void staticCopyRange(InputStream istream, OutputStream ostream, long start, long end) throws IOException { long position = 0; byte buffer[] = new byte[IO_BUFFER_SIZE]; while (position < start) { long skipped = istream.skip(start - position); if (skipped == 0) { // skip() may return zero if for whatever reason it wasn't // able to advance the stream. In such cases we need to // fall back to read() to force the skipping of bytes. int len = (int) Math.min(start - position, buffer.length); skipped = istream.read(buffer, 0, len); if (skipped == -1) { throw new IOException("Failed to skip " + start + " bytes; only skipped " + position + " bytes"); } } position += skipped; } while (position < end) { int len = (int) Math.min(end - position, buffer.length); int read = istream.read(buffer, 0, len); if (read != -1) { position += read; ostream.write(buffer, 0, read); } else { break; } } } /** * Parse the range header. * * @param request The servlet request we are processing * @param response The servlet response we are creating * @return ArrayList of ranges parsed from the Range header or {@link #FULL} * if the full resource should be returned or <code>null</code> if * an error occurred parsing the header and the request has been * finished sending an error status. */ private ArrayList<Range> parseRange(HttpServletRequest request, HttpServletResponse response, ResourceMetadata metadata) throws IOException { // Checking If-Range String headerValue = request.getHeader("If-Range"); if (headerValue != null) { long headerValueTime = (-1L); try { headerValueTime = request.getDateHeader("If-Range"); } catch (IllegalArgumentException e) { // Ignore } if (headerValueTime == (-1L)) { // If the ETag the client gave does not match the entity // etag, then the entire entity is returned. // Sling: no etag support yet, return full range return FULL; } else if (metadata.getModificationTime() > (headerValueTime + 1000)) { // If the timestamp of the entity the client got is older than // the last modification date of the entity, the entire entity // is returned. return FULL; } } long fileLength = metadata.getContentLength(); if (fileLength == 0) { return FULL; } // Retrieving the range header (if any is specified) String rangeHeader = request.getHeader("Range"); if (rangeHeader == null) { return FULL; } // bytes is the only range unit supported (and I don't see the point // of adding new ones). if (!rangeHeader.startsWith("bytes")) { failParseRange(response, fileLength, rangeHeader); return null; } rangeHeader = rangeHeader.substring(6); // Vector which will contain all the ranges which are successfully // parsed. ArrayList<Range> result = new ArrayList<>(); StringTokenizer commaTokenizer = new StringTokenizer(rangeHeader, ","); // Parsing the range list while (commaTokenizer.hasMoreTokens()) { String rangeDefinition = commaTokenizer.nextToken().trim(); Range currentRange = new Range(); currentRange.length = fileLength; int dashPos = rangeDefinition.indexOf('-'); if (dashPos == -1) { failParseRange(response, fileLength, rangeHeader); return null; } if (dashPos == 0) { try { long offset = Long.parseLong(rangeDefinition); currentRange.start = fileLength + offset; currentRange.end = fileLength - 1; } catch (NumberFormatException e) { failParseRange(response, fileLength, rangeHeader); return null; } } else { try { currentRange.start = Long.parseLong(rangeDefinition.substring( 0, dashPos)); if (dashPos < rangeDefinition.length() - 1) currentRange.end = Long.parseLong(rangeDefinition.substring( dashPos + 1, rangeDefinition.length())); else currentRange.end = fileLength - 1; } catch (NumberFormatException e) { failParseRange(response, fileLength, rangeHeader); return null; } } if (!currentRange.validate()) { failParseRange(response, fileLength, rangeHeader); return null; } result.add(currentRange); } return result; } /** * Sends a 416 error response to the client if the Range header is * not acceptable */ private void failParseRange(final HttpServletResponse response, final long fileLength, final String rangeHeader) throws IOException { log.error("parseRange: Cannot support range {}; sending 416", rangeHeader); response.addHeader("Content-Range", "bytes */" + fileLength); response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); } private void closeSilently(final Closeable closeable) { if (closeable != null) { try { closeable.close(); } catch (IOException ignore) { } } } // --------- Range Inner Class protected class Range { public long start; public long end; public long length; /** * Validate range. * * @return {@code true} if the range is valid, {@code false} otherwise */ public boolean validate() { if (end >= length) end = length - 1; return ((start >= 0) && (end >= 0) && (start <= end) && (length > 0)); } } }