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 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
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
296
297
298
299
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
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
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
337 if (exception != null) {
338 throw exception;
339 }
340 }
341
342
343
344
345 private static final String mimeSeparation = "MATTERHORN_MIME_BOUNDARY";
346
347
348
349
350
351
352
353
354
355
356 protected ArrayList<Range> parseRange(HttpServletRequest req, HttpServletResponse response, String eTag,
357 long lastModified, long fileLength) throws IOException {
358
359
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
371
372 if (!eTag.equals(headerValue.trim())) {
373 return FULL_RANGE;
374 }
375 } else {
376
377
378
379 if (lastModified > (headerValueTime + 1000)) {
380 return FULL_RANGE;
381 }
382 }
383 }
384
385 if (fileLength == 0) {
386 return null;
387 }
388
389
390 String rangeHeader = req.getHeader("Range");
391
392 if (rangeHeader == null) {
393 return null;
394 }
395
396
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
406
407 ArrayList<Range> result = new ArrayList<Range>();
408 StringTokenizer commaTokenizer = new StringTokenizer(rangeHeader, ",");
409
410
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
457
458
459
460
461
462
463
464
465
466
467
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
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
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
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 }