io.undertow.server.handlers.resource.ResourceHandler Maven / Gradle / Ivy
/*
* JBoss, Home of Professional Open Source.
* Copyright 2014 Red Hat, Inc., and individual contributors
* as indicated by the @author tags.
*
* 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
*
* http://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.undertow.server.handlers.resource;
import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import io.undertow.UndertowLogger;
import io.undertow.io.IoCallback;
import io.undertow.predicate.Predicate;
import io.undertow.predicate.Predicates;
import io.undertow.server.HandlerWrapper;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.handlers.ResponseCodeHandler;
import io.undertow.server.handlers.builder.HandlerBuilder;
import io.undertow.server.handlers.cache.ResponseCache;
import io.undertow.server.handlers.encoding.ContentEncodedResource;
import io.undertow.server.handlers.encoding.ContentEncodedResourceManager;
import io.undertow.util.ByteRange;
import io.undertow.util.CanonicalPathUtils;
import io.undertow.util.DateUtils;
import io.undertow.util.ETag;
import io.undertow.util.ETagUtils;
import io.undertow.util.Headers;
import io.undertow.util.HttpString;
import io.undertow.util.Methods;
import io.undertow.util.MimeMappings;
import io.undertow.util.RedirectBuilder;
import io.undertow.util.StatusCodes;
/**
* @author Stuart Douglas
*/
public class ResourceHandler implements HttpHandler {
/**
* Set of methods prescribed by HTTP 1.1. If request method is not one of those, handler will
* return NOT_IMPLEMENTED.
*/
private static final Set KNOWN_METHODS = new HashSet<>();
static {
KNOWN_METHODS.add(Methods.OPTIONS);
KNOWN_METHODS.add(Methods.GET);
KNOWN_METHODS.add(Methods.HEAD);
KNOWN_METHODS.add(Methods.POST);
KNOWN_METHODS.add(Methods.PUT);
KNOWN_METHODS.add(Methods.DELETE);
KNOWN_METHODS.add(Methods.TRACE);
KNOWN_METHODS.add(Methods.CONNECT);
}
private final List welcomeFiles = new CopyOnWriteArrayList<>(new String[]{"index.html", "index.htm", "default.html", "default.htm"});
/**
* If directory listing is enabled.
*/
private volatile boolean directoryListingEnabled = false;
/**
* If the canonical version of paths should be passed into the resource manager.
*/
private volatile boolean canonicalizePaths = true;
/**
* The mime mappings that are used to determine the content type.
*/
private volatile MimeMappings mimeMappings = MimeMappings.DEFAULT;
private volatile Predicate cachable = Predicates.truePredicate();
private volatile Predicate allowed = Predicates.truePredicate();
private volatile ResourceSupplier resourceSupplier;
private volatile ResourceManager resourceManager;
/**
* If this is set this will be the maximum time (in seconds) the client will cache the resource.
*
* Note: Do not set this for private resources, as it will cause a Cache-Control: public
* to be sent.
*
* TODO: make this more flexible
*
* This will only be used if the {@link #cachable} predicate returns true
*/
private volatile Integer cacheTime;
private volatile ContentEncodedResourceManager contentEncodedResourceManager;
/**
* Handler that is called if no resource is found
*/
private final HttpHandler next;
public ResourceHandler(ResourceManager resourceSupplier) {
this(resourceSupplier, ResponseCodeHandler.HANDLE_404);
}
public ResourceHandler(ResourceManager resourceManager, HttpHandler next) {
this.resourceSupplier = new DefaultResourceSupplier(resourceManager);
this.resourceManager = resourceManager;
this.next = next;
}
public ResourceHandler(ResourceSupplier resourceSupplier) {
this(resourceSupplier, ResponseCodeHandler.HANDLE_404);
}
public ResourceHandler(ResourceSupplier resourceManager, HttpHandler next) {
this.resourceSupplier = resourceManager;
this.next = next;
}
/**
* You should use {@link ResourceHandler(ResourceManager)} instead.
*/
@Deprecated
public ResourceHandler() {
this.next = ResponseCodeHandler.HANDLE_404;
}
@Override
public void handleRequest(final HttpServerExchange exchange) throws Exception {
if (exchange.getRequestMethod().equals(Methods.GET) ||
exchange.getRequestMethod().equals(Methods.POST)) {
serveResource(exchange, true);
} else if (exchange.getRequestMethod().equals(Methods.HEAD)) {
serveResource(exchange, false);
} else {
if (KNOWN_METHODS.contains(exchange.getRequestMethod())) {
exchange.setStatusCode(StatusCodes.METHOD_NOT_ALLOWED);
exchange.getResponseHeaders().add(Headers.ALLOW,
String.join(", ", Methods.GET_STRING, Methods.HEAD_STRING, Methods.POST_STRING));
} else {
exchange.setStatusCode(StatusCodes.NOT_IMPLEMENTED);
}
exchange.endExchange();
}
}
private void serveResource(final HttpServerExchange exchange, final boolean sendContent) throws Exception {
if (directoryListingEnabled && DirectoryUtils.sendRequestedBlobs(exchange)) {
return;
}
if (!allowed.resolve(exchange)) {
exchange.setStatusCode(StatusCodes.FORBIDDEN);
exchange.endExchange();
return;
}
ResponseCache cache = exchange.getAttachment(ResponseCache.ATTACHMENT_KEY);
final boolean cachable = this.cachable.resolve(exchange);
//we set caching headers before we try and serve from the cache
if (cachable && cacheTime != null) {
exchange.getResponseHeaders().put(Headers.CACHE_CONTROL, "public, max-age=" + cacheTime);
long date = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(cacheTime);
String dateHeader = DateUtils.toDateString(new Date(date));
exchange.getResponseHeaders().put(Headers.EXPIRES, dateHeader);
}
if (cache != null && cachable) {
if (cache.tryServeResponse()) {
return;
}
}
//we now dispatch to a worker thread
//as resource manager methods are potentially blocking
HttpHandler dispatchTask = new HttpHandler() {
@Override
public void handleRequest(HttpServerExchange exchange) throws Exception {
Resource resource = null;
try {
if (File.separatorChar == '/' || !exchange.getRelativePath().contains(File.separator)) {
//we don't process resources that contain the sperator character if this is not /
//this prevents attacks where people use windows path seperators in file URLS's
resource = resourceSupplier.getResource(exchange, canonicalize(exchange.getRelativePath()));
}
} catch (IOException e) {
clearCacheHeaders(exchange);
UndertowLogger.REQUEST_IO_LOGGER.ioException(e);
exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR);
exchange.endExchange();
return;
}
if (resource == null) {
clearCacheHeaders(exchange);
//usually a 404 handler
next.handleRequest(exchange);
return;
}
if (resource.isDirectory()) {
Resource indexResource;
try {
indexResource = getIndexFiles(exchange, resourceSupplier, resource.getPath(), welcomeFiles);
} catch (IOException e) {
UndertowLogger.REQUEST_IO_LOGGER.ioException(e);
exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR);
exchange.endExchange();
return;
}
if (indexResource == null) {
if (directoryListingEnabled) {
DirectoryUtils.renderDirectoryListing(exchange, resource);
return;
} else {
exchange.setStatusCode(StatusCodes.FORBIDDEN);
exchange.endExchange();
return;
}
} else if (!exchange.getRequestPath().endsWith("/")) {
exchange.setStatusCode(StatusCodes.FOUND);
exchange.getResponseHeaders().put(Headers.LOCATION, RedirectBuilder.redirect(exchange, exchange.getRelativePath() + "/", true));
exchange.endExchange();
return;
}
resource = indexResource;
} else if(exchange.getRelativePath().endsWith("/")) {
//UNDERTOW-432
exchange.setStatusCode(StatusCodes.NOT_FOUND);
exchange.endExchange();
return;
}
final ETag etag = resource.getETag();
final Date lastModified = resource.getLastModified();
if (!ETagUtils.handleIfMatch(exchange, etag, false) ||
!DateUtils.handleIfUnmodifiedSince(exchange, lastModified)) {
exchange.setStatusCode(StatusCodes.PRECONDITION_FAILED);
exchange.endExchange();
return;
}
if (!ETagUtils.handleIfNoneMatch(exchange, etag, true) ||
!DateUtils.handleIfModifiedSince(exchange, lastModified)) {
exchange.setStatusCode(StatusCodes.NOT_MODIFIED);
exchange.endExchange();
return;
}
final ContentEncodedResourceManager contentEncodedResourceManager = ResourceHandler.this.contentEncodedResourceManager;
Long contentLength = resource.getContentLength();
if (contentLength != null && !exchange.getResponseHeaders().contains(Headers.TRANSFER_ENCODING)) {
exchange.setResponseContentLength(contentLength);
}
ByteRange.RangeResponseResult rangeResponse = null;
long start = -1, end = -1;
if(resource instanceof RangeAwareResource && ((RangeAwareResource)resource).isRangeSupported() && contentLength != null && contentEncodedResourceManager == null) {
exchange.getResponseHeaders().put(Headers.ACCEPT_RANGES, "bytes");
//TODO: figure out what to do with the content encoded resource manager
ByteRange range = ByteRange.parse(exchange.getRequestHeaders().getFirst(Headers.RANGE));
if(range != null && range.getRanges() == 1 && resource.getContentLength() != null) {
rangeResponse = range.getResponseResult(resource.getContentLength(), exchange.getRequestHeaders().getFirst(Headers.IF_RANGE), resource.getLastModified(), resource.getETag() == null ? null : resource.getETag().getTag());
if(rangeResponse != null){
start = rangeResponse.getStart();
end = rangeResponse.getEnd();
exchange.setStatusCode(rangeResponse.getStatusCode());
exchange.getResponseHeaders().put(Headers.CONTENT_RANGE, rangeResponse.getContentRange());
long length = rangeResponse.getContentLength();
exchange.setResponseContentLength(length);
if(rangeResponse.getStatusCode() == StatusCodes.REQUEST_RANGE_NOT_SATISFIABLE) {
return;
}
}
}
}
//we are going to proceed. Set the appropriate headers
if (!exchange.getResponseHeaders().contains(Headers.CONTENT_TYPE)) {
final String contentType = resource.getContentType(mimeMappings);
if (contentType != null) {
exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, contentType);
} else {
exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/octet-stream");
}
}
if (lastModified != null) {
exchange.getResponseHeaders().put(Headers.LAST_MODIFIED, resource.getLastModifiedString());
}
if (etag != null) {
exchange.getResponseHeaders().put(Headers.ETAG, etag.toString());
}
if (contentEncodedResourceManager != null) {
try {
ContentEncodedResource encoded = contentEncodedResourceManager.getResource(resource, exchange);
if (encoded != null) {
exchange.getResponseHeaders().put(Headers.CONTENT_ENCODING, encoded.getContentEncoding());
exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, encoded.getResource().getContentLength());
encoded.getResource().serve(exchange.getResponseSender(), exchange, IoCallback.END_EXCHANGE);
return;
}
} catch (IOException e) {
//TODO: should this be fatal
UndertowLogger.REQUEST_IO_LOGGER.ioException(e);
exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR);
exchange.endExchange();
return;
}
}
if (!sendContent) {
exchange.endExchange();
} else if(rangeResponse != null) {
((RangeAwareResource)resource).serveRange(exchange.getResponseSender(), exchange, start, end, IoCallback.END_EXCHANGE);
} else {
resource.serve(exchange.getResponseSender(), exchange, IoCallback.END_EXCHANGE);
}
}
};
if(exchange.isInIoThread()) {
exchange.dispatch(dispatchTask);
} else {
dispatchTask.handleRequest(exchange);
}
}
private void clearCacheHeaders(HttpServerExchange exchange) {
exchange.getResponseHeaders().remove(Headers.CACHE_CONTROL);
exchange.getResponseHeaders().remove(Headers.EXPIRES);
}
private Resource getIndexFiles(HttpServerExchange exchange, ResourceSupplier resourceManager, final String base, List possible) throws IOException {
String realBase;
if (base.endsWith("/")) {
realBase = base;
} else {
realBase = base + "/";
}
for (String possibility : possible) {
Resource index = resourceManager.getResource(exchange, canonicalize(realBase + possibility));
if (index != null) {
return index;
}
}
return null;
}
private String canonicalize(String s) {
if(canonicalizePaths) {
return CanonicalPathUtils.canonicalize(s);
}
return s;
}
public boolean isDirectoryListingEnabled() {
return directoryListingEnabled;
}
public ResourceHandler setDirectoryListingEnabled(final boolean directoryListingEnabled) {
this.directoryListingEnabled = directoryListingEnabled;
return this;
}
public ResourceHandler addWelcomeFiles(String... files) {
this.welcomeFiles.addAll(Arrays.asList(files));
return this;
}
public ResourceHandler setWelcomeFiles(String... files) {
this.welcomeFiles.clear();
this.welcomeFiles.addAll(Arrays.asList(files));
return this;
}
public MimeMappings getMimeMappings() {
return mimeMappings;
}
public ResourceHandler setMimeMappings(final MimeMappings mimeMappings) {
this.mimeMappings = mimeMappings;
return this;
}
public Predicate getCachable() {
return cachable;
}
public ResourceHandler setCachable(final Predicate cachable) {
this.cachable = cachable;
return this;
}
public Predicate getAllowed() {
return allowed;
}
public ResourceHandler setAllowed(final Predicate allowed) {
this.allowed = allowed;
return this;
}
public ResourceSupplier getResourceSupplier() {
return resourceSupplier;
}
public ResourceHandler setResourceSupplier(final ResourceSupplier resourceSupplier) {
this.resourceSupplier = resourceSupplier;
this.resourceManager = null;
return this;
}
public ResourceManager getResourceManager() {
return resourceManager;
}
public ResourceHandler setResourceManager(final ResourceManager resourceManager) {
this.resourceManager = resourceManager;
this.resourceSupplier = new DefaultResourceSupplier(resourceManager);
return this;
}
public Integer getCacheTime() {
return cacheTime;
}
public ResourceHandler setCacheTime(final Integer cacheTime) {
this.cacheTime = cacheTime;
return this;
}
public ContentEncodedResourceManager getContentEncodedResourceManager() {
return contentEncodedResourceManager;
}
public ResourceHandler setContentEncodedResourceManager(ContentEncodedResourceManager contentEncodedResourceManager) {
this.contentEncodedResourceManager = contentEncodedResourceManager;
return this;
}
public boolean isCanonicalizePaths() {
return canonicalizePaths;
}
/**
* If this handler should use canonicalized paths.
*
* WARNING: If this is not true and {@link io.undertow.server.handlers.CanonicalPathHandler} is not installed in
* the handler chain then is may be possible to perform a directory traversal attack. If you set this to false make
* sure you have some kind of check in place to control the path.
* @param canonicalizePaths If paths should be canonicalized
*/
public void setCanonicalizePaths(boolean canonicalizePaths) {
this.canonicalizePaths = canonicalizePaths;
}
public static class Builder implements HandlerBuilder {
@Override
public String name() {
return "resource";
}
@Override
public Map> parameters() {
Map> params = new HashMap<>();
params.put("location", String.class);
params.put("allow-listing", boolean.class);
return params;
}
@Override
public Set requiredParameters() {
return Collections.singleton("location");
}
@Override
public String defaultParameter() {
return "location";
}
@Override
public HandlerWrapper build(Map config) {
return new Wrapper((String)config.get("location"), (Boolean) config.get("allow-listing"));
}
}
private static class Wrapper implements HandlerWrapper {
private final String location;
private final boolean allowDirectoryListing;
private Wrapper(String location, boolean allowDirectoryListing) {
this.location = location;
this.allowDirectoryListing = allowDirectoryListing;
}
@Override
public HttpHandler wrap(HttpHandler handler) {
ResourceManager rm = new PathResourceManager(Paths.get(location), 1024);
ResourceHandler resourceHandler = new ResourceHandler(rm);
resourceHandler.setDirectoryListingEnabled(allowDirectoryListing);
return resourceHandler;
}
}
}