blob: 08e04e2fb8f867fb035dba848314790eb5da1f69 [file] [log] [blame]
/*
* 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.resource.external.ExternalizableInputStream;
import org.apache.sling.api.servlets.HttpConstants;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.apache.sling.servlets.get.impl.DefaultGetServlet;
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 {
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(DefaultGetServlet.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 ( stream instanceof ExternalizableInputStream) {
response.sendRedirect(((ExternalizableInputStream)stream).getURI().toString());
return;
}
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));
}
}
}