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     resp.setHeader("Accept-Ranges", "bytes");
231     ArrayList<Range> ranges = parseRange(req, resp, eTag, file.lastModified(), file.length());
232 
233     if ((((ranges == null) || (ranges.isEmpty())) && (req.getHeader("Range") == null)) || (ranges == FULL_RANGE)) {
234       if (withBody) {
235         IOException e = copyRange(new FileInputStream(file), resp.getOutputStream(), 0, file.length());
236         if (e != null) {
237           try {
238             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
239           } catch (IOException e1) {
240             logger.warn("unable to send http 500 error", e1);
241           } catch (IllegalStateException e2) {
242             logger.trace("unable to send http 500 error. Client side was probably closed during file copy.", e2);
243           }
244         }
245       }
246       return;
247     }
248     if ((ranges == null) || (ranges.isEmpty())) {
249       return;
250     }
251     if (ranges.size() == 1) {
252       Range range = ranges.get(0);
253       resp.addHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + range.length);
254       long length = range.end - range.start + 1;
255       if (length < Integer.MAX_VALUE) {
256         resp.setContentLength((int) length);
257       } else {
258         // Set the content-length as String to be able to use a long
259         resp.setHeader("content-length", "" + length);
260       }
261       resp.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
262       if (withBody) {
263         try {
264           resp.setBufferSize(2048);
265         } catch (IllegalStateException e) {
266           logger.debug(e.getMessage(), e);
267         }
268         IOException e = copyRange(new FileInputStream(file), resp.getOutputStream(), range.start, range.end);
269         if (e != null) {
270           try {
271             resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
272           } catch (IOException e1) {
273             logger.warn("unable to send http 500 error", e1);
274           } catch (IllegalStateException e2) {
275             logger.trace("unable to send http 500 error. Client side was probably closed during file copy.", e2);
276           }
277         }
278       }
279       return;
280     }
281 
282     resp.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
283     resp.setContentType("multipart/byteranges; boundary=" + mimeSeparation);
284     if (withBody) {
285       try {
286         resp.setBufferSize(2048);
287       } catch (IllegalStateException e) {
288         logger.debug(e.getMessage(), e);
289       }
290       copy(file, resp.getOutputStream(), ranges.iterator(), contentType);
291     }
292   }
293 
294   /**
295    * Computes an etag for a file using the filename, last modified, and length of the file.
296    *
297    * @param file
298    *          the file
299    * @return the etag
300    */
301   private String computeEtag(File file) {
302     CRC32 crc = new CRC32();
303     crc.update(file.getName().getBytes());
304     checksum(file.lastModified(), crc);
305     checksum(file.length(), crc);
306     return Long.toString(crc.getValue());
307   }
308 
309   private static void checksum(long l, CRC32 crc) {
310     for (int i = 0; i < 8; i++) {
311       crc.update((int) (l & 0x000000ff));
312       l >>= 8;
313     }
314   }
315 
316   protected void copy(File f, ServletOutputStream out, Iterator<Range> ranges, String contentType) throws IOException {
317     IOException exception = null;
318     while ((exception == null) && (ranges.hasNext())) {
319       Range currentRange = ranges.next();
320       // Writing MIME header.
321       out.println();
322       out.println("--" + mimeSeparation);
323       if (contentType != null) {
324         out.println("Content-Type: " + contentType);
325       }
326       out.println("Content-Range: bytes " + currentRange.start + "-" + currentRange.end + "/" + currentRange.length);
327       out.println();
328 
329       // Printing content
330       InputStream in = new FileInputStream(f);
331       exception = copyRange(in, out, currentRange.start, currentRange.end);
332       in.close();
333     }
334     out.println();
335     out.print("--" + mimeSeparation + "--");
336     // Rethrow any exception that has occurred
337     if (exception != null) {
338       throw exception;
339     }
340   }
341 
342   /**
343    * MIME multipart separation string
344    */
345   private static final String mimeSeparation = "MATTERHORN_MIME_BOUNDARY";
346 
347   /**
348    * Parse the range header.
349    *
350    * @param req
351    *          The servlet request we are processing
352    * @param response
353    *          The servlet response we are creating
354    * @return Vector of ranges
355    */
356   protected ArrayList<Range> parseRange(HttpServletRequest req, HttpServletResponse response, String eTag,
357           long lastModified, long fileLength) throws IOException {
358 
359     // Checking If-Range
360     String headerValue = req.getHeader("If-Range");
361     if (headerValue != null) {
362       long headerValueTime = (-1L);
363       try {
364         headerValueTime = req.getDateHeader("If-Range");
365       } catch (IllegalArgumentException e) {
366         logger.debug(e.getMessage(), e);
367       }
368 
369       if (headerValueTime == (-1L)) {
370         // If the ETag the client gave does not match the entity
371         // etag, then the entire entity is returned.
372         if (!eTag.equals(headerValue.trim())) {
373           return FULL_RANGE;
374         }
375       } else {
376         // If the timestamp of the entity the client got is older than
377         // the last modification date of the entity, the entire entity
378         // is returned.
379         if (lastModified > (headerValueTime + 1000)) {
380           return FULL_RANGE;
381         }
382       }
383     }
384 
385     if (fileLength == 0) {
386       return null;
387     }
388 
389     // Retrieving the range header (if any is specified
390     String rangeHeader = req.getHeader("Range");
391 
392     if (rangeHeader == null) {
393       return null;
394     }
395     // bytes is the only range unit supported (and I don't see the point
396     // of adding new ones).
397     if (!rangeHeader.startsWith("bytes")) {
398       response.addHeader("Content-Range", "bytes */" + fileLength);
399       response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
400       return null;
401     }
402 
403     rangeHeader = rangeHeader.substring(6);
404 
405     // Vector which will contain all the ranges which are successfully
406     // parsed.
407     ArrayList<Range> result = new ArrayList<Range>();
408     StringTokenizer commaTokenizer = new StringTokenizer(rangeHeader, ",");
409 
410     // Parsing the range list
411     while (commaTokenizer.hasMoreTokens()) {
412       String rangeDefinition = commaTokenizer.nextToken().trim();
413       Range currentRange = new Range();
414       currentRange.length = fileLength;
415       int dashPos = rangeDefinition.indexOf('-');
416       if (dashPos == -1) {
417         response.addHeader("Content-Range", "bytes */" + fileLength);
418         response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
419         return null;
420       }
421       if (dashPos == 0) {
422         try {
423           long offset = Long.parseLong(rangeDefinition);
424           currentRange.start = fileLength + offset;
425           currentRange.end = fileLength - 1;
426         } catch (NumberFormatException e) {
427           response.addHeader("Content-Range", "bytes */" + fileLength);
428           response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
429           return null;
430         }
431       } else {
432         try {
433           currentRange.start = Long.parseLong(rangeDefinition.substring(0, dashPos));
434           if (dashPos < rangeDefinition.length() - 1) {
435             currentRange.end = Long.parseLong(rangeDefinition.substring(dashPos + 1, rangeDefinition.length()));
436           } else {
437             currentRange.end = fileLength - 1;
438           }
439         } catch (NumberFormatException e) {
440           response.addHeader("Content-Range", "bytes */" + fileLength);
441           response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
442           return null;
443         }
444       }
445       if (!currentRange.validate()) {
446         response.addHeader("Content-Range", "bytes */" + fileLength);
447         response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
448         return null;
449       }
450       result.add(currentRange);
451     }
452     return result;
453   }
454 
455   /**
456    * Copy the contents of the specified input stream to the specified output stream, and ensure that both streams are
457    * closed before returning (even in the face of an exception).
458    *
459    * @param istream
460    *          The input stream to read from
461    * @param ostream
462    *          The output stream to write to
463    * @param start
464    *          Start of the range which will be copied
465    * @param end
466    *          End of the range which will be copied
467    * @return Exception which occurred during processing
468    */
469   protected IOException copyRange(InputStream istream, ServletOutputStream ostream, long start, long end) {
470     logger.debug("Serving bytes:{}-{}", start, end);
471     try {
472       istream.skip(start);
473     } catch (IOException e) {
474       logger.trace("Cannot skip to input stream position {}. The user probably closed the client side.", start, e);
475       return e;
476     }
477     // MH-10447, fix for files of size 2048*C bytes
478     long bytesToRead = end - start + 1;
479     byte[] buffer = new byte[2048];
480     int len = buffer.length;
481     try {
482       len = (int) bytesToRead % buffer.length;
483       if (len > 0) {
484         len = istream.read(buffer, 0, len);
485         if (len > 0) {
486           // This test could actually be "if (len != -1)"
487           ostream.write(buffer, 0, len);
488           bytesToRead -= len;
489           if (bytesToRead == 0) {
490             return null;
491           }
492         } else {
493           return null;
494         }
495       }
496 
497       for (len = istream.read(buffer); len > 0; len = istream.read(buffer)) {
498         ostream.write(buffer, 0, len);
499         bytesToRead -= len;
500         if (bytesToRead < 1) {
501           break;
502         }
503       }
504     } catch (IOException e) {
505       logger.trace("IOException after starting the byte copy, current length {}, buffer {}."
506               + " The user probably closed the client side after the file started copying.",
507               len, buffer, e);
508       return e;
509     }
510     return null;
511   }
512 
513   protected class Range {
514 
515     protected long start;
516     protected long end;
517     protected long length;
518 
519     /**
520      * Validate range.
521      */
522     public boolean validate() {
523       if (end >= length) {
524         end = length - 1;
525       }
526       return ((start >= 0) && (end >= 0) && (start <= end) && (length > 0));
527     }
528 
529     public void recycle() {
530       start = 0;
531       end = 0;
532       length = 0;
533     }
534   }
535 }