View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.fsresources;
23  
24  import org.opencastproject.security.api.SecurityService;
25  import org.opencastproject.security.api.StaticFileAuthorization;
26  import org.opencastproject.util.ConfigurationException;
27  import org.opencastproject.util.MimeTypes;
28  
29  import org.apache.commons.lang3.BooleanUtils;
30  import org.apache.commons.lang3.StringUtils;
31  import org.osgi.service.component.ComponentContext;
32  import org.osgi.service.component.annotations.Activate;
33  import org.osgi.service.component.annotations.Component;
34  import org.osgi.service.component.annotations.Reference;
35  import org.osgi.service.component.annotations.ReferenceCardinality;
36  import org.osgi.service.component.annotations.ReferencePolicy;
37  import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardContextSelect;
38  import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardServletName;
39  import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardServletPattern;
40  import org.slf4j.Logger;
41  import org.slf4j.LoggerFactory;
42  
43  import java.io.File;
44  import java.io.FileInputStream;
45  import java.io.IOException;
46  import java.io.InputStream;
47  import java.nio.file.Paths;
48  import java.util.ArrayList;
49  import java.util.Iterator;
50  import java.util.List;
51  import java.util.Objects;
52  import java.util.StringTokenizer;
53  import java.util.regex.Pattern;
54  import java.util.zip.CRC32;
55  
56  import javax.servlet.Servlet;
57  import javax.servlet.ServletOutputStream;
58  import javax.servlet.http.HttpServlet;
59  import javax.servlet.http.HttpServletRequest;
60  import javax.servlet.http.HttpServletResponse;
61  
62  /**
63   * Serves static content from a configured path on the filesystem. In production systems, this should be replaced with
64   * apache httpd or another web server optimized for serving static content.
65   */
66  @Component(
67      property = {
68          "service.description=Opencast Download Resources",
69      },
70      service = Servlet.class
71  )
72  @HttpWhiteboardServletName(StaticResourceServlet.SERVLET_PATH)
73  @HttpWhiteboardServletPattern(StaticResourceServlet.SERVLET_PATH + "/*")
74  @HttpWhiteboardContextSelect("(osgi.http.whiteboard.context.name=opencast)")
75  public class StaticResourceServlet extends HttpServlet {
76  
77    /** The serialization UID */
78    private static final long serialVersionUID = 1L;
79    /** Full range marker. */
80    private static final ArrayList<Range> FULL_RANGE;
81    /** The logger */
82    private static final Logger logger = LoggerFactory.getLogger(StaticResourceServlet.class);
83  
84    private static final String PROP_AUTH_REQUIRED = "authentication.required";
85    private static final String PROP_X_ACCEL_REDIRECT = "x.accel.redirect";
86  
87    /** static initializer */
88    static {
89      FULL_RANGE = new ArrayList<>();
90    }
91  
92    public static final String SERVLET_PATH = "/static";
93  
94    /** The filesystem directory to serve files fro */
95    private String distributionDirectory;
96  
97    private boolean authRequired = true;
98    private String xAccelRedirect = null;
99  
100   private SecurityService securityService = null;
101 
102   private List<StaticFileAuthorization> authorizations = new ArrayList<>();
103 
104   /**
105    * No-arg constructor
106    */
107   public StaticResourceServlet() {
108   }
109 
110   @Reference(
111       cardinality = ReferenceCardinality.MULTIPLE,
112       policy = ReferencePolicy.DYNAMIC
113   )
114   public void addStaticFileAuthorization(final StaticFileAuthorization authorization) {
115     authorizations.add(authorization);
116     logger.info("Added static file authorization for {}", authorization.getProtectedUrlPattern());
117   }
118 
119   public void removeStaticFileAuthorization(final StaticFileAuthorization authorization) {
120     authorizations.remove(authorization);
121     logger.info("Removed static file authorization for {}", authorization.getProtectedUrlPattern());
122   }
123 
124   private boolean isAuthorized(final String path) {
125     // Check with authorization plug-ins
126     for (StaticFileAuthorization auth: authorizations) {
127       for (Pattern pattern: auth.getProtectedUrlPattern()) {
128         logger.debug("Testing pattern `{}`", pattern);
129         if (pattern.matcher(path).matches()) {
130           logger.debug("Using regexp `{}` for authorization check", pattern);
131           return auth.verifyUrlAccess(path);
132         }
133       }
134     }
135     logger.debug("No authorization plug-in matches");
136     return false;
137   }
138 
139   /**
140    * OSGI Activation callback
141    *
142    * @param cc
143    *          the component context
144    */
145   @Activate
146   public void activate(ComponentContext cc) {
147     if (cc == null) {
148       // set defaults
149       authRequired = true;
150       xAccelRedirect = null;
151     } else {
152       authRequired = BooleanUtils.toBoolean(Objects.toString(cc.getProperties().get(PROP_AUTH_REQUIRED), "true"));
153 
154       xAccelRedirect = Objects.toString(cc.getProperties().get(PROP_X_ACCEL_REDIRECT), null);
155 
156       distributionDirectory = cc.getBundleContext().getProperty("org.opencastproject.download.directory");
157       if (StringUtils.isEmpty(distributionDirectory)) {
158         final String storageDir = cc.getBundleContext().getProperty("org.opencastproject.storage.dir");
159         if (StringUtils.isNotEmpty(storageDir)) {
160           distributionDirectory = new File(storageDir, "downloads").getPath();
161         }
162       }
163     }
164     logger.debug("Authentication check enabled: {}", authRequired);
165 
166     if (StringUtils.isEmpty(distributionDirectory)) {
167       throw new ConfigurationException("Distribution directory not set");
168     }
169     logger.info("Serving static files from '{}'", distributionDirectory);
170   }
171 
172   /**
173    * {@inheritDoc}
174    *
175    * @see javax.servlet.http.HttpServlet#doGet(javax.servlet.http.HttpServletRequest,
176    *      javax.servlet.http.HttpServletResponse)
177    */
178   @Override
179   protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
180     logger.debug("Looking for static resource '{}'", req.getRequestURI());
181     impl(req, resp, true);
182   }
183 
184   @Override
185   protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws IOException {
186     logger.trace("HEAD request '{}'", req.getRequestURI());
187     impl(req, resp, false);
188   }
189 
190   private void impl(HttpServletRequest req, HttpServletResponse resp, boolean withBody) throws IOException {
191     String path = req.getPathInfo();
192     if (path == null || path.contains("..")) {
193       resp.sendError(HttpServletResponse.SC_FORBIDDEN);
194       return;
195     }
196 
197     if (authRequired && !isAuthorized(path)) {
198       resp.sendError(HttpServletResponse.SC_FORBIDDEN);
199       logger.debug("Not authorized");
200       return;
201     }
202 
203     File file = new File(distributionDirectory, path);
204     if (!file.isFile() || !file.canRead()) {
205       logger.debug("Unable to find file '{}', returning HTTP 404", file);
206       resp.sendError(HttpServletResponse.SC_NOT_FOUND);
207       return;
208     }
209 
210     logger.debug("Serving static resource '{}'", file.getAbsolutePath());
211     String eTag = computeEtag(file);
212     if (eTag.equals(req.getHeader("If-None-Match"))) {
213       resp.setStatus(304);
214       return;
215     }
216     resp.setHeader("ETag", eTag);
217 
218     if (xAccelRedirect != null) {
219       resp.setHeader("X-Accel-Redirect", Paths.get(xAccelRedirect, path).toString());
220       return;
221     }
222 
223     String contentType = MimeTypes.getMimeType(path);
224     if (!MimeTypes.DEFAULT_TYPE.equals(contentType)) {
225       resp.setContentType(contentType);
226     }
227     resp.setHeader("Content-Length", Long.toString(file.length()));
228     resp.setDateHeader("Last-Modified", file.lastModified());
229 
230     // Set Content-Disposition header based on query parameter (`?download=1`).
231     // With this, passing that parameter will allow download links to trigger the download directly
232     // instead of opening files in the browser.
233     if ("1".equals(req.getParameter("download"))) {
234       resp.setHeader("Content-Disposition", "attachment");
235     }
236 
237     resp.setHeader("Accept-Ranges", "bytes");
238     ArrayList<Range> ranges = parseRange(req, resp, eTag, file.lastModified(), file.length());
239 
240     if ((((ranges == null) || (ranges.isEmpty())) && (req.getHeader("Range") == null)) || (ranges == FULL_RANGE)) {
241       if (withBody) {
242         IOException e = copyRange(new FileInputStream(file), resp.getOutputStream(), 0, file.length());
243         if (e != null) {
244           try {
245             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
246           } catch (IOException e1) {
247             logger.warn("unable to send http 500 error", e1);
248           } catch (IllegalStateException e2) {
249             logger.trace("unable to send http 500 error. Client side was probably closed during file copy.", e2);
250           }
251         }
252       }
253       return;
254     }
255     if ((ranges == null) || (ranges.isEmpty())) {
256       return;
257     }
258     if (ranges.size() == 1) {
259       Range range = ranges.get(0);
260       resp.addHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + range.length);
261       long length = range.end - range.start + 1;
262       if (length < Integer.MAX_VALUE) {
263         resp.setContentLength((int) length);
264       } else {
265         // Set the content-length as String to be able to use a long
266         resp.setHeader("content-length", "" + length);
267       }
268       resp.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
269       if (withBody) {
270         try {
271           resp.setBufferSize(2048);
272         } catch (IllegalStateException e) {
273           logger.debug(e.getMessage(), e);
274         }
275         IOException e = copyRange(new FileInputStream(file), resp.getOutputStream(), range.start, range.end);
276         if (e != null) {
277           try {
278             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
279           } catch (IOException e1) {
280             logger.warn("unable to send http 500 error", e1);
281           } catch (IllegalStateException e2) {
282             logger.trace("unable to send http 500 error. Client side was probably closed during file copy.", e2);
283           }
284         }
285       }
286       return;
287     }
288 
289     resp.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
290     resp.setContentType("multipart/byteranges; boundary=" + mimeSeparation);
291     if (withBody) {
292       try {
293         resp.setBufferSize(2048);
294       } catch (IllegalStateException e) {
295         logger.debug(e.getMessage(), e);
296       }
297       copy(file, resp.getOutputStream(), ranges.iterator(), contentType);
298     }
299   }
300 
301   /**
302    * Computes an etag for a file using the filename, last modified, and length of the file.
303    *
304    * @param file
305    *          the file
306    * @return the etag
307    */
308   private String computeEtag(File file) {
309     CRC32 crc = new CRC32();
310     crc.update(file.getName().getBytes());
311     checksum(file.lastModified(), crc);
312     checksum(file.length(), crc);
313     return Long.toString(crc.getValue());
314   }
315 
316   private static void checksum(long l, CRC32 crc) {
317     for (int i = 0; i < 8; i++) {
318       crc.update((int) (l & 0x000000ff));
319       l >>= 8;
320     }
321   }
322 
323   protected void copy(File f, ServletOutputStream out, Iterator<Range> ranges, String contentType) throws IOException {
324     IOException exception = null;
325     while ((exception == null) && (ranges.hasNext())) {
326       Range currentRange = ranges.next();
327       // Writing MIME header.
328       out.println();
329       out.println("--" + mimeSeparation);
330       if (contentType != null) {
331         out.println("Content-Type: " + contentType);
332       }
333       out.println("Content-Range: bytes " + currentRange.start + "-" + currentRange.end + "/" + currentRange.length);
334       out.println();
335 
336       // Printing content
337       InputStream in = new FileInputStream(f);
338       exception = copyRange(in, out, currentRange.start, currentRange.end);
339       in.close();
340     }
341     out.println();
342     out.print("--" + mimeSeparation + "--");
343     // Rethrow any exception that has occurred
344     if (exception != null) {
345       throw exception;
346     }
347   }
348 
349   /**
350    * MIME multipart separation string
351    */
352   private static final String mimeSeparation = "MATTERHORN_MIME_BOUNDARY";
353 
354   /**
355    * Parse the range header.
356    *
357    * @param req
358    *          The servlet request we are processing
359    * @param response
360    *          The servlet response we are creating
361    * @return Vector of ranges
362    */
363   protected ArrayList<Range> parseRange(HttpServletRequest req, HttpServletResponse response, String eTag,
364           long lastModified, long fileLength) throws IOException {
365 
366     // Checking If-Range
367     String headerValue = req.getHeader("If-Range");
368     if (headerValue != null) {
369       long headerValueTime = (-1L);
370       try {
371         headerValueTime = req.getDateHeader("If-Range");
372       } catch (IllegalArgumentException e) {
373         logger.debug(e.getMessage(), e);
374       }
375 
376       if (headerValueTime == (-1L)) {
377         // If the ETag the client gave does not match the entity
378         // etag, then the entire entity is returned.
379         if (!eTag.equals(headerValue.trim())) {
380           return FULL_RANGE;
381         }
382       } else {
383         // If the timestamp of the entity the client got is older than
384         // the last modification date of the entity, the entire entity
385         // is returned.
386         if (lastModified > (headerValueTime + 1000)) {
387           return FULL_RANGE;
388         }
389       }
390     }
391 
392     if (fileLength == 0) {
393       return null;
394     }
395 
396     // Retrieving the range header (if any is specified
397     String rangeHeader = req.getHeader("Range");
398 
399     if (rangeHeader == null) {
400       return null;
401     }
402     // bytes is the only range unit supported (and I don't see the point
403     // of adding new ones).
404     if (!rangeHeader.startsWith("bytes")) {
405       response.addHeader("Content-Range", "bytes */" + fileLength);
406       response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
407       return null;
408     }
409 
410     rangeHeader = rangeHeader.substring(6);
411 
412     // Vector which will contain all the ranges which are successfully
413     // parsed.
414     ArrayList<Range> result = new ArrayList<Range>();
415     StringTokenizer commaTokenizer = new StringTokenizer(rangeHeader, ",");
416 
417     // Parsing the range list
418     while (commaTokenizer.hasMoreTokens()) {
419       String rangeDefinition = commaTokenizer.nextToken().trim();
420       Range currentRange = new Range();
421       currentRange.length = fileLength;
422       int dashPos = rangeDefinition.indexOf('-');
423       if (dashPos == -1) {
424         response.addHeader("Content-Range", "bytes */" + fileLength);
425         response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
426         return null;
427       }
428       if (dashPos == 0) {
429         try {
430           long offset = Long.parseLong(rangeDefinition);
431           currentRange.start = fileLength + offset;
432           currentRange.end = fileLength - 1;
433         } catch (NumberFormatException e) {
434           response.addHeader("Content-Range", "bytes */" + fileLength);
435           response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
436           return null;
437         }
438       } else {
439         try {
440           currentRange.start = Long.parseLong(rangeDefinition.substring(0, dashPos));
441           if (dashPos < rangeDefinition.length() - 1) {
442             currentRange.end = Long.parseLong(rangeDefinition.substring(dashPos + 1, rangeDefinition.length()));
443           } else {
444             currentRange.end = fileLength - 1;
445           }
446         } catch (NumberFormatException e) {
447           response.addHeader("Content-Range", "bytes */" + fileLength);
448           response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
449           return null;
450         }
451       }
452       if (!currentRange.validate()) {
453         response.addHeader("Content-Range", "bytes */" + fileLength);
454         response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
455         return null;
456       }
457       result.add(currentRange);
458     }
459     return result;
460   }
461 
462   /**
463    * Copy the contents of the specified input stream to the specified output stream, and ensure that both streams are
464    * closed before returning (even in the face of an exception).
465    *
466    * @param istream
467    *          The input stream to read from
468    * @param ostream
469    *          The output stream to write to
470    * @param start
471    *          Start of the range which will be copied
472    * @param end
473    *          End of the range which will be copied
474    * @return Exception which occurred during processing
475    */
476   protected IOException copyRange(InputStream istream, ServletOutputStream ostream, long start, long end) {
477     logger.debug("Serving bytes:{}-{}", start, end);
478     try {
479       istream.skip(start);
480     } catch (IOException e) {
481       logger.trace("Cannot skip to input stream position {}. The user probably closed the client side.", start, e);
482       return e;
483     }
484     // MH-10447, fix for files of size 2048*C bytes
485     long bytesToRead = end - start + 1;
486     byte[] buffer = new byte[2048];
487     int len = buffer.length;
488     try {
489       len = (int) bytesToRead % buffer.length;
490       if (len > 0) {
491         len = istream.read(buffer, 0, len);
492         if (len > 0) {
493           // This test could actually be "if (len != -1)"
494           ostream.write(buffer, 0, len);
495           bytesToRead -= len;
496           if (bytesToRead == 0) {
497             return null;
498           }
499         } else {
500           return null;
501         }
502       }
503 
504       for (len = istream.read(buffer); len > 0; len = istream.read(buffer)) {
505         ostream.write(buffer, 0, len);
506         bytesToRead -= len;
507         if (bytesToRead < 1) {
508           break;
509         }
510       }
511     } catch (IOException e) {
512       logger.trace("IOException after starting the byte copy, current length {}, buffer {}."
513               + " The user probably closed the client side after the file started copying.",
514               len, buffer, e);
515       return e;
516     }
517     return null;
518   }
519 
520   protected class Range {
521 
522     protected long start;
523     protected long end;
524     protected long length;
525 
526     /**
527      * Validate range.
528      */
529     public boolean validate() {
530       if (end >= length) {
531         end = length - 1;
532       }
533       return ((start >= 0) && (end >= 0) && (start <= end) && (length > 0));
534     }
535 
536     public void recycle() {
537       start = 0;
538       end = 0;
539       length = 0;
540     }
541   }
542 }