Skip to content

Instantly share code, notes, and snippets.

@davinkevin
Created February 10, 2015 16:31
Show Gist options
  • Select an option

  • Save davinkevin/b97e39d7ce89198774b4 to your computer and use it in GitHub Desktop.

Select an option

Save davinkevin/b97e39d7ce89198774b4 to your computer and use it in GitHub Desktop.

Revisions

  1. davinkevin created this gist Feb 10, 2015.
    351 changes: 351 additions & 0 deletions MultipartFileSender
    Original file line number Diff line number Diff line change
    @@ -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;
    }
    }
    }
    53 changes: 53 additions & 0 deletions gistfile1.java
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,53 @@
    package lan.dk.podcastserver.controller.api;

    import lan.dk.podcastserver.business.ItemBusiness;
    import lan.dk.podcastserver.entity.Item;
    import lan.dk.podcastserver.exception.PodcastNotFoundException;
    import lan.dk.podcastserver.manager.ItemDownloadManager;
    import lan.dk.podcastserver.utils.facade.PageRequestFacade;
    import lan.dk.podcastserver.utils.multipart.MultipartFileSender;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.domain.Page;
    import org.springframework.http.HttpStatus;
    import org.springframework.web.bind.annotation.*;
    import org.springframework.web.multipart.MultipartFile;

    import javax.annotation.Resource;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.net.URISyntaxException;
    import java.text.ParseException;

    /**
    * Created by kevin on 26/12/2013.
    * See full code here : https://github.com/davinkevin/Podcast-Server/blob/d927d9b8cb9ea1268af74316cd20b7192ca92da7/src/main/java/lan/dk/podcastserver/controller/api/ItemController.java
    */
    @RestController
    @RequestMapping("/api/podcast/{idPodcast}/items")
    public class ItemController {

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Resource private ItemBusiness itemBusiness;

    @Autowired
    protected ItemDownloadManager itemDownloadManager;

    @RequestMapping(value="{id:[\\d]+}/download{ext}", method = RequestMethod.GET)
    public void getEpisodeFile(@PathVariable Integer id, HttpServletRequest request, HttpServletResponse response) throws Exception {
    logger.debug("Download du fichier d'item {}", id);
    Item item = itemBusiness.findOne(id);
    if (item.isDownloaded()) {
    logger.debug("Récupération en local de l'item {} au chemin {}", id, item.getLocalUri());
    MultipartFileSender.fromPath(item.getLocalPath())
    .with(request)
    .with(response)
    .serveResource();
    } else {
    response.sendRedirect(item.getUrl());
    }
    }
    }