/** * Licensed to The Apereo Foundation under one or more contributor license * agreements. See the NOTICE file distributed with this work for additional * information regarding copyright ownership. * * * The Apereo Foundation licenses this file to you under the Educational * Community 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://opensource.org/licenses/ecl2.txt * * 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.opencastproject.fsresources; import org.opencastproject.util.ConfigurationException; import org.apache.commons.lang3.StringUtils; import org.osgi.service.component.ComponentContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Iterator; import java.util.StringTokenizer; import java.util.zip.CRC32; import javax.activation.MimetypesFileTypeMap; import javax.servlet.ServletException; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * Serves static content from a configured path on the filesystem. In production systems, this should be replaced with * apache httpd or another web server optimized for serving static content. */ public class StaticResourceServlet extends HttpServlet { /** The serialization UID */ private static final long serialVersionUID = 1L; /** Full range marker. */ private static final ArrayList<Range> FULL_RANGE; /** The mime types map */ private static final MimetypesFileTypeMap MIME_TYPES_MAP; /** The logger */ private static final Logger logger = LoggerFactory.getLogger(StaticResourceServlet.class); /** static initializer */ static { FULL_RANGE = new ArrayList<Range>(); MIME_TYPES_MAP = new MimetypesFileTypeMap(); } /** The filesystem directory to serve files fro */ protected String distributionDirectory; /** * No-arg constructor */ public StaticResourceServlet() { } /** * OSGI Activation callback * * @param cc * the component context */ public void activate(ComponentContext cc) { if (cc != null) { String ccDistributionDirectory = cc.getBundleContext().getProperty("org.opencastproject.download.directory"); if (StringUtils.isNotEmpty(ccDistributionDirectory)) { this.distributionDirectory = ccDistributionDirectory; } else { String storageDir = cc.getBundleContext().getProperty("org.opencastproject.storage.dir"); if (StringUtils.isNotEmpty(storageDir)) { this.distributionDirectory = new File(storageDir, "downloads").getPath(); } } } if (distributionDirectory == null) { throw new ConfigurationException("Distribution directory not set"); } logger.info("Serving static files from '{}'", distributionDirectory); InputStream is = this.getClass().getResourceAsStream("/META-INF/mime.types"); BufferedReader reader = new BufferedReader(new InputStreamReader(is)); try { String line; while ((line = reader.readLine()) != null) { MIME_TYPES_MAP.addMimeTypes(line); } } catch (IOException e) { logger.error("Failed to read mime type map from JAR", e); } } /** * {@inheritDoc} * * @see javax.servlet.http.HttpServlet#doGet(javax.servlet.http.HttpServletRequest, * javax.servlet.http.HttpServletResponse) */ @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { logger.debug("Looking for static resource '{}'", req.getRequestURI()); String path = req.getPathInfo(); if (path == null) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; } String normalized = path.trim().replaceAll("/+", "/").replaceAll("\\.\\.", ""); if (normalized != null && normalized.startsWith("/") && normalized.length() > 1) { normalized = normalized.substring(1); } File f = new File(distributionDirectory, normalized); String eTag = null; if (f.isFile() && f.canRead()) { logger.debug("Serving static resource '{}'", f.getAbsolutePath()); eTag = computeEtag(f); if (eTag.equals(req.getHeader("If-None-Match"))) { resp.setStatus(304); return; } resp.setHeader("ETag", eTag); String contentType = MIME_TYPES_MAP.getContentType(f); if (!"application/octet-stream".equals(contentType)) { resp.setContentType(contentType); } resp.setHeader("Content-Length", Long.toString(f.length())); resp.setDateHeader("Last-Modified", f.lastModified()); resp.setHeader("Accept-Ranges", "bytes"); ArrayList<Range> ranges = parseRange(req, resp, eTag, f.lastModified(), f.length()); if ((((ranges == null) || (ranges.isEmpty())) && (req.getHeader("Range") == null)) || (ranges == FULL_RANGE)) { IOException e = copyRange(new FileInputStream(f), resp.getOutputStream(), 0, f.length()); if (e != null) { try { resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); return; } catch (IOException e1) { logger.warn("unable to send http 500 error: {}", e1); return; } } } else { if ((ranges == null) || (ranges.isEmpty())) { return; } if (ranges.size() == 1) { Range range = ranges.get(0); resp.addHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + range.length); long length = range.end - range.start + 1; if (length < Integer.MAX_VALUE) { resp.setContentLength((int) length); } else { // Set the content-length as String to be able to use a long resp.setHeader("content-length", "" + length); } try { resp.setBufferSize(2048); } catch (IllegalStateException e) { logger.debug(e.getMessage(), e); } resp.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); IOException e = copyRange(new FileInputStream(f), resp.getOutputStream(), range.start, range.end); if (e != null) { try { resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); return; } catch (IOException e1) { logger.warn("unable to send http 500 error: {}", e1); return; } } } else { resp.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); resp.setContentType("multipart/byteranges; boundary=" + mimeSeparation); try { resp.setBufferSize(2048); } catch (IllegalStateException e) { logger.debug(e.getMessage(), e); } copy(f, resp.getOutputStream(), ranges.iterator(), contentType); } } } else { logger.debug("unable to find file '{}', returning HTTP 404"); resp.sendError(HttpServletResponse.SC_NOT_FOUND); } } /** * Computes an etag for a file using the filename, last modified, and length of the file. * * @param file * the file * @return the etag */ protected String computeEtag(File file) { CRC32 crc = new CRC32(); crc.update(file.getName().getBytes()); checksum(file.lastModified(), crc); checksum(file.length(), crc); return Long.toString(crc.getValue()); } private static void checksum(long l, CRC32 crc) { for (int i = 0; i < 8; i++) { crc.update((int) (l & 0x000000ff)); l >>= 8; } } protected void copy(File f, ServletOutputStream out, Iterator<Range> ranges, String contentType) throws IOException { IOException exception = null; while ((exception == null) && (ranges.hasNext())) { Range currentRange = ranges.next(); // Writing MIME header. out.println(); out.println("--" + mimeSeparation); if (contentType != null) { out.println("Content-Type: " + contentType); } out.println("Content-Range: bytes " + currentRange.start + "-" + currentRange.end + "/" + currentRange.length); out.println(); // Printing content InputStream in = new FileInputStream(f); exception = copyRange(in, out, currentRange.start, currentRange.end); in.close(); } out.println(); out.print("--" + mimeSeparation + "--"); // Rethrow any exception that has occurred if (exception != null) { throw exception; } } /** * MIME multipart separation string */ protected static final String mimeSeparation = "MATTERHORN_MIME_BOUNDARY"; /** * Parse the range header. * * @param req * The servlet request we are processing * @param response * The servlet response we are creating * @return Vector of ranges */ protected ArrayList<Range> parseRange(HttpServletRequest req, HttpServletResponse response, String eTag, long lastModified, long fileLength) throws IOException { // Checking If-Range String headerValue = req.getHeader("If-Range"); if (headerValue != null) { long headerValueTime = (-1L); try { headerValueTime = req.getDateHeader("If-Range"); } catch (IllegalArgumentException e) { logger.debug(e.getMessage(), e); } if (headerValueTime == (-1L)) { // If the ETag the client gave does not match the entity // etag, then the entire entity is returned. if (!eTag.equals(headerValue.trim())) { return FULL_RANGE; } } else { // If the timestamp of the entity the client got is older than // the last modification date of the entity, the entire entity // is returned. if (lastModified > (headerValueTime + 1000)) { return FULL_RANGE; } } } if (fileLength == 0) { return null; } // Retrieving the range header (if any is specified String rangeHeader = req.getHeader("Range"); if (rangeHeader == null) { return null; } // bytes is the only range unit supported (and I don't see the point // of adding new ones). if (!rangeHeader.startsWith("bytes")) { response.addHeader("Content-Range", "bytes */" + fileLength); response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return null; } rangeHeader = rangeHeader.substring(6); // Vector which will contain all the ranges which are successfully // parsed. ArrayList<Range> result = new ArrayList<Range>(); 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) { response.addHeader("Content-Range", "bytes */" + fileLength); response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return null; } if (dashPos == 0) { try { long offset = Long.parseLong(rangeDefinition); currentRange.start = fileLength + offset; currentRange.end = fileLength - 1; } catch (NumberFormatException e) { response.addHeader("Content-Range", "bytes */" + fileLength); response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); 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) { response.addHeader("Content-Range", "bytes */" + fileLength); response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return null; } } if (!currentRange.validate()) { response.addHeader("Content-Range", "bytes */" + fileLength); response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return null; } result.add(currentRange); } return result; } /** * 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 istream * The input stream to read from * @param ostream * The output stream to write to * @param start * Start of the range which will be copied * @param end * End of the range which will be copied * @return Exception which occurred during processing */ protected IOException copyRange(InputStream istream, ServletOutputStream ostream, long start, long end) { logger.debug("Serving bytes:{}-{}", start, end); try { istream.skip(start); } catch (IOException e) { return e; } // MH-10447, fix for files of size 2048*C bytes long bytesToRead = end - start + 1; byte[] buffer = new byte[2048]; int len = buffer.length; try { len = (int) bytesToRead % buffer.length; if (len > 0) { len = istream.read(buffer, 0, len); if (len > 0) { // This test coud actually be "if (len != -1)" ostream.write(buffer, 0, len); bytesToRead -= len; if (bytesToRead == 0) return null; } else return null; } for (len = istream.read(buffer); len > 0; len = istream.read(buffer)) { ostream.write(buffer, 0, len); bytesToRead -= len; if (bytesToRead < 1) break; } } catch (IOException e) { return e; } return null; } protected class Range { protected long start; protected long end; protected long length; /** * Validate range. */ public boolean validate() { if (end >= length) { end = length - 1; } return ((start >= 0) && (end >= 0) && (start <= end) && (length > 0)); } public void recycle() { start = 0; end = 0; length = 0; } } }