Created
April 14, 2023 09:33
-
-
Save devld/ab7c0feb89b4292a138947911bd5e50b to your computer and use it in GitHub Desktop.
HTTP serve file with range and cache headers
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package me.devld; | |
import javax.servlet.http.HttpServletRequest; | |
import javax.servlet.http.HttpServletResponse; | |
import java.io.*; | |
import java.lang.ref.SoftReference; | |
import java.nio.charset.StandardCharsets; | |
import java.text.ParseException; | |
import java.text.SimpleDateFormat; | |
import java.util.*; | |
/* | |
* Licensed to the Apache Software Foundation (ASF) under one or more | |
* contributor license agreements. See the NOTICE file distributed with | |
* this work for additional information regarding copyright ownership. | |
* The ASF licenses this file to You 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 | |
* | |
* http://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. | |
*/ | |
public final class HttpServeFile { | |
public static class Request { | |
public final String method; | |
public final Headers headers; | |
public final File file; | |
public final String mimeType; | |
public Request(String method, Headers headers, File file, String mimeType) { | |
this.method = method.toUpperCase(); | |
this.headers = headers; | |
this.file = file; | |
this.mimeType = mimeType; | |
} | |
public static Request from(HttpServletRequest request, File file, String mimeType) { | |
Headers headers = new Headers(); | |
@SuppressWarnings("unchecked") | |
Enumeration<String> headerNames = request.getHeaderNames(); | |
while (headerNames.hasMoreElements()) { | |
String key = headerNames.nextElement(); | |
headers.set(key, request.getHeader(key)); | |
} | |
return new Request(request.getMethod(), headers, file, mimeType); | |
} | |
} | |
public static class ServletResponse implements Response { | |
private final HttpServletResponse resp; | |
public ServletResponse(HttpServletResponse resp) { | |
this.resp = resp; | |
} | |
@Override | |
public void writeHeader(int status, Headers headers) { | |
for (String key : headers.keys()) { | |
resp.setHeader(key, headers.get(key)); | |
} | |
resp.setStatus(status); | |
} | |
@Override | |
public OutputStream getOutput() throws IOException { | |
return resp.getOutputStream(); | |
} | |
} | |
public interface Response { | |
void writeHeader(int status, Headers headers) throws IOException; | |
OutputStream getOutput() throws IOException; | |
} | |
private final int input; | |
private final String mimeSeparation; | |
public HttpServeFile() { | |
this(2048, "CATALINA_MIME_BOUNDARY"); | |
} | |
public HttpServeFile(int input, String mimeSeparation) { | |
this.input = input; | |
this.mimeSeparation = mimeSeparation; | |
} | |
public void serve(Request request, Response response) throws IOException { | |
boolean serveContent = !"HEAD".equals(request.method); | |
File resource = request.file; | |
if (!resource.exists()) { | |
new ShortResponse(404).write(response); | |
return; | |
} | |
if (!resource.canRead()) { | |
new ShortResponse(403).write(response); | |
return; | |
} | |
// Check if the conditions specified in the optional If headers are | |
// satisfied. | |
if (resource.isFile()) { | |
// Checking If headers | |
ShortResponse r = checkIfHeaders(request, resource); | |
if (r != null) { | |
r.write(response); | |
return; | |
} | |
} | |
String contentType = request.mimeType; | |
if (contentType == null) { | |
contentType = "application/octet-stream"; | |
} | |
// These need to reflect the original resource, not the potentially | |
// precompressed version of the resource so get them now if they are going to | |
// be needed later | |
String eTag = null; | |
String lastModifiedHttp = null; | |
if (resource.isFile()) { | |
eTag = generateETag(resource); | |
lastModifiedHttp = DateUtils.formatDate(new Date(resource.lastModified())); | |
} | |
if (resource.isDirectory()) { | |
new ShortResponse(405).write(response); | |
return; | |
} | |
Ranges ranges; | |
long contentLength; | |
int status = 200; | |
Headers respHeaders = new Headers(); | |
respHeaders.set("Accept-Ranges", "bytes"); | |
// Parse range specifier | |
try { | |
ranges = parseRange(request, resource); | |
} catch (ParseRangeException e) { | |
e.shortResponse.write(response); | |
return; | |
} | |
// ETag header | |
respHeaders.set("ETag", eTag); | |
// Last-Modified header | |
respHeaders.set("Last-Modified", lastModifiedHttp); | |
// Get content length | |
contentLength = resource.length(); | |
// Special case for zero length files, which would cause a | |
// (silent) ISE when setting the output buffer size | |
if (contentLength == 0L) { | |
serveContent = false; | |
} | |
OutputStream ostream = null; | |
if (serveContent) ostream = response.getOutput(); | |
if (ranges == Ranges.FULL) { | |
// Set the appropriate output headers | |
respHeaders.set("Content-Type", contentType); | |
if (resource.isFile() && contentLength >= 0 && | |
(!serveContent || ostream != null)) { | |
respHeaders.set("Content-Length", String.valueOf(contentLength)); | |
} | |
if (serveContent) { | |
InputStream renderResult = new FileInputStream(resource); | |
copy(renderResult, ostream); | |
} | |
} else { | |
if ((ranges == null) || (ranges.getEntries().isEmpty())) { | |
response.writeHeader(200, respHeaders); | |
return; | |
} | |
status = 206; | |
if (ranges.getEntries().size() == 1) { | |
Ranges.Entry range = ranges.getEntries().get(0); | |
long start = rangeGetStart(range, contentLength); | |
long end = rangeGetEnd(range, contentLength); | |
respHeaders.set("Content-Range", | |
"bytes " + start + "-" + end + "/" + contentLength); | |
long length = end - start + 1; | |
respHeaders.set("Content-Length", String.valueOf(length)); | |
respHeaders.set("Content-Type", contentType); | |
if (serveContent) { | |
if (ostream != null) { | |
copy(resource, contentLength, ostream, range); | |
} else { | |
// we should not get here | |
throw new IllegalStateException(); | |
} | |
} | |
} else { | |
respHeaders.set("Content-Type", "multipart / byteranges; boundary = " + mimeSeparation); | |
if (serveContent) { | |
if (ostream != null) { | |
copy(resource, contentLength, ostream, ranges, contentType); | |
} else { | |
// we should not get here | |
throw new IllegalStateException(); | |
} | |
} | |
} | |
} | |
response.writeHeader(status, respHeaders); | |
} | |
private void copy(InputStream is, OutputStream ostream) throws IOException { | |
IOException exception; | |
InputStream istream = new BufferedInputStream(is, input); | |
// Copy the input stream to the output stream | |
exception = copyRange(istream, ostream); | |
// Clean up the input stream | |
istream.close(); | |
// Rethrow any exception that has occurred | |
if (exception != null) { | |
throw exception; | |
} | |
} | |
private void copy(File resource, long length, OutputStream ostream, Ranges.Entry range) | |
throws IOException { | |
IOException exception; | |
InputStream resourceInputStream = new FileInputStream(resource); | |
InputStream istream = | |
new BufferedInputStream(resourceInputStream, input); | |
exception = copyRange(istream, ostream, rangeGetStart(range, length), rangeGetEnd(range, length)); | |
// Clean up the input stream | |
istream.close(); | |
// Rethrow any exception that has occurred | |
if (exception != null) { | |
throw exception; | |
} | |
} | |
private void copy(File resource, long length, OutputStream ostream, Ranges ranges, String contentType) | |
throws IOException { | |
IOException exception = null; | |
for (Ranges.Entry range : ranges.getEntries()) { | |
if (exception != null) { | |
break; | |
} | |
InputStream resourceInputStream = new FileInputStream(resource); | |
try (InputStream istream = new BufferedInputStream(resourceInputStream, input)) { | |
// Writing MIME header. | |
ostream.write("\r\n".getBytes(StandardCharsets.UTF_8)); | |
ostream.write(("--" + mimeSeparation + "\r\n").getBytes(StandardCharsets.UTF_8)); | |
if (contentType != null) { | |
ostream.write(("Content-Type: " + contentType + "\r\n").getBytes(StandardCharsets.UTF_8)); | |
} | |
long start = rangeGetStart(range, length); | |
long end = rangeGetEnd(range, length); | |
ostream.write(("Content-Range: bytes " + start | |
+ "-" + end + "/" | |
+ (end - start) + "\r\n").getBytes(StandardCharsets.UTF_8)); | |
ostream.write("\r\n".getBytes(StandardCharsets.UTF_8)); | |
// Printing content | |
exception = copyRange(istream, ostream, start, end); | |
} | |
} | |
ostream.write("\r\n".getBytes(StandardCharsets.UTF_8)); | |
ostream.write(("--" + mimeSeparation + "--").getBytes(StandardCharsets.UTF_8)); | |
// Rethrow any exception that has occurred | |
if (exception != null) { | |
throw exception; | |
} | |
} | |
private IOException copyRange(InputStream istream, OutputStream ostream) { | |
// Copy the input stream to the output stream | |
IOException exception = null; | |
byte[] buffer = new byte[input]; | |
int len; | |
while (true) { | |
try { | |
len = istream.read(buffer); | |
if (len == -1) { | |
break; | |
} | |
ostream.write(buffer, 0, len); | |
} catch (IOException e) { | |
exception = e; | |
// len = -1; | |
break; | |
} | |
} | |
return exception; | |
} | |
private IOException copyRange(InputStream istream, OutputStream ostream, long start, long end) { | |
long skipped; | |
try { | |
skipped = istream.skip(start); | |
} catch (IOException e) { | |
return e; | |
} | |
if (skipped < start) { | |
return new IOException("skip failed: " + skipped + ", " + start); | |
} | |
IOException exception = null; | |
long bytesToRead = end - start + 1; | |
byte[] buffer = new byte[input]; | |
int len = buffer.length; | |
while ((bytesToRead > 0) && (len >= buffer.length)) { | |
try { | |
len = istream.read(buffer); | |
if (bytesToRead >= len) { | |
ostream.write(buffer, 0, len); | |
bytesToRead -= len; | |
} else { | |
ostream.write(buffer, 0, (int) bytesToRead); | |
bytesToRead = 0; | |
} | |
} catch (IOException e) { | |
exception = e; | |
len = -1; | |
} | |
if (len < buffer.length) { | |
break; | |
} | |
} | |
return exception; | |
} | |
private Ranges parseRange(Request request, File resource) throws IOException, ParseRangeException { | |
// Range headers are only valid on GET requests. That implies they are | |
// also valid on HEAD requests. This method is only called by doGet() | |
// and doHead() so no need to check the request method. | |
// Checking If-Range | |
String headerValue = request.headers.get("If-Range"); | |
if (headerValue != null) { | |
long headerValueTime = (-1L); | |
try { | |
headerValueTime = request.headers.getDate("If-Range"); | |
} catch (IllegalArgumentException e) { | |
// Ignore | |
} | |
String eTag = generateETag(resource); | |
long lastModified = resource.lastModified(); | |
if (headerValueTime == (-1L)) { | |
// If the ETag the client gave does not match the entity | |
// etag, then the entire entity is returned. | |
if (!eTag.equals(headerValue.trim())) { | |
return Ranges.FULL; | |
} | |
} else { | |
// If the timestamp of the entity the client got differs from | |
// the last modification date of the entity, the entire entity | |
// is returned. | |
if (Math.abs(lastModified - headerValueTime) > 1000) { | |
return Ranges.FULL; | |
} | |
} | |
} | |
long fileLength = resource.length(); | |
if (fileLength == 0) { | |
// Range header makes no sense for a zero length resource. Tomcat | |
// therefore opts to ignore it. | |
return Ranges.FULL; | |
} | |
// Retrieving the range header (if any is specified | |
String rangeHeader = request.headers.get("Range"); | |
if (rangeHeader == null) { | |
// No Range header is the same as ignoring any Range header | |
return Ranges.FULL; | |
} | |
Ranges ranges = Ranges.parse(new StringReader(rangeHeader)); | |
if (ranges == null) { | |
// The Range header is present but not formatted correctly. | |
// Could argue for a 400 response but 416 is more specific. | |
// There is also the option to ignore the (invalid) Range header. | |
// RFC7233#4.4 notes that many servers do ignore the Range header in | |
// these circumstances but Tomcat has always returned a 416. | |
throw new ParseRangeException( | |
new ShortResponse(416, Collections.singletonMap("Content-Range", "bytes */" + fileLength)) | |
); | |
} | |
// bytes is the only range unit supported (and I don't see the point | |
// of adding new ones). | |
if (!ranges.getUnits().equals("bytes")) { | |
// RFC7233#3.1 Servers must ignore range units they don't understand | |
return Ranges.FULL; | |
} | |
for (Ranges.Entry range : ranges.getEntries()) { | |
if (!validateRange(range, fileLength)) { | |
throw new ParseRangeException( | |
new ShortResponse(416, Collections.singletonMap("Content-Range", "bytes */" + fileLength)) | |
); | |
} | |
} | |
return ranges; | |
} | |
private static boolean validateRange(Ranges.Entry range, long length) { | |
long start = rangeGetStart(range, length); | |
long end = rangeGetEnd(range, length); | |
return (start >= 0) && (end >= 0) && (start <= end); | |
} | |
private static long rangeGetStart(Ranges.Entry range, long length) { | |
long start = range.getStart(); | |
if (start == -1) { | |
long end = range.getEnd(); | |
// If there is no start, then the start is based on the end | |
if (end >= length) { | |
return 0; | |
} else { | |
return length - end; | |
} | |
} else { | |
return start; | |
} | |
} | |
private static long rangeGetEnd(Ranges.Entry range, long length) { | |
long end = range.getEnd(); | |
if (range.getStart() == -1 || end == -1 || end >= length) { | |
return length - 1; | |
} else { | |
return end; | |
} | |
} | |
private ShortResponse checkIfHeaders(Request request, File resource) throws IOException { | |
ShortResponse r = checkIfMatch(request, resource); | |
if (r == null) r = checkIfModifiedSince(request, resource); | |
if (r == null) r = checkIfNoneMatch(request, resource); | |
if (r == null) r = checkIfUnmodifiedSince(request, resource); | |
return r; | |
} | |
private ShortResponse checkIfMatch(Request request, File resource) throws IOException { | |
String headerValue = request.headers.get("If-Match"); | |
if (headerValue != null) { | |
boolean conditionSatisfied; | |
if (!headerValue.equals("*")) { | |
String resourceETag = generateETag(resource); | |
if (resourceETag == null) { | |
conditionSatisfied = false; | |
} else { | |
// RFC 7232 requires strong comparison for If-Match headers | |
Boolean matched = compareEntityTag(new StringReader(headerValue), false, resourceETag); | |
if (matched == null) { | |
return new ShortResponse(400); | |
} | |
conditionSatisfied = matched; | |
} | |
} else { | |
conditionSatisfied = true; | |
} | |
if (!conditionSatisfied) { | |
return new ShortResponse(412); | |
} | |
} | |
return null; | |
} | |
protected ShortResponse checkIfModifiedSince(Request request, File resource) { | |
try { | |
long headerValue = request.headers.getDate("If-Modified-Since"); | |
long lastModified = resource.lastModified(); | |
if (headerValue != -1) { | |
// If an If-None-Match header has been specified, if modified since | |
// is ignored. | |
if ((request.headers.get("If-None-Match") == null) | |
&& (lastModified < headerValue + 1000)) { | |
// The entity has not been modified since the date | |
// specified by the client. This is not an error case. | |
return new ShortResponse(304, Collections.singletonMap("ETag", generateETag(resource))); | |
} | |
} | |
} catch (IllegalArgumentException illegalArgument) { | |
return null; | |
} | |
return null; | |
} | |
protected ShortResponse checkIfNoneMatch(Request request, File resource) throws IOException { | |
String headerValue = request.headers.get("If-None-Match"); | |
if (headerValue != null) { | |
boolean conditionSatisfied; | |
String resourceETag = generateETag(resource); | |
if (!headerValue.equals("*")) { | |
if (resourceETag == null) { | |
conditionSatisfied = false; | |
} else { | |
// RFC 7232 requires weak comparison for If-None-Match headers | |
Boolean matched = compareEntityTag(new StringReader(headerValue), true, resourceETag); | |
if (matched == null) { | |
return new ShortResponse(400); | |
} | |
conditionSatisfied = matched; | |
} | |
} else { | |
conditionSatisfied = true; | |
} | |
if (conditionSatisfied) { | |
// For GET and HEAD, we should respond with | |
// 304 Not Modified. | |
// For every other method, 412 Precondition Failed is sent | |
// back. | |
ShortResponse shortResponse; | |
if ("GET".equals(request.method) || "HEAD".equals(request.method)) { | |
shortResponse = new ShortResponse(304, Collections.singletonMap("ETag", resourceETag)); | |
} else { | |
shortResponse = new ShortResponse(412); | |
} | |
return shortResponse; | |
} | |
} | |
return null; | |
} | |
protected ShortResponse checkIfUnmodifiedSince(Request request, File resource) { | |
try { | |
long lastModified = resource.lastModified(); | |
long headerValue = request.headers.getDate("If-Unmodified-Since"); | |
if (headerValue != -1) { | |
if (lastModified >= (headerValue + 1000)) { | |
return new ShortResponse(412); | |
} | |
} | |
} catch (IllegalArgumentException illegalArgument) { | |
return null; | |
} | |
return null; | |
} | |
private String generateETag(File resource) { | |
long mod = resource.lastModified(); | |
long len = resource.length(); | |
return String.format("\"%x-%x\"", mod, len); | |
} | |
public static Boolean compareEntityTag(StringReader input, boolean compareWeak, String resourceETag) | |
throws IOException { | |
// The resourceETag may be weak so to do weak comparison remove /W | |
// before comparison | |
String comparisonETag; | |
if (compareWeak && resourceETag.startsWith("W/")) { | |
comparisonETag = resourceETag.substring(2); | |
} else { | |
comparisonETag = resourceETag; | |
} | |
Boolean result = Boolean.FALSE; | |
while (true) { | |
boolean strong = false; | |
HttpParser.skipLws(input); | |
switch (HttpParser.skipConstant(input, "W/")) { | |
case EOF: | |
// Empty values are invalid | |
return null; | |
case NOT_FOUND: | |
strong = true; | |
break; | |
case FOUND: | |
strong = false; | |
break; | |
} | |
// Note: RFC 2616 allowed quoted string | |
// RFC 7232 does not allow " in the entity-tag | |
String value = HttpParser.readQuotedString(input); | |
if (value == null) { | |
// Not a quoted string so the header is invalid | |
return null; | |
} | |
if (strong || compareWeak) { | |
if (comparisonETag.equals(value)) { | |
result = Boolean.TRUE; | |
} | |
} | |
HttpParser.skipLws(input); | |
switch (HttpParser.skipConstant(input, ",")) { | |
case EOF: | |
return result; | |
case NOT_FOUND: | |
// Not EOF and not "," so must be invalid | |
return null; | |
case FOUND: | |
// Parse next entry | |
break; | |
} | |
} | |
} | |
private static class ShortResponse { | |
public final int status; | |
public final Headers headers; | |
ShortResponse(int status, Map<String, String> headers) { | |
this.status = status; | |
this.headers = new Headers(headers); | |
} | |
ShortResponse(int status) { | |
this(status, Collections.emptyMap()); | |
} | |
void write(Response response) throws IOException { | |
response.writeHeader(status, headers); | |
} | |
} | |
static class ParseRangeException extends Exception { | |
private final ShortResponse shortResponse; | |
ParseRangeException(ShortResponse shortResponse) { | |
this.shortResponse = shortResponse; | |
} | |
} | |
public static class Headers { | |
private final Map<String, String> map = new HashMap<>(); | |
public Headers() { | |
} | |
public Headers(Map<String, String> headers) { | |
super(); | |
for (String key : headers.keySet()) { | |
this.set(key, headers.get(key)); | |
} | |
} | |
public String get(String key) { | |
return this.map.get(key.toLowerCase()); | |
} | |
public long getDate(String key) { | |
String val = get(key); | |
try { | |
return DateUtils.parseDate(val).getTime(); | |
} catch (IllegalArgumentException e) { | |
return -1; | |
} | |
} | |
public void set(String key, String value) { | |
this.map.put(key.toLowerCase(), value); | |
} | |
public Set<String> keys() { | |
return this.map.keySet(); | |
} | |
@Override | |
public String toString() { | |
StringBuilder sb = new StringBuilder(); | |
for (String key : this.map.keySet()) { | |
sb.append(key).append(": ").append(this.map.get(key)).append("\n"); | |
} | |
return sb.toString().trim(); | |
} | |
} | |
} | |
class DateUtils { | |
/** | |
* Date format pattern used to parse HTTP date headers in RFC 1123 format. | |
*/ | |
public static final String PATTERN_RFC1123 = "EEE, dd MMM yyyy HH:mm:ss zzz"; | |
/** | |
* Date format pattern used to parse HTTP date headers in RFC 1036 format. | |
*/ | |
public static final String PATTERN_RFC1036 = "EEEE, dd-MMM-yy HH:mm:ss zzz"; | |
/** | |
* Date format pattern used to parse HTTP date headers in ANSI C | |
* <code>asctime()</code> format. | |
*/ | |
public static final String PATTERN_ASCTIME = "EEE MMM d HH:mm:ss yyyy"; | |
private static final String[] DEFAULT_PATTERNS = new String[]{ | |
PATTERN_RFC1036, | |
PATTERN_RFC1123, | |
PATTERN_ASCTIME | |
}; | |
private static final Date DEFAULT_TWO_DIGIT_YEAR_START; | |
static { | |
Calendar calendar = Calendar.getInstance(); | |
calendar.setTimeZone(TimeZone.getTimeZone("GMT")); | |
calendar.set(2000, Calendar.JANUARY, 1, 0, 0, 0); | |
calendar.set(Calendar.MILLISECOND, 0); | |
DEFAULT_TWO_DIGIT_YEAR_START = calendar.getTime(); | |
} | |
public static Date parseDate(String dateValue) { | |
return parseDate(dateValue, null, null); | |
} | |
public static Date parseDate(String dateValue, String[] dateFormats, Date startDate) { | |
if (dateValue == null) { | |
throw new IllegalArgumentException("dateValue is null"); | |
} | |
if (dateFormats == null) { | |
dateFormats = DEFAULT_PATTERNS; | |
} | |
if (startDate == null) { | |
startDate = DEFAULT_TWO_DIGIT_YEAR_START; | |
} | |
// trim single quotes around date if present | |
// see issue #5279 | |
if (dateValue.length() > 1 | |
&& dateValue.startsWith("'") | |
&& dateValue.endsWith("'") | |
) { | |
dateValue = dateValue.substring(1, dateValue.length() - 1); | |
} | |
for (String dateFormat : dateFormats) { | |
SimpleDateFormat dateParser = DateFormatHolder.formatFor(dateFormat); | |
dateParser.set2DigitYearStart(startDate); | |
try { | |
return dateParser.parse(dateValue); | |
} catch (ParseException pe) { | |
// ignore this exception, we will try the next format | |
} | |
} | |
// we were unable to parse the date | |
throw new IllegalArgumentException("Unable to parse the date " + dateValue); | |
} | |
public static String formatDate(Date date) { | |
return formatDate(date, PATTERN_RFC1123); | |
} | |
public static String formatDate(Date date, String pattern) { | |
if (date == null) throw new IllegalArgumentException("date is null"); | |
if (pattern == null) throw new IllegalArgumentException("pattern is null"); | |
SimpleDateFormat formatter = DateFormatHolder.formatFor(pattern); | |
return formatter.format(date); | |
} | |
private final static class DateFormatHolder { | |
private static final ThreadLocal<SoftReference<Map<String, SimpleDateFormat>>> | |
THREADLOCAL_FORMATS = ThreadLocal.withInitial(() -> new SoftReference<>( | |
new HashMap<>())); | |
public static SimpleDateFormat formatFor(String pattern) { | |
SoftReference<Map<String, SimpleDateFormat>> ref = THREADLOCAL_FORMATS.get(); | |
Map<String, SimpleDateFormat> formats = ref.get(); | |
if (formats == null) { | |
formats = new HashMap<>(); | |
THREADLOCAL_FORMATS.set(new SoftReference<>(formats)); | |
} | |
SimpleDateFormat format = formats.get(pattern); | |
if (format == null) { | |
format = new SimpleDateFormat(pattern, Locale.US); | |
format.setTimeZone(TimeZone.getTimeZone("GMT")); | |
formats.put(pattern, format); | |
} | |
return format; | |
} | |
} | |
} | |
class HttpParser { | |
public enum SkipResult {FOUND, NOT_FOUND, EOF} | |
private static final int ARRAY_SIZE = 128; | |
private static final boolean[] IS_CONTROL = new boolean[ARRAY_SIZE]; | |
private static final boolean[] IS_SEPARATOR = new boolean[ARRAY_SIZE]; | |
private static final boolean[] IS_TOKEN = new boolean[ARRAY_SIZE]; | |
private static final boolean[] IS_NUMERIC = new boolean[ARRAY_SIZE]; | |
static { | |
for (int i = 0; i < ARRAY_SIZE; i++) { | |
// Control> 0-31, 127 | |
if (i < 32 || i == 127) { | |
IS_CONTROL[i] = true; | |
} | |
// Separator | |
if (i == '(' || i == ')' || i == '<' || i == '>' || i == '@' || | |
i == ',' || i == ';' || i == ':' || i == '\\' || i == '\"' || | |
i == '/' || i == '[' || i == ']' || i == '?' || i == '=' || | |
i == '{' || i == '}' || i == ' ' || i == '\t') { | |
IS_SEPARATOR[i] = true; | |
} | |
// Token: Anything 0-127 that is not a control and not a separator | |
if (!IS_CONTROL[i] && !IS_SEPARATOR[i]) { | |
IS_TOKEN[i] = true; | |
} | |
if (i >= '0' && i <= '9') { | |
IS_NUMERIC[i] = true; | |
} | |
} | |
} | |
static void skipLws(Reader input) throws IOException { | |
input.mark(1); | |
int c = input.read(); | |
while (c == 32 || c == 9 || c == 10 || c == 13) { | |
input.mark(1); | |
c = input.read(); | |
} | |
input.reset(); | |
} | |
static SkipResult skipConstant(Reader input, String constant) throws IOException { | |
int len = constant.length(); | |
skipLws(input); | |
input.mark(len); | |
int c = input.read(); | |
for (int i = 0; i < len; i++) { | |
if (i == 0 && c == -1) { | |
return SkipResult.EOF; | |
} | |
if (c != constant.charAt(i)) { | |
input.reset(); | |
return SkipResult.NOT_FOUND; | |
} | |
if (i != (len - 1)) { | |
c = input.read(); | |
} | |
} | |
return SkipResult.FOUND; | |
} | |
static String readQuotedString(Reader input) throws IOException { | |
skipLws(input); | |
int c = input.read(); | |
if (c != '"') { | |
return null; | |
} | |
StringBuilder result = new StringBuilder(); | |
result.append('\"'); | |
c = input.read(); | |
while (c != '"') { | |
if (c == -1) { | |
return null; | |
} else if (c == '\\') { | |
c = input.read(); | |
result.append('\\'); | |
result.append((char) c); | |
} else { | |
result.append((char) c); | |
} | |
c = input.read(); | |
} | |
result.append('\"'); | |
return result.toString(); | |
} | |
static boolean isToken(int c) { | |
// Fast for correct values, slower for incorrect ones | |
try { | |
return IS_TOKEN[c]; | |
} catch (ArrayIndexOutOfBoundsException ex) { | |
return false; | |
} | |
} | |
static boolean isNumeric(int c) { | |
// Fast for valid numeric characters, slower for some incorrect | |
// ones | |
try { | |
return IS_NUMERIC[c]; | |
} catch (ArrayIndexOutOfBoundsException ex) { | |
return false; | |
} | |
} | |
static String readToken(Reader input) throws IOException { | |
StringBuilder result = new StringBuilder(); | |
skipLws(input); | |
input.mark(1); | |
int c = input.read(); | |
while (c != -1 && isToken(c)) { | |
result.append((char) c); | |
input.mark(1); | |
c = input.read(); | |
} | |
// Use mark(1)/reset() rather than skip(-1) since skip() is a NOP | |
// once the end of the String has been reached. | |
input.reset(); | |
if (c != -1 && result.length() == 0) { | |
return null; | |
} else { | |
return result.toString(); | |
} | |
} | |
static String readDigits(Reader input) throws IOException { | |
StringBuilder result = new StringBuilder(); | |
skipLws(input); | |
input.mark(1); | |
int c = input.read(); | |
while (c != -1 && isNumeric(c)) { | |
result.append((char) c); | |
input.mark(1); | |
c = input.read(); | |
} | |
// Use mark(1)/reset() rather than skip(-1) since skip() is a NOP | |
// once the end of the String has been reached. | |
input.reset(); | |
return result.toString(); | |
} | |
public static long readLong(Reader input) throws IOException { | |
String digits = readDigits(input); | |
if (digits.length() == 0) { | |
return -1; | |
} | |
return Long.parseLong(digits); | |
} | |
} | |
class Ranges { | |
public static final Ranges FULL = new Ranges(null, new ArrayList<>()); | |
private final String units; | |
private final List<Entry> entries; | |
public Ranges(String units, List<Entry> entries) { | |
// Units are lower case (RFC 9110, section 14.1) | |
if (units == null) { | |
this.units = null; | |
} else { | |
this.units = units.toLowerCase(Locale.ENGLISH); | |
} | |
this.entries = Collections.unmodifiableList(entries); | |
} | |
public List<Entry> getEntries() { | |
return entries; | |
} | |
public String getUnits() { | |
return units; | |
} | |
public static class Entry { | |
private final long start; | |
private final long end; | |
public Entry(long start, long end) { | |
this.start = start; | |
this.end = end; | |
} | |
public long getStart() { | |
return start; | |
} | |
public long getEnd() { | |
return end; | |
} | |
} | |
/** | |
* Parses a Range header from an HTTP header. | |
* | |
* @param input a reader over the header text | |
* @return a set of ranges parsed from the input, or null if not valid | |
* @throws IOException if there was a problem reading the input | |
*/ | |
public static Ranges parse(StringReader input) throws IOException { | |
// Units (required) | |
String units = HttpParser.readToken(input); | |
if (units == null || units.length() == 0) { | |
return null; | |
} | |
// Must be followed by '=' | |
if (HttpParser.skipConstant(input, "=") != HttpParser.SkipResult.FOUND) { | |
return null; | |
} | |
// Range entries | |
List<Entry> entries = new ArrayList<>(); | |
HttpParser.SkipResult skipResult; | |
do { | |
long start = HttpParser.readLong(input); | |
// Must be followed by '-' | |
if (HttpParser.skipConstant(input, "-") != HttpParser.SkipResult.FOUND) { | |
return null; | |
} | |
long end = HttpParser.readLong(input); | |
if (start == -1 && end == -1) { | |
// Invalid range | |
return null; | |
} | |
entries.add(new Entry(start, end)); | |
skipResult = HttpParser.skipConstant(input, ","); | |
if (skipResult == HttpParser.SkipResult.NOT_FOUND) { | |
// Invalid range | |
return null; | |
} | |
} while (skipResult == HttpParser.SkipResult.FOUND); | |
return new Ranges(units, entries); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package me.devld; | |
import javax.servlet.http.HttpServlet; | |
import javax.servlet.http.HttpServletRequest; | |
import javax.servlet.http.HttpServletResponse; | |
import java.io.File; | |
import java.io.IOException; | |
public class Usage extends HttpServlet { | |
private final HttpServeFile serveFile; | |
public Usage() { | |
this.serveFile = new HttpServeFile(); | |
} | |
@Override | |
protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { | |
File file = new File("root", req.getRequestURI()); | |
serveFile.serve( | |
HttpServeFile.Request.from(req, file, "application/octet-stream"), | |
new HttpServeFile.ServletResponse(resp) | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment