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 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
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
280
281
282
283
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
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
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
321 if (exception != null) {
322 throw exception;
323 }
324 }
325
326
327
328
329 private static final String mimeSeparation = "MATTERHORN_MIME_BOUNDARY";
330
331
332
333
334
335
336
337
338
339
340 protected ArrayList<Range> parseRange(HttpServletRequest req, HttpServletResponse response, String eTag,
341 long lastModified, long fileLength) throws IOException {
342
343
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
355
356 if (!eTag.equals(headerValue.trim())) {
357 return FULL_RANGE;
358 }
359 } else {
360
361
362
363 if (lastModified > (headerValueTime + 1000)) {
364 return FULL_RANGE;
365 }
366 }
367 }
368
369 if (fileLength == 0) {
370 return null;
371 }
372
373
374 String rangeHeader = req.getHeader("Range");
375
376 if (rangeHeader == null) {
377 return null;
378 }
379
380
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
390
391 ArrayList<Range> result = new ArrayList<Range>();
392 StringTokenizer commaTokenizer = new StringTokenizer(rangeHeader, ",");
393
394
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
441
442
443
444
445
446
447
448
449
450
451
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
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
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
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 }