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