View Javadoc
1   /*
2    * This file is part of ***  M y C o R e  ***
3    * See http://www.mycore.de/ for details.
4    *
5    * MyCoRe is free software: you can redistribute it and/or modify
6    * it under the terms of the GNU General Public License as published by
7    * the Free Software Foundation, either version 3 of the License, or
8    * (at your option) any later version.
9    *
10   * MyCoRe is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with MyCoRe.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  package org.mycore.common.content.util;
19  
20  import java.io.IOException;
21  import java.util.Date;
22  import java.util.HashMap;
23  import java.util.Iterator;
24  import java.util.List;
25  import java.util.Map;
26  import java.util.Optional;
27  import java.util.stream.Collectors;
28  import java.util.stream.Stream;
29  
30  import org.apache.logging.log4j.LogManager;
31  import org.apache.logging.log4j.Logger;
32  import org.mycore.common.content.MCRContent;
33  
34  import com.google.common.collect.Iterables;
35  
36  import jakarta.ws.rs.NotFoundException;
37  import jakarta.ws.rs.WebApplicationException;
38  import jakarta.ws.rs.core.HttpHeaders;
39  import jakarta.ws.rs.core.MediaType;
40  import jakarta.ws.rs.core.Response;
41  import jakarta.ws.rs.core.StreamingOutput;
42  import jakarta.ws.rs.core.UriInfo;
43  import jakarta.ws.rs.ext.RuntimeDelegate;
44  
45  /**
46   * @author Thomas Scheffler (yagee)
47   */
48  public abstract class MCRRestContentHelper {
49  
50      public static final RuntimeDelegate.HeaderDelegate<Date> DATE_HEADER_DELEGATE = RuntimeDelegate.getInstance()
51          .createHeaderDelegate(Date.class);
52  
53      private static Logger LOGGER = LogManager.getLogger();
54  
55      public static Response serveContent(final MCRContent content, final UriInfo uriInfo,
56          final HttpHeaders requestHeader, List<Map.Entry<String, String>> responseHeader)
57          throws IOException {
58          return serveContent(content, uriInfo, requestHeader, responseHeader, new Config());
59      }
60  
61      public static Response serveContent(final MCRContent content, final UriInfo uriInfo,
62          final HttpHeaders requestHeader, final List<Map.Entry<String, String>> responseHeader, final Config config)
63          throws IOException {
64  
65          if (content == null) {
66              throw new NotFoundException();
67          }
68  
69          // Find content type.
70          MediaType contentType = getMediaType(content);
71  
72          Response.ResponseBuilder response = Response.ok();
73  
74          String eTag = content.getETag();
75          response.header(HttpHeaders.ETAG, eTag);
76          responseHeader.forEach(e -> response.header(e.getKey(), e.getValue()));
77          final long contentLength = content.length();
78          if (contentLength == 0) {
79              //No Content to serve?
80              return response.status(Response.Status.NO_CONTENT).build();
81          }
82          long lastModified = content.lastModified();
83          if (lastModified >= 0) {
84              response.lastModified(new Date(lastModified));
85          }
86  
87          List<Range> ranges = null;
88          if (config.useAcceptRanges) {
89              response.header("Accept-Ranges", "bytes");
90              ranges = parseRange(requestHeader, lastModified, eTag, contentLength);
91              String varyHeader = Stream.of("Range", "If-Range")
92                  .filter(h -> requestHeader.getHeaderString(h) != null)
93                  .collect(Collectors.joining(","));
94              if (!varyHeader.isEmpty()) {
95                  response.header(HttpHeaders.VARY, varyHeader);
96              }
97          }
98  
99          String filename = Optional.of(content.getName())
100             .orElseGet(() -> Iterables.getLast(uriInfo.getPathSegments()).getPath());
101         response.header(HttpHeaders.CONTENT_DISPOSITION,
102             config.dispositionType.name() + ";filename=\"" + filename + "\"");
103 
104         boolean noRangeRequest = ranges == null || ranges == ContentUtils.FULL;
105         if (noRangeRequest) {
106             LOGGER.debug("contentType='{}'", contentType);
107             LOGGER.debug("contentLength={}", contentLength);
108             response.type(contentType);
109             response.header(HttpHeaders.CONTENT_LENGTH, contentLength);
110             response.entity(
111                 (StreamingOutput) out -> ContentUtils.copy(content, out, config.inputBufferSize,
112                     config.outputBufferSize));
113 
114         } else if (ranges.isEmpty()) {
115             return response.status(Response.Status.NO_CONTENT).build();
116         } else {
117             // Partial content response.
118             response.status(Response.Status.PARTIAL_CONTENT);
119 
120             if (ranges.size() == 1) {
121                 final Range range = ranges.get(0);
122                 response.header("Content-Range", "bytes " + range.start + "-" + range.end + "/" + range.length);
123                 final long length = range.end - range.start + 1;
124                 response.header(HttpHeaders.CONTENT_LENGTH, length);
125 
126                 LOGGER.debug("contentType='{}'", contentType);
127                 response.type(contentType);
128 
129                 response.entity(
130                     (StreamingOutput) out -> ContentUtils.copy(content, out, range, config.inputBufferSize,
131                         config.outputBufferSize));
132             } else {
133                 response.type("multipart/byteranges; boundary=" + ContentUtils.MIME_BOUNDARY);
134                 Iterator<Range> rangeIterator = ranges.iterator();
135                 String ct = contentType.toString();
136                 response.entity(
137                     (StreamingOutput) out -> ContentUtils.copy(content, out, rangeIterator, ct,
138                         config.inputBufferSize,
139                         config.outputBufferSize));
140             }
141         }
142         return response.build();
143     }
144 
145     private static MediaType getMediaType(MCRContent content) throws IOException {
146         String mimeType = content.getMimeType();
147         if (mimeType == null) {
148             mimeType = MediaType.APPLICATION_OCTET_STREAM;
149         }
150         MediaType contentType = MediaType.valueOf(mimeType);
151         String enc = content.getEncoding();
152         if (enc != null) {
153             HashMap<String, String> param = new HashMap<>(contentType.getParameters());
154             param.put(MediaType.CHARSET_PARAMETER, enc);
155             contentType = new MediaType(contentType.getType(), contentType.getSubtype(), param);
156         }
157         return contentType;
158     }
159 
160     /**
161      * Parses and validates the range header.
162      * This method ensures that all ranges are in ascending order and non-overlapping, so we can use a single
163      * InputStream.
164      */
165     private static List<Range> parseRange(HttpHeaders headers, long lastModified, String eTag, long contentLength) {
166 
167         // Checking if range is still valid (lastModified, ETag)
168         String ifRangeHeader = headers.getHeaderString("If-Range");
169         if (ifRangeHeader != null) {
170             long headerValueTime = -1L;
171             try {
172                 headerValueTime = DATE_HEADER_DELEGATE.fromString(ifRangeHeader).getTime();
173             } catch (final IllegalArgumentException e) {
174                 // Ignore
175             }
176 
177             if (headerValueTime == -1L) {
178                 // If the content changed, the complete content is served.
179                 if (!eTag.equals(ifRangeHeader.trim())) {
180                     return ContentUtils.FULL;
181                 }
182             } else {
183                 //add one second buffer to check if the content was modified.
184                 if (lastModified > headerValueTime + 1000) {
185                     return ContentUtils.FULL;
186                 }
187             }
188 
189         }
190         String rangeHeader = headers.getHeaderString("Range");
191         try {
192             return Range.parseRanges(rangeHeader, contentLength);
193         } catch (IllegalArgumentException e) {
194             Response errResponse = Response.status(Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE)
195                 .header("Content-Range", "bytes */" + contentLength).build();
196             throw new WebApplicationException(errResponse);
197         }
198     }
199 
200     public enum ContentDispositionType {
201         inline, attachment
202     }
203 
204     public static class Config {
205 
206         public ContentDispositionType dispositionType = ContentDispositionType.attachment;
207 
208         public boolean useAcceptRanges = true;
209 
210         public int inputBufferSize = ContentUtils.DEFAULT_BUFFER_SIZE;
211 
212         public int outputBufferSize = ContentUtils.DEFAULT_BUFFER_SIZE;
213 
214     }
215 
216 }