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.io.InputStream;
22  import java.io.OutputStream;
23  import java.text.MessageFormat;
24  import java.util.List;
25  import java.util.Locale;
26  import java.util.StringTokenizer;
27  
28  import org.apache.logging.log4j.LogManager;
29  import org.apache.logging.log4j.Logger;
30  import org.mycore.common.content.MCRContent;
31  
32  import jakarta.servlet.ServletConfig;
33  import jakarta.servlet.ServletContext;
34  import jakarta.servlet.ServletOutputStream;
35  import jakarta.servlet.ServletResponseWrapper;
36  import jakarta.servlet.http.HttpServletRequest;
37  import jakarta.servlet.http.HttpServletResponse;
38  
39  /**
40   * @author Thomas Scheffler (yagee)
41   * @author Matthias Eichner
42   */
43  public abstract class MCRServletContentHelper {
44  
45      public static final int DEFAULT_BUFFER_SIZE = ContentUtils.DEFAULT_BUFFER_SIZE;
46  
47      public static final String ATT_SERVE_CONTENT = MCRServletContentHelper.class.getName() + ".serveContent";
48  
49      private static Logger LOGGER = LogManager.getLogger(MCRServletContentHelper.class);
50  
51      public static Config buildConfig(ServletConfig servletConfig) {
52          Config config = new Config();
53  
54          if (servletConfig.getInitParameter("inputBufferSize") != null) {
55              config.inputBufferSize = Integer.parseInt(servletConfig.getInitParameter("inputBufferSize"));
56          }
57  
58          if (servletConfig.getInitParameter("outputBufferSize") != null) {
59              config.outputBufferSize = Integer.parseInt(servletConfig.getInitParameter("outputBufferSize"));
60          }
61  
62          if (servletConfig.getInitParameter("useAcceptRanges") != null) {
63              config.useAcceptRanges = Boolean.parseBoolean(servletConfig.getInitParameter("useAcceptRanges"));
64          }
65  
66          if (config.inputBufferSize < ContentUtils.MIN_BUFFER_SIZE) {
67              config.inputBufferSize = ContentUtils.MIN_BUFFER_SIZE;
68          }
69          if (config.outputBufferSize < ContentUtils.MIN_BUFFER_SIZE) {
70              config.outputBufferSize = ContentUtils.MIN_BUFFER_SIZE;
71          }
72          return config;
73      }
74  
75      public static boolean isServeContent(final HttpServletRequest request) {
76          return request.getAttribute(ATT_SERVE_CONTENT) != Boolean.FALSE;
77      }
78  
79      /**
80       * Serve the specified content, optionally including the data content.
81       * This method handles both GET and HEAD requests.
82       */
83      public static void serveContent(final MCRContent content, final HttpServletRequest request,
84          final HttpServletResponse response, final ServletContext context) throws IOException {
85          serveContent(content, request, response, context, new Config(), isServeContent(request));
86      }
87  
88      /**
89       * Serve the specified content, optionally including the data content.
90       * This method handles both GET and HEAD requests.
91       */
92      public static void serveContent(final MCRContent content, final HttpServletRequest request,
93          final HttpServletResponse response, final ServletContext context, final Config config,
94          final boolean withContent)
95          throws IOException {
96  
97          boolean serveContent = withContent;
98  
99          final String path = getRequestPath(request);
100         if (LOGGER.isDebugEnabled()) {
101             if (serveContent) {
102                 LOGGER.debug("Serving '{}' headers and data", path);
103             } else {
104                 LOGGER.debug("Serving '{}' headers only", path);
105             }
106         }
107 
108         if (response.isCommitted()) {
109             //getContent has access to response
110             return;
111         }
112 
113         final boolean isError = response.getStatus() >= HttpServletResponse.SC_BAD_REQUEST;
114 
115         if (content == null && !isError) {
116             response.sendError(HttpServletResponse.SC_NOT_FOUND, request.getRequestURI());
117             return;
118         }
119 
120         //Check if all conditional header validate
121         if (!isError && !checkIfHeaders(request, response, content)) {
122             return;
123         }
124 
125         // Find content type.
126         String contentType = content.getMimeType();
127         final String filename = getFileName(request, content);
128         if (contentType == null) {
129             contentType = context.getMimeType(filename);
130             content.setMimeType(contentType);
131         }
132         String enc = content.getEncoding();
133         if (enc != null) {
134             contentType = String.format(Locale.ROOT, "%s; charset=%s", contentType, enc);
135         }
136 
137         String eTag = null;
138         List<Range> ranges = null;
139         if (!isError) {
140             eTag = content.getETag();
141             if (config.useAcceptRanges) {
142                 response.setHeader("Accept-Ranges", "bytes");
143             }
144 
145             ranges = parseRange(request, response, content);
146 
147             response.setHeader("ETag", eTag);
148 
149             long lastModified = content.lastModified();
150             if (lastModified >= 0) {
151                 response.setDateHeader("Last-Modified", lastModified);
152             }
153             if (serveContent) {
154                 String dispositionType = request.getParameter("dl") == null ? "inline" : "attachment";
155                 response.setHeader("Content-Disposition", dispositionType + ";filename=\"" + filename + "\"");
156             }
157         }
158 
159         final long contentLength = content.length();
160         //No Content to serve?
161         if (contentLength == 0) {
162             serveContent = false;
163         }
164 
165         if (content.isUsingSession()) {
166             response.addHeader("Cache-Control", "private, max-age=0, must-revalidate");
167             response.addHeader("Vary", "*");
168         }
169 
170         try (ServletOutputStream out = serveContent ? response.getOutputStream() : null) {
171             if (serveContent) {
172                 try {
173                     response.setBufferSize(config.outputBufferSize);
174                 } catch (final IllegalStateException e) {
175                     //does not matter if we fail
176                 }
177             }
178             if (response instanceof ServletResponseWrapper) {
179                 if (request.getHeader("Range") != null) {
180                     LOGGER.warn("Response is wrapped by ServletResponseWrapper, no 'Range' requests supported.");
181                 }
182                 ranges = ContentUtils.FULL;
183             }
184 
185             if (isError || (ranges == null || ranges.isEmpty()) && request.getHeader("Range") == null
186                 || ranges == ContentUtils.FULL) {
187                 //No ranges
188                 if (contentType != null) {
189                     if (LOGGER.isDebugEnabled()) {
190                         LOGGER.debug("contentType='{}'", contentType);
191                     }
192                     response.setContentType(contentType);
193                 }
194                 if (contentLength >= 0) {
195                     if (LOGGER.isDebugEnabled()) {
196                         LOGGER.debug("contentLength={}", contentLength);
197                     }
198                     setContentLengthLong(response, contentLength);
199                 }
200 
201                 if (serveContent) {
202                     ContentUtils.copy(content, out, config.inputBufferSize, config.outputBufferSize);
203                 }
204 
205             } else {
206 
207                 if (ranges == null || ranges.isEmpty()) {
208                     return;
209                 }
210 
211                 // Partial content response.
212 
213                 response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
214 
215                 if (ranges.size() == 1) {
216 
217                     final Range range = ranges.get(0);
218                     response.addHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + range.length);
219                     final long length = range.end - range.start + 1;
220                     setContentLengthLong(response, length);
221 
222                     if (contentType != null) {
223                         if (LOGGER.isDebugEnabled()) {
224                             LOGGER.debug("contentType='{}'", contentType);
225                         }
226                         response.setContentType(contentType);
227                     }
228 
229                     if (serveContent) {
230                         ContentUtils.copy(content, out, range, config.inputBufferSize, config.outputBufferSize);
231                     }
232 
233                 } else {
234 
235                     response.setContentType("multipart/byteranges; boundary=" + ContentUtils.MIME_BOUNDARY);
236 
237                     if (serveContent) {
238                         ContentUtils.copy(content, out, ranges.iterator(), contentType, config.inputBufferSize,
239                             config.outputBufferSize);
240                     }
241                 }
242             }
243         }
244     }
245 
246     /**
247      * Check if all conditions specified in the If headers are
248      * satisfied.
249      */
250     private static boolean checkIfHeaders(final HttpServletRequest request, final HttpServletResponse response,
251         final MCRContent content) throws IOException {
252 
253         return checkIfMatch(request, response, content) && checkIfModifiedSince(request, response, content)
254             && checkIfNoneMatch(request, response, content) && checkIfUnmodifiedSince(request, response, content);
255 
256     }
257 
258     /**
259      * Check if the If-Match condition is satisfied.
260      */
261     private static boolean checkIfMatch(final HttpServletRequest request, final HttpServletResponse response,
262         final MCRContent content) throws IOException {
263 
264         final String eTag = content.getETag();
265         final String headerValue = request.getHeader("If-Match");
266         if (headerValue != null) {
267             if (headerValue.indexOf('*') == -1) {
268 
269                 final StringTokenizer commaTokenizer = new StringTokenizer(headerValue, ",");
270                 boolean conditionSatisfied = false;
271 
272                 while (!conditionSatisfied && commaTokenizer.hasMoreTokens()) {
273                     final String currentToken = commaTokenizer.nextToken();
274                     if (currentToken.trim().equals(eTag)) {
275                         conditionSatisfied = true;
276                     }
277                 }
278 
279                 // none of the given ETags match
280                 if (!conditionSatisfied) {
281                     response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
282                     return false;
283                 }
284 
285             }
286         }
287         return true;
288     }
289 
290     /**
291      * Check if the If-Modified-Since condition is satisfied.
292      *
293      */
294     private static boolean checkIfModifiedSince(final HttpServletRequest request, final HttpServletResponse response,
295         final MCRContent content) throws IOException {
296         try {
297             final long headerValue = request.getDateHeader("If-Modified-Since");
298             final long lastModified = content.lastModified();
299             if (headerValue != -1) {
300 
301                 // If an If-None-Match header has been specified, if modified since
302                 // is ignored.
303                 if (request.getHeader("If-None-Match") == null && lastModified < headerValue + 1000) {
304                     response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
305                     response.setHeader("ETag", content.getETag());
306 
307                     return false;
308                 }
309             }
310         } catch (final IllegalArgumentException illegalArgument) {
311             return true;
312         }
313         return true;
314     }
315 
316     /**
317      * Check if the if-none-match condition is satisfied.
318      */
319     private static boolean checkIfNoneMatch(final HttpServletRequest request, final HttpServletResponse response,
320         final MCRContent content) throws IOException {
321 
322         final String eTag = content.getETag();
323         final String headerValue = request.getHeader("If-None-Match");
324         if (headerValue != null) {
325 
326             boolean conditionSatisfied = false;
327 
328             if ("*".equals(headerValue)) {
329                 conditionSatisfied = true;
330             } else {
331                 final StringTokenizer commaTokenizer = new StringTokenizer(headerValue, ",");
332                 while (!conditionSatisfied && commaTokenizer.hasMoreTokens()) {
333                     final String currentToken = commaTokenizer.nextToken();
334                     if (currentToken.trim().equals(eTag)) {
335                         conditionSatisfied = true;
336                     }
337                 }
338 
339             }
340 
341             if (conditionSatisfied) {
342                 //'GET' 'HEAD' -> not modified
343                 if ("GET".equals(request.getMethod()) || "HEAD".equals(request.getMethod())) {
344                     response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
345                     response.setHeader("ETag", eTag);
346 
347                     return false;
348                 }
349                 response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
350                 return false;
351             }
352         }
353         return true;
354     }
355 
356     /**
357      * Check if the if-unmodified-since condition is satisfied.
358      */
359     private static boolean checkIfUnmodifiedSince(final HttpServletRequest request, final HttpServletResponse response,
360         final MCRContent resource) throws IOException {
361         try {
362             final long lastModified = resource.lastModified();
363             final long headerValue = request.getDateHeader("If-Unmodified-Since");
364             if (headerValue != -1) {
365                 if (lastModified >= headerValue + 1000) {
366                     // The content has been modified.
367                     response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
368                     return false;
369                 }
370             }
371         } catch (final IllegalArgumentException illegalArgument) {
372             return true;
373         }
374         return true;
375     }
376 
377     public static long copyLarge(InputStream input, OutputStream output, long inputOffset, long length, byte[] buffer)
378         throws IOException {
379         return ContentUtils.copyLarge(input, output, inputOffset, length, buffer);
380     }
381 
382     private static String extractFileName(String filename) {
383         int filePosition = filename.lastIndexOf('/') + 1;
384         filename = filename.substring(filePosition);
385         filePosition = filename.lastIndexOf('.');
386         if (filePosition > 0) {
387             filename = filename.substring(0, filePosition);
388         }
389         return filename;
390     }
391 
392     private static String getFileName(final HttpServletRequest req, final MCRContent content) {
393         final String filename = content.getName();
394         if (filename != null) {
395             return filename;
396         }
397         if (req.getPathInfo() != null) {
398             return extractFileName(req.getPathInfo());
399         }
400         return new MessageFormat("{0}-{1}", Locale.ROOT).format(
401             new Object[] { extractFileName(req.getServletPath()), System.currentTimeMillis() });
402     }
403 
404     /**
405      * Returns the request path for debugging purposes.
406      */
407     private static String getRequestPath(final HttpServletRequest request) {
408         return request.getServletPath() + (request.getPathInfo() == null ? "" : request.getPathInfo());
409     }
410 
411     /**
412      * Parses and validates the range header.
413      * This method ensures that all ranges are in ascending order and non-overlapping, so we can use a single
414      * InputStream.
415      */
416     private static List<Range> parseRange(final HttpServletRequest request, final HttpServletResponse response,
417         final MCRContent content) throws IOException {
418 
419         // Checking if range is still valid (lastModified)
420         final String headerValue = request.getHeader("If-Range");
421 
422         if (headerValue != null) {
423             long headerValueTime = -1L;
424             try {
425                 headerValueTime = request.getDateHeader("If-Range");
426             } catch (final IllegalArgumentException e) {
427                 // Ignore
428             }
429 
430             final String eTag = content.getETag();
431             final long lastModified = content.lastModified();
432 
433             if (headerValueTime == -1L) {
434                 // If the content changed, the complete content is served.
435                 if (!eTag.equals(headerValue.trim())) {
436                     return ContentUtils.FULL;
437                 }
438             } else {
439                 //add one second buffer to check if the content was modified.
440                 if (lastModified > headerValueTime + 1000) {
441                     return ContentUtils.FULL;
442                 }
443             }
444 
445         }
446 
447         final long fileLength = content.length();
448         if (fileLength <= 0) {
449             return null;
450         }
451 
452         String rangeHeader = request.getHeader("Range");
453         try {
454             return Range.parseRanges(rangeHeader, fileLength);
455         } catch (IllegalArgumentException e) {
456             response.addHeader("Content-Range", "bytes */" + fileLength);
457             response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
458             return null;
459         }
460     }
461 
462     private static void setContentLengthLong(final HttpServletResponse response, final long length) {
463         response.setHeader("Content-Length", String.valueOf(length));
464     }
465 
466     public static class Config {
467 
468         public boolean useAcceptRanges = true;
469 
470         public int inputBufferSize = ContentUtils.DEFAULT_BUFFER_SIZE;
471 
472         public int outputBufferSize = ContentUtils.DEFAULT_BUFFER_SIZE;
473 
474     }
475 
476 }