1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
64
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
78 private static final long serialVersionUID = 1L;
79
80 private static final ArrayList<Range> FULL_RANGE;
81
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
88 static {
89 FULL_RANGE = new ArrayList<>();
90 }
91
92 public static final String SERVLET_PATH = "/static";
93
94
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
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
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
141
142
143
144
145 @Activate
146 public void activate(ComponentContext cc) {
147 if (cc == null) {
148
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
174
175
176
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
231
232
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
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
303
304
305
306
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
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
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
344 if (exception != null) {
345 throw exception;
346 }
347 }
348
349
350
351
352 private static final String mimeSeparation = "MATTERHORN_MIME_BOUNDARY";
353
354
355
356
357
358
359
360
361
362
363 protected ArrayList<Range> parseRange(HttpServletRequest req, HttpServletResponse response, String eTag,
364 long lastModified, long fileLength) throws IOException {
365
366
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
378
379 if (!eTag.equals(headerValue.trim())) {
380 return FULL_RANGE;
381 }
382 } else {
383
384
385
386 if (lastModified > (headerValueTime + 1000)) {
387 return FULL_RANGE;
388 }
389 }
390 }
391
392 if (fileLength == 0) {
393 return null;
394 }
395
396
397 String rangeHeader = req.getHeader("Range");
398
399 if (rangeHeader == null) {
400 return null;
401 }
402
403
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
413
414 ArrayList<Range> result = new ArrayList<Range>();
415 StringTokenizer commaTokenizer = new StringTokenizer(rangeHeader, ",");
416
417
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
464
465
466
467
468
469
470
471
472
473
474
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
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
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
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 }