/
FileServlet.java
503 lines (437 loc) · 18.2 KB
/
FileServlet.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
/*
* Copyright 2021 OmniFaces
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.omnifaces.servlet;
import static java.lang.String.format;
import static java.util.logging.Level.FINE;
import static org.omnifaces.util.Servlets.formatContentDispositionHeader;
import static org.omnifaces.util.Utils.coalesce;
import static org.omnifaces.util.Utils.encodeURL;
import static org.omnifaces.util.Utils.startsWithOneOf;
import static org.omnifaces.util.Utils.stream;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.omnifaces.filter.GzipResponseFilter;
import org.omnifaces.util.Servlets;
/**
* <p>
* The well known "<a href="https://balusc.omnifaces.org/2009/02/fileservlet-supporting-resume-and.html">BalusC FileServlet</a>",
* as an abstract template, slightly refactored, rewritten and modernized with a.o. fast NIO stuff instead of legacy
* RandomAccessFile. GZIP support is stripped off as that can be done application wide via {@link GzipResponseFilter}.
* <p>
* This servlet properly deals with <code>ETag</code>, <code>If-None-Match</code> and <code>If-Modified-Since</code>
* caching requests, hereby improving browser caching. This servlet also properly deals with <code>Range</code> and
* <code>If-Range</code> ranging requests (<a href="https://tools.ietf.org/html/rfc7233">RFC7233</a>), which is required
* by most media players for proper audio/video streaming, and by webbrowsers and for a proper resume of an paused
* download, and by download accelerators to be able to request smaller parts simultaneously. This servlet is ideal when
* you have large files like media files placed outside the web application and you can't use the default servlet.
*
* <h3>Usage</h3>
* <p>
* Just extend this class and override the {@link #getFile(HttpServletRequest)} method to return the desired file. If
* you want to trigger a HTTP 400 "Bad Request" error, simply throw {@link IllegalArgumentException}. If you want to
* trigger a HTTP 404 "Not Found" error, simply return <code>null</code>, or a non-existent file.
* <p>
* Here's a concrete example which serves it via an URL like <code>/media/foo.ext</code>:
*
* <pre>
* @WebServlet("/media/*")
* public class MediaFileServlet extends FileServlet {
*
* private File folder;
*
* @Override
* public void init() throws ServletException {
* folder = new File("/var/webapp/media");
* }
*
* @Override
* protected File getFile(HttpServletRequest request) {
* String pathInfo = request.getPathInfo();
*
* if (pathInfo == null || pathInfo.isEmpty() || "/".equals(pathInfo)) {
* throw new IllegalArgumentException();
* }
*
* return new File(folder, pathInfo);
* }
*
* }
* </pre>
* <p>
* You can embed it in e.g. HTML5 video tag as below:
* <pre>
* <video src="#{request.contextPath}/media/video.mp4" controls="controls" />
* </pre>
*
* <h3>Customizing <code>FileServlet</code></h3>
* <p>
* If more fine grained control is desired for handling "file not found" error, determining the cache expire time, the
* content type, whether the file should be supplied as an attachment and the attachment's file name, then the developer
* can opt to override one or more of the following protected methods:
* <ul>
* <li>{@link #handleFileNotFound(HttpServletRequest, HttpServletResponse)}
* <li>{@link #getExpireTime(HttpServletRequest, File)}
* <li>{@link #getContentType(HttpServletRequest, File)}
* <li>{@link #isAttachment(HttpServletRequest, String)}
* <li>{@link #getAttachmentName(HttpServletRequest, File)}
* </ul>
*
* <p><strong>See also</strong>:
* <ul>
* <li><a href="https://stackoverflow.com/q/13588149/157882">How to stream audio/video files such as MP3, MP4, AVI, etc using a Servlet</a>
* <li><a href="https://stackoverflow.com/a/29991447/157882">Abstract template for a static resource servlet</a>
* </ul>
*
* @author Bauke Scholtz
* @since 2.2
*/
public abstract class FileServlet extends HttpServlet {
// Constants ------------------------------------------------------------------------------------------------------
private static final long serialVersionUID = 1L;
private static final Logger logger = Logger.getLogger(FileServlet.class.getName());
private static final Long DEFAULT_EXPIRE_TIME_IN_SECONDS = TimeUnit.DAYS.toSeconds(30);
private static final long ONE_SECOND_IN_MILLIS = TimeUnit.SECONDS.toMillis(1);
private static final String ETAG = "W/\"%s-%s\"";
private static final Pattern RANGE_PATTERN = Pattern.compile("^bytes=[0-9]*-[0-9]*(,[0-9]*-[0-9]*)*+$");
private static final String MULTIPART_BOUNDARY = UUID.randomUUID().toString();
// Actions --------------------------------------------------------------------------------------------------------
@Override
protected void doHead(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
doRequest(request, response, true);
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
doRequest(request, response, false);
}
private void doRequest(HttpServletRequest request, HttpServletResponse response, boolean head) throws IOException {
response.reset();
Resource resource;
try {
resource = new Resource(getFile(request));
}
catch (IllegalArgumentException e) {
logger.log(FINE, "Got an IllegalArgumentException from user code; interpreting it as 400 Bad Request.", e);
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
if (resource.file == null) {
handleFileNotFound(request, response);
return;
}
if (preconditionFailed(request, resource)) {
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return;
}
setCacheHeaders(response, resource, getExpireTime(request, resource.file));
if (notModified(request, resource)) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return;
}
List<Range> ranges = getRanges(request, resource);
if (ranges == null) {
response.setHeader("Content-Range", "bytes */" + resource.length);
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
if (!ranges.isEmpty()) {
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
}
else {
ranges.add(new Range(0, resource.length - 1)); // Full content.
}
String contentType = setContentHeaders(request, response, resource, ranges);
if (head) {
return;
}
writeContent(response, resource, ranges, contentType);
}
/**
* Returns the file associated with the given HTTP servlet request.
* If this method throws {@link IllegalArgumentException}, then the servlet will return a HTTP 400 error.
* If this method returns <code>null</code>, or if {@link File#isFile()} returns <code>false</code>, then the
* servlet will invoke {@link #handleFileNotFound(HttpServletRequest, HttpServletResponse)}.
* @param request The involved HTTP servlet request.
* @return The file associated with the given HTTP servlet request.
* @throws IllegalArgumentException When the request is mangled in such way that it's not recognizable as a valid
* file request. The servlet will then return a HTTP 400 error.
*/
protected abstract File getFile(HttpServletRequest request);
/**
* Handles the case when the file is not found.
* <p>
* The default implementation sends a HTTP 404 error.
* @param request The involved HTTP servlet request.
* @param response The involved HTTP servlet response.
* @throws IOException When something fails at I/O level.
* @since 2.3
*/
protected void handleFileNotFound(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
/**
* Returns how long the resource may be cached by the client before it expires, in seconds.
* <p>
* The default implementation returns 30 days in seconds.
* @param request The involved HTTP servlet request.
* @param file The involved file.
* @return The client cache expire time in seconds (not milliseconds!).
*/
protected long getExpireTime(HttpServletRequest request, File file) {
return DEFAULT_EXPIRE_TIME_IN_SECONDS;
}
/**
* Returns the content type associated with the given HTTP servlet request and file.
* <p>
* The default implementation delegates {@link File#getName()} to {@link ServletContext#getMimeType(String)} with a
* fallback default value of <code>application/octet-stream</code>.
* @param request The involved HTTP servlet request.
* @param file The involved file.
* @return The content type associated with the given HTTP servlet request and file.
*/
protected String getContentType(HttpServletRequest request, File file) {
return coalesce(request.getServletContext().getMimeType(file.getName()), "application/octet-stream");
}
/**
* Returns <code>true</code> if we must force a "Save As" dialog based on the given HTTP servlet request and content
* type as obtained from {@link #getContentType(HttpServletRequest, File)}.
* <p>
* The default implementation will return <code>true</code> if the content type does <strong>not</strong> start with
* <code>text</code> or <code>image</code>, and the <code>Accept</code> request header is either <code>null</code>
* or does not match the given content type.
* @param request The involved HTTP servlet request.
* @param contentType The content type of the involved file.
* @return <code>true</code> if we must force a "Save As" dialog based on the given HTTP servlet request and content
* type.
*/
protected boolean isAttachment(HttpServletRequest request, String contentType) {
String accept = request.getHeader("Accept");
return !startsWithOneOf(contentType, "text", "image") && (accept == null || !accepts(accept, contentType));
}
/**
* Returns the file name to be used in <code>Content-Disposition</code> header.
* This does not need to be URL-encoded as this will be taken care of.
* <p>
* The default implementation returns {@link File#getName()}.
* @param request The involved HTTP servlet request.
* @param file The involved file.
* @return The file name to be used in <code>Content-Disposition</code> header.
* @since 2.3
*/
protected String getAttachmentName(HttpServletRequest request, File file) {
return file.getName();
}
// Sub-actions ----------------------------------------------------------------------------------------------------
/**
* Returns true if it's a conditional request which must return 412.
*/
private boolean preconditionFailed(HttpServletRequest request, Resource resource) {
String match = request.getHeader("If-Match");
long unmodified = request.getDateHeader("If-Unmodified-Since");
return (match != null) ? !matches(match, resource.eTag) : (unmodified != -1 && modified(unmodified, resource.lastModified));
}
/**
* Set cache headers.
*/
private void setCacheHeaders(HttpServletResponse response, Resource resource, long expires) {
Servlets.setCacheHeaders(response, expires);
response.setHeader("ETag", resource.eTag);
response.setDateHeader("Last-Modified", resource.lastModified);
}
/**
* Returns true if it's a conditional request which must return 304.
*/
private boolean notModified(HttpServletRequest request, Resource resource) {
String noMatch = request.getHeader("If-None-Match");
long modified = request.getDateHeader("If-Modified-Since");
return (noMatch != null) ? matches(noMatch, resource.eTag) : (modified != -1 && !modified(modified, resource.lastModified));
}
/**
* Get requested ranges. If this is null, then we must return 416. If this is empty, then we must return full file.
*/
private List<Range> getRanges(HttpServletRequest request, Resource resource) {
List<Range> ranges = new ArrayList<>(1);
String rangeHeader = request.getHeader("Range");
if (rangeHeader == null) {
return ranges;
}
else if (!RANGE_PATTERN.matcher(rangeHeader).matches()) {
return null; // Syntax error.
}
String ifRange = request.getHeader("If-Range");
if (ifRange != null && !ifRange.equals(resource.eTag)) {
try {
long ifRangeTime = request.getDateHeader("If-Range");
if (ifRangeTime != -1 && modified(ifRangeTime, resource.lastModified)) {
return ranges;
}
}
catch (IllegalArgumentException ifRangeHeaderIsInvalid) {
logger.log(FINE, "If-Range header is invalid. Just return full file then.", ifRangeHeaderIsInvalid);
return ranges;
}
}
for (String rangeHeaderPart : rangeHeader.split("=")[1].split(",")) {
Range range = parseRange(rangeHeaderPart, resource.length);
if (range == null) {
return null; // Logic error.
}
ranges.add(range);
}
return ranges;
}
/**
* Parse range header part. Returns null if there's a logic error (i.e. start after end).
*/
private Range parseRange(String range, long length) {
long start = sublong(range, 0, range.indexOf('-'));
long end = sublong(range, range.indexOf('-') + 1, range.length());
if (start == -1) {
start = length - end;
end = length - 1;
}
else if (end == -1 || end > length - 1) {
end = length - 1;
}
if (start > end) {
return null; // Logic error.
}
return new Range(start, end);
}
/**
* Set content headers.
*/
private String setContentHeaders(HttpServletRequest request, HttpServletResponse response, Resource resource, List<Range> ranges) {
String contentType = getContentType(request, resource.file);
String filename = getAttachmentName(request, resource.file);
boolean attachment = isAttachment(request, contentType);
response.setHeader("Content-Disposition", formatContentDispositionHeader(filename, attachment));
response.setHeader("Accept-Ranges", "bytes");
if (ranges.size() == 1) {
Range range = ranges.get(0);
response.setContentType(contentType);
response.setHeader("Content-Length", String.valueOf(range.length));
if (response.getStatus() == HttpServletResponse.SC_PARTIAL_CONTENT) {
response.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + resource.length);
}
}
else {
response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY);
}
return contentType;
}
/**
* Write given file to response with given content type and ranges.
*/
private void writeContent(HttpServletResponse response, Resource resource, List<Range> ranges, String contentType) throws IOException {
ServletOutputStream output = response.getOutputStream();
if (ranges.size() == 1) {
Range range = ranges.get(0);
stream(resource.file, output, range.start, range.length);
}
else {
for (Range range : ranges) {
output.println();
output.println("--" + MULTIPART_BOUNDARY);
output.println("Content-Type: " + contentType);
output.println("Content-Range: bytes " + range.start + "-" + range.end + "/" + resource.length);
stream(resource.file, output, range.start, range.length);
}
output.println();
output.println("--" + MULTIPART_BOUNDARY + "--");
}
}
// Helpers --------------------------------------------------------------------------------------------------------
/**
* Returns true if the given match header matches the given ETag value.
*/
private static boolean matches(String matchHeader, String eTag) {
String[] matchValues = matchHeader.split("\\s*,\\s*");
Arrays.sort(matchValues);
return Arrays.binarySearch(matchValues, eTag) > -1
|| Arrays.binarySearch(matchValues, "*") > -1;
}
/**
* Returns true if the given modified header is older than the given last modified value.
*/
private static boolean modified(long modifiedHeader, long lastModified) {
return (modifiedHeader + ONE_SECOND_IN_MILLIS <= lastModified); // That second is because the header is in seconds, not millis.
}
/**
* Returns a substring of the given string value from the given begin index to the given end index as a long.
* If the substring is empty, then -1 will be returned.
*/
private static long sublong(String value, int beginIndex, int endIndex) {
String substring = value.substring(beginIndex, endIndex);
return substring.isEmpty() ? -1 : Long.parseLong(substring);
}
/**
* Returns true if the given accept header accepts the given value.
*/
private static boolean accepts(String acceptHeader, String toAccept) {
String[] acceptValues = acceptHeader.split("\\s*[,;]\\s*");
Arrays.sort(acceptValues);
return Arrays.binarySearch(acceptValues, toAccept) > -1
|| Arrays.binarySearch(acceptValues, toAccept.replaceAll("/.*$", "/*")) > -1
|| Arrays.binarySearch(acceptValues, "*/*") > -1;
}
// Nested classes -------------------------------------------------------------------------------------------------
/**
* Convenience class for a file resource.
*/
private static class Resource {
private final File file;
private final long length;
private final long lastModified;
private final String eTag;
public Resource(File file) {
if (file != null && file.isFile()) {
this.file = file;
length = file.length();
lastModified = file.lastModified();
eTag = format(ETAG, encodeURL(file.getName()), lastModified);
}
else {
this.file = null;
length = 0;
lastModified = 0;
eTag = null;
}
}
}
/**
* Convenience class for a byte range.
*/
private static class Range {
private final long start;
private final long end;
private final long length;
public Range(long start, long end) {
this.start = start;
this.end = end;
length = end - start + 1;
}
}
}