
io.github.pustike.web.servlet.StaticResourceServlet Maven / Gradle / Ivy
Show all versions of pustike-web Show documentation
/*
* Copyright (c) 2021 the original author or authors.
*
* Licensed 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
*
* https://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.
*/
package io.github.pustike.web.servlet;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Static resource servlet.
* REVIEW: Content-Range support, Resource Cache, use builder pattern to create resource instances.
*/
public abstract class StaticResourceServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private static final long DEFAULT_EXPIRE_TIME_IN_MILLIS = 0; // TimeUnit.DAYS.toMillis(30);
private static final long ONE_SECOND_IN_MILLIS = TimeUnit.SECONDS.toMillis(1);
private static final String ETAG_HEADER = "W/\"%s-%s\"";
private static final int DEFAULT_STREAM_BUFFER_SIZE = 102400;
private static final String CONTENT_DISPOSITION_HEADER = "inline;filename=\"%1$s\"; filename*=UTF-8''%1$s";
private static final String ERROR_UNSUPPORTED_ENCODING = "UTF-8 is apparently not supported on this platform.";
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
doRequest(request, response, false);
}
@Override
protected void doHead(HttpServletRequest request, HttpServletResponse response) throws IOException {
doRequest(request, response, true);
}
private void doRequest(HttpServletRequest request, HttpServletResponse response, boolean head) throws IOException {
response.reset();
StaticResource resource;
try {
resource = getStaticResource(request);
} catch (IllegalArgumentException e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
if (resource == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
String fileName = encodeURL(resource.getFileName());
String eTag = String.format(ETAG_HEADER, fileName, resource.getLastModified());
if (preconditionFailed(request, resource, eTag)) {
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return;
}
if (notModified(request, resource, eTag)) {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return;
}
setCacheHeaders(response, resource, eTag);
setContentHeaders(request, response, resource);
if (head) {
return;
}
writeContent(response, resource);
}
/**
* Returns the static resource associated with the given HTTP servlet request. This returns null
when
* the resource does actually not exist. The servlet will then return a HTTP 404 error.
* @param request The involved HTTP servlet request.
* @return The static resource associated with the given HTTP servlet request.
* @throws IllegalArgumentException When the request is mangled in such way that it's not recognizable as a valid
* static resource request. The servlet will then return a HTTP 400 error.
*/
protected abstract StaticResource getStaticResource(HttpServletRequest request) throws IllegalArgumentException;
/**
* Returns true if it's a conditional request which must return 412.
*/
private boolean preconditionFailed(HttpServletRequest request, StaticResource resource, String eTag) {
String match = request.getHeader("If-Match");
long unmodified = request.getDateHeader("If-Unmodified-Since");
return (match != null) ? !matches(match, eTag) :
(unmodified != -1 && modified(unmodified, resource.getLastModified()));
}
private boolean notModified(HttpServletRequest request, StaticResource resource, String eTag) {
String ifNoneMatch = request.getHeader("If-None-Match");
if (ifNoneMatch != null) {
String[] matches = ifNoneMatch.split("\\s*,\\s*");
Arrays.sort(matches);
return (Arrays.binarySearch(matches, eTag) > -1 || Arrays.binarySearch(matches, "*") > -1);
} else {
long ifModifiedSince = request.getDateHeader("If-Modified-Since");
// That second is because the header is in seconds, not millis.
return (ifModifiedSince + ONE_SECOND_IN_MILLIS > resource.getLastModified());
}
}
/**
* Set the cache headers.
*
* If the expires
argument is larger than 0 seconds, then the following headers will be set:
*
* Cache-Control: public,max-age=[expiration time in seconds],must-revalidate
* Expires: [expiration date of now plus expiration time in seconds]
*
* Else the method will delegate to {@link #setNoCacheHeaders(HttpServletResponse)}.
* @param response The HTTP servlet response to set the headers on
* @param resource The static resource
* @param eTag The ETag
*/
private void setCacheHeaders(HttpServletResponse response, StaticResource resource, String eTag) {
response.setHeader("ETag", eTag);
response.setDateHeader("Last-Modified", resource.getLastModified());
long expires = DEFAULT_EXPIRE_TIME_IN_MILLIS;
if (expires > 0) {
response.setHeader("Cache-Control", "public,max-age=" + expires + ",must-revalidate");
response.setDateHeader("Expires", System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(expires));
response.setHeader("Pragma", ""); // Explicitly set pragma to prevent container from overriding it.
} else {
setNoCacheHeaders(response);
}
}
/**
* Set the no-cache headers.
*
*
The following headers will be set:
*
* Cache-Control: no-cache,no-store,must-revalidate
* Expires: [expiration date of 0]
* Pragma: no-cache
*
* @param response The HTTP servlet response to set the headers on
*/
private void setNoCacheHeaders(HttpServletResponse response) {
response.setHeader("Cache-Control", "no-cache,no-store,must-revalidate");
response.setDateHeader("Expires", 0);
response.setHeader("Pragma", "no-cache"); // Backwards compatibility for HTTP 1.0.
}
/**
* Set content headers.
*
* The following headers will be set:
*
* Content-Type:
delegates the fileName to {@link jakarta.servlet.ServletContext#getMimeType(String)}
* with a fallback default value of application/octet-stream
.
* Content-Disposition:
* Content-Length:
*
* @param request The HTTP servlet request
* @param response The HTTP servlet response to set the headers on
* @param resource The static resource
*/
private void setContentHeaders(HttpServletRequest request, HttpServletResponse response, StaticResource resource) {
String fileName = resource.getFileName();
response.setContentType(Optional.of(request.getServletContext().getMimeType(fileName))
.orElse("application/octet-stream"));
response.setHeader("Content-Disposition", String.format(CONTENT_DISPOSITION_HEADER, encodeURI(fileName)));
if (resource.getContentLength() != -1) {
response.setContentLengthLong(resource.getContentLength());
}
}
private void writeContent(HttpServletResponse response, StaticResource resource) throws IOException {
try (ReadableByteChannel inputChannel = Channels.newChannel(resource.getInputStream());
WritableByteChannel outputChannel = Channels.newChannel(response.getOutputStream())) {
ByteBuffer buffer = ByteBuffer.allocateDirect(DEFAULT_STREAM_BUFFER_SIZE);
long size = 0;
while (inputChannel.read(buffer) != -1) {
buffer.flip();
size += outputChannel.write(buffer);
buffer.clear();
}
if (resource.getContentLength() == -1 && !response.isCommitted()) {
response.setContentLengthLong(size);
}
}
}
/**
* Returns true if the given match header matches the given ETag value.
*/
private static boolean matches(String matchHeader, String eTag) {
String[] matchValues = matchHeader.split("\\s*,\\s*");
Arrays.sort(matchValues);
return Arrays.binarySearch(matchValues, eTag) > -1
|| Arrays.binarySearch(matchValues, "*") > -1;
}
/**
* Returns true if the given modified header is older than the given last modified value.
*/
private static boolean modified(long modifiedHeader, long lastModified) {
// That second is because the header is in seconds, not millis.
return (modifiedHeader + ONE_SECOND_IN_MILLIS <= lastModified);
}
/**
* URI-encode the given string using UTF-8. URIs (paths and filenames) have different encoding rules as compared to
* URL query string parameters. {@link URLEncoder} is actually only for www (HTML) form based query string parameter
* values (as used when a webbrowser submits a HTML form). URI encoding has a lot in common with URL encoding, but
* the space has to be %20 and some chars doesn't necessarily need to be encoded.
* @param string The string to be URI-encoded using UTF-8.
* @return The given string, URI-encoded using UTF-8, or null
if null
was given.
* @throws UnsupportedOperationException When this platform does not support UTF-8.
*/
private static String encodeURI(String string) throws UnsupportedOperationException {
if (string == null) {
return null;
}
return encodeURL(string).replace("+", "%20").replace("%21", "!").replace("%27", "'")
.replace("%28", "(").replace("%29", ")").replace("%7E", "~");
}
/**
* URL-encode the given string using UTF-8.
* @param string The string to be URL-encoded using UTF-8
* @return The given string, URL-encoded using UTF-8, or null
if null
was given
* @throws UnsupportedOperationException When this platform does not support UTF-8
*/
private static String encodeURL(String string) throws UnsupportedOperationException {
if (string == null) {
return null;
}
try {
return URLEncoder.encode(string, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
throw new UnsupportedOperationException(ERROR_UNSUPPORTED_ENCODING, e);
}
}
}