|
|
@@ -0,0 +1,351 @@ |
|
|
package lan.dk.podcastserver.utils.multipart; |
|
|
|
|
|
import lan.dk.podcastserver.utils.MimeTypeUtils; |
|
|
import org.apache.commons.lang3.StringUtils; |
|
|
import org.slf4j.Logger; |
|
|
import org.slf4j.LoggerFactory; |
|
|
|
|
|
import javax.servlet.ServletOutputStream; |
|
|
import javax.servlet.http.HttpServletRequest; |
|
|
import javax.servlet.http.HttpServletResponse; |
|
|
import java.io.*; |
|
|
import java.nio.file.Files; |
|
|
import java.nio.file.Path; |
|
|
import java.nio.file.Paths; |
|
|
import java.nio.file.attribute.FileTime; |
|
|
import java.time.LocalDateTime; |
|
|
import java.time.ZoneId; |
|
|
import java.time.ZoneOffset; |
|
|
import java.util.ArrayList; |
|
|
import java.util.Arrays; |
|
|
import java.util.List; |
|
|
|
|
|
/** |
|
|
* Created by kevin on 10/02/15. |
|
|
* See full code here : https://github.com/davinkevin/Podcast-Server/blob/d927d9b8cb9ea1268af74316cd20b7192ca92da7/src/main/java/lan/dk/podcastserver/utils/multipart/MultipartFileSender.java |
|
|
*/ |
|
|
public class MultipartFileSender { |
|
|
|
|
|
protected final Logger logger = LoggerFactory.getLogger(this.getClass()); |
|
|
|
|
|
private static final int DEFAULT_BUFFER_SIZE = 20480; // ..bytes = 20KB. |
|
|
private static final long DEFAULT_EXPIRE_TIME = 604800000L; // ..ms = 1 week. |
|
|
private static final String MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES"; |
|
|
|
|
|
Path filepath; |
|
|
HttpServletRequest request; |
|
|
HttpServletResponse response; |
|
|
|
|
|
public MultipartFileSender() { |
|
|
} |
|
|
|
|
|
public static MultipartFileSender fromPath(Path path) { |
|
|
return new MultipartFileSender().setFilepath(path); |
|
|
} |
|
|
|
|
|
public static MultipartFileSender fromFile(File file) { |
|
|
return new MultipartFileSender().setFilepath(file.toPath()); |
|
|
} |
|
|
|
|
|
public static MultipartFileSender fromURIString(String uri) { |
|
|
return new MultipartFileSender().setFilepath(Paths.get(uri)); |
|
|
} |
|
|
|
|
|
//** internal setter **// |
|
|
private MultipartFileSender setFilepath(Path filepath) { |
|
|
this.filepath = filepath; |
|
|
return this; |
|
|
} |
|
|
|
|
|
public MultipartFileSender with(HttpServletRequest httpRequest) { |
|
|
request = httpRequest; |
|
|
return this; |
|
|
} |
|
|
|
|
|
public MultipartFileSender with(HttpServletResponse httpResponse) { |
|
|
response = httpResponse; |
|
|
return this; |
|
|
} |
|
|
|
|
|
public void serveResource() throws Exception { |
|
|
if (response == null || request == null) { |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!Files.exists(filepath)) { |
|
|
logger.error("File doesn't exist at URI : {}", filepath.toAbsolutePath().toString()); |
|
|
response.sendError(HttpServletResponse.SC_NOT_FOUND); |
|
|
return; |
|
|
} |
|
|
|
|
|
Long length = Files.size(filepath); |
|
|
String fileName = filepath.getFileName().toString(); |
|
|
FileTime lastModifiedObj = Files.getLastModifiedTime(filepath); |
|
|
|
|
|
if (StringUtils.isEmpty(fileName) || lastModifiedObj == null) { |
|
|
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); |
|
|
return; |
|
|
} |
|
|
long lastModified = LocalDateTime.ofInstant(lastModifiedObj.toInstant(), ZoneId.of(ZoneOffset.systemDefault().getId())).toEpochSecond(ZoneOffset.UTC); |
|
|
String contentType = MimeTypeUtils.probeContentType(filepath); |
|
|
|
|
|
// Validate request headers for caching --------------------------------------------------- |
|
|
|
|
|
// If-None-Match header should contain "*" or ETag. If so, then return 304. |
|
|
String ifNoneMatch = request.getHeader("If-None-Match"); |
|
|
if (ifNoneMatch != null && HttpUtils.matches(ifNoneMatch, fileName)) { |
|
|
response.setHeader("ETag", fileName); // Required in 304. |
|
|
response.sendError(HttpServletResponse.SC_NOT_MODIFIED); |
|
|
return; |
|
|
} |
|
|
|
|
|
// If-Modified-Since header should be greater than LastModified. If so, then return 304. |
|
|
// This header is ignored if any If-None-Match header is specified. |
|
|
long ifModifiedSince = request.getDateHeader("If-Modified-Since"); |
|
|
if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) { |
|
|
response.setHeader("ETag", fileName); // Required in 304. |
|
|
response.sendError(HttpServletResponse.SC_NOT_MODIFIED); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Validate request headers for resume ---------------------------------------------------- |
|
|
|
|
|
// If-Match header should contain "*" or ETag. If not, then return 412. |
|
|
String ifMatch = request.getHeader("If-Match"); |
|
|
if (ifMatch != null && !HttpUtils.matches(ifMatch, fileName)) { |
|
|
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); |
|
|
return; |
|
|
} |
|
|
|
|
|
// If-Unmodified-Since header should be greater than LastModified. If not, then return 412. |
|
|
long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since"); |
|
|
if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) { |
|
|
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Validate and process range ------------------------------------------------------------- |
|
|
|
|
|
// Prepare some variables. The full Range represents the complete file. |
|
|
Range full = new Range(0, length - 1, length); |
|
|
List<Range> ranges = new ArrayList<>(); |
|
|
|
|
|
// Validate and process Range and If-Range headers. |
|
|
String range = request.getHeader("Range"); |
|
|
if (range != null) { |
|
|
|
|
|
// Range header should match format "bytes=n-n,n-n,n-n...". If not, then return 416. |
|
|
if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) { |
|
|
response.setHeader("Content-Range", "bytes */" + length); // Required in 416. |
|
|
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); |
|
|
return; |
|
|
} |
|
|
|
|
|
String ifRange = request.getHeader("If-Range"); |
|
|
if (ifRange != null && !ifRange.equals(fileName)) { |
|
|
try { |
|
|
long ifRangeTime = request.getDateHeader("If-Range"); // Throws IAE if invalid. |
|
|
if (ifRangeTime != -1) { |
|
|
ranges.add(full); |
|
|
} |
|
|
} catch (IllegalArgumentException ignore) { |
|
|
ranges.add(full); |
|
|
} |
|
|
} |
|
|
|
|
|
// If any valid If-Range header, then process each part of byte range. |
|
|
if (ranges.isEmpty()) { |
|
|
for (String part : range.substring(6).split(",")) { |
|
|
// Assuming a file with length of 100, the following examples returns bytes at: |
|
|
// 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100). |
|
|
long start = Range.sublong(part, 0, part.indexOf("-")); |
|
|
long end = Range.sublong(part, part.indexOf("-") + 1, part.length()); |
|
|
|
|
|
if (start == -1) { |
|
|
start = length - end; |
|
|
end = length - 1; |
|
|
} else if (end == -1 || end > length - 1) { |
|
|
end = length - 1; |
|
|
} |
|
|
|
|
|
// Check if Range is syntactically valid. If not, then return 416. |
|
|
if (start > end) { |
|
|
response.setHeader("Content-Range", "bytes */" + length); // Required in 416. |
|
|
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Add range. |
|
|
ranges.add(new Range(start, end, length)); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// Prepare and initialize response -------------------------------------------------------- |
|
|
|
|
|
// Get content type by file name and set content disposition. |
|
|
String disposition = "inline"; |
|
|
|
|
|
// If content type is unknown, then set the default value. |
|
|
// For all content types, see: http://www.w3schools.com/media/media_mimeref.asp |
|
|
// To add new content types, add new mime-mapping entry in web.xml. |
|
|
if (contentType == null) { |
|
|
contentType = "application/octet-stream"; |
|
|
} else if (!contentType.startsWith("image")) { |
|
|
// Else, expect for images, determine content disposition. If content type is supported by |
|
|
// the browser, then set to inline, else attachment which will pop a 'save as' dialogue. |
|
|
String accept = request.getHeader("Accept"); |
|
|
disposition = accept != null && HttpUtils.accepts(accept, contentType) ? "inline" : "attachment"; |
|
|
} |
|
|
logger.debug("Content-Type : {}", contentType); |
|
|
// Initialize response. |
|
|
response.reset(); |
|
|
response.setBufferSize(DEFAULT_BUFFER_SIZE); |
|
|
response.setHeader("Content-Type", contentType); |
|
|
response.setHeader("Content-Disposition", disposition + ";filename=\"" + fileName + "\""); |
|
|
logger.debug("Content-Disposition : {}", disposition); |
|
|
response.setHeader("Accept-Ranges", "bytes"); |
|
|
response.setHeader("ETag", fileName); |
|
|
response.setDateHeader("Last-Modified", lastModified); |
|
|
response.setDateHeader("Expires", System.currentTimeMillis() + DEFAULT_EXPIRE_TIME); |
|
|
|
|
|
// Send requested file (part(s)) to client ------------------------------------------------ |
|
|
|
|
|
// Prepare streams. |
|
|
try (InputStream input = new BufferedInputStream(Files.newInputStream(filepath)); |
|
|
OutputStream output = response.getOutputStream()) { |
|
|
|
|
|
if (ranges.isEmpty() || ranges.get(0) == full) { |
|
|
|
|
|
// Return full file. |
|
|
logger.info("Return full file"); |
|
|
response.setContentType(contentType); |
|
|
response.setHeader("Content-Range", "bytes " + full.start + "-" + full.end + "/" + full.total); |
|
|
response.setHeader("Content-Length", String.valueOf(full.length)); |
|
|
Range.copy(input, output, length, full.start, full.length); |
|
|
|
|
|
} else if (ranges.size() == 1) { |
|
|
|
|
|
// Return single part of file. |
|
|
Range r = ranges.get(0); |
|
|
logger.info("Return 1 part of file : from ({}) to ({})", r.start, r.end); |
|
|
response.setContentType(contentType); |
|
|
response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total); |
|
|
response.setHeader("Content-Length", String.valueOf(r.length)); |
|
|
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206. |
|
|
|
|
|
// Copy single part range. |
|
|
Range.copy(input, output, length, r.start, r.length); |
|
|
|
|
|
} else { |
|
|
|
|
|
// Return multiple parts of file. |
|
|
response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY); |
|
|
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206. |
|
|
|
|
|
// Cast back to ServletOutputStream to get the easy println methods. |
|
|
ServletOutputStream sos = (ServletOutputStream) output; |
|
|
|
|
|
// Copy multi part range. |
|
|
for (Range r : ranges) { |
|
|
logger.info("Return multi part of file : from ({}) to ({})", r.start, r.end); |
|
|
// Add multipart boundary and header fields for every range. |
|
|
sos.println(); |
|
|
sos.println("--" + MULTIPART_BOUNDARY); |
|
|
sos.println("Content-Type: " + contentType); |
|
|
sos.println("Content-Range: bytes " + r.start + "-" + r.end + "/" + r.total); |
|
|
|
|
|
// Copy single part range of multi part range. |
|
|
Range.copy(input, output, length, r.start, r.length); |
|
|
} |
|
|
|
|
|
// End with multipart boundary. |
|
|
sos.println(); |
|
|
sos.println("--" + MULTIPART_BOUNDARY + "--"); |
|
|
} |
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
private static class Range { |
|
|
long start; |
|
|
long end; |
|
|
long length; |
|
|
long total; |
|
|
|
|
|
/** |
|
|
* Construct a byte range. |
|
|
* @param start Start of the byte range. |
|
|
* @param end End of the byte range. |
|
|
* @param total Total length of the byte source. |
|
|
*/ |
|
|
public Range(long start, long end, long total) { |
|
|
this.start = start; |
|
|
this.end = end; |
|
|
this.length = end - start + 1; |
|
|
this.total = total; |
|
|
} |
|
|
|
|
|
public static long sublong(String value, int beginIndex, int endIndex) { |
|
|
String substring = value.substring(beginIndex, endIndex); |
|
|
return (substring.length() > 0) ? Long.parseLong(substring) : -1; |
|
|
} |
|
|
|
|
|
private static void copy(InputStream input, OutputStream output, long inputSize, long start, long length) throws IOException { |
|
|
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; |
|
|
int read; |
|
|
|
|
|
if (inputSize == length) { |
|
|
// Write full range. |
|
|
while ((read = input.read(buffer)) > 0) { |
|
|
output.write(buffer, 0, read); |
|
|
output.flush(); |
|
|
} |
|
|
} else { |
|
|
input.skip(start); |
|
|
long toRead = length; |
|
|
|
|
|
while ((read = input.read(buffer)) > 0) { |
|
|
if ((toRead -= read) > 0) { |
|
|
output.write(buffer, 0, read); |
|
|
output.flush(); |
|
|
} else { |
|
|
output.write(buffer, 0, (int) toRead + read); |
|
|
output.flush(); |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
private static class HttpUtils { |
|
|
|
|
|
/** |
|
|
* Returns true if the given accept header accepts the given value. |
|
|
* @param acceptHeader The accept header. |
|
|
* @param toAccept The value to be accepted. |
|
|
* @return True if the given accept header accepts the given value. |
|
|
*/ |
|
|
public 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; |
|
|
} |
|
|
|
|
|
/** |
|
|
* Returns true if the given match header matches the given value. |
|
|
* @param matchHeader The match header. |
|
|
* @param toMatch The value to be matched. |
|
|
* @return True if the given match header matches the given value. |
|
|
*/ |
|
|
public static boolean matches(String matchHeader, String toMatch) { |
|
|
String[] matchValues = matchHeader.split("\\s*,\\s*"); |
|
|
Arrays.sort(matchValues); |
|
|
return Arrays.binarySearch(matchValues, toMatch) > -1 |
|
|
|| Arrays.binarySearch(matchValues, "*") > -1; |
|
|
} |
|
|
} |
|
|
} |