com.blade.server.netty.StaticFileHandler Maven / Gradle / Ivy
/**
* Copyright (c) 2017, biezhi 王爵 nice ([email protected])
*
* 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 com.blade.server.netty;
import com.blade.Blade;
import com.blade.exception.ForbiddenException;
import com.blade.exception.NotFoundException;
import com.blade.kit.*;
import com.blade.mvc.Const;
import com.blade.mvc.WebContext;
import com.blade.mvc.handler.RequestHandler;
import com.blade.mvc.http.Request;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.DefaultFileRegion;
import io.netty.handler.codec.http.*;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.stream.ChunkedFile;
import io.netty.util.CharsetUtil;
import lombok.extern.slf4j.Slf4j;
import lombok.var;
import java.io.*;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;
import static com.blade.kit.BladeKit.*;
import static io.netty.handler.codec.http.HttpResponseStatus.*;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
/**
* static file handler
*
* @author biezhi
* 2017/5/31
*/
@Slf4j
public class StaticFileHandler implements RequestHandler {
private static final String STYLE = "body{background:#fff;margin:0;padding:30px;-webkit-font-smoothing:antialiased;font-family:Menlo,Consolas,monospace}main{max-width:920px}header{display:flex;justify-content:space-between}#toggle{display:none;cursor:pointer}#toggle:before{display:inline-block;content:url(\"data:image/svg+xml; utf8, \")}#toggle.single-column:before{content:url(\"data:image/svg+xml; utf8, \")}a{color:#1A00F2;text-decoration:none}h1{font-size:18px;font-weight:500;margin-top:0;color:#000;font-family:-apple-system,Helvetica;display:flex}h1 a{color:inherit;font-weight:700;border-bottom:1px dashed transparent}h1 a::after{content:'/'}h1 a:hover{color:#7d7d7d}h1 i{font-style:normal}ul{margin:0;padding:20px 0 0 0}ul.single-column{flex-direction:column}ul li{list-style:none;padding:10px 0;font-size:14px;display:flex;justify-content:space-between}ul li i{color:#9B9B9B;font-size:11px;display:block;font-style:normal;white-space:nowrap;padding-left:15px}ul a{color:#1A00F2;white-space:nowrap;overflow:hidden;display:block;text-overflow:ellipsis}ul a::before{content:url(\"data:image/svg+xml; utf8, \");display:inline-block;vertical-align:middle;margin-right:10px}ul a:hover{color:#000}ul a[class=''] + i{display:none}ul a[class='']::before{content:url(\"data:image/svg+xml; utf8, \")}ul a[class='gif']::before,ul a[class='jpg']::before,ul a[class='png']::before,ul a[class='svg']::before{content:url(\"data:image/svg+xml; utf8, \");width:16px}@media (min-width:768px){#toggle{display:inline-block}ul{display:flex;flex-wrap:wrap}ul li{width:230px;padding-right:20px}ul.single-column li{width:auto}}@media (min-width:992px){body{padding:45px}h1{font-size:15px}ul li{font-size:13px;box-sizing:border-box;justify-content:flex-start}ul li:hover i{opacity:1}ul li i{font-size:10px;opacity:0;margin-left:10px;margin-top:3px;padding-left:0}}";
private boolean showFileList;
/**
* default cache 30 days.
*/
private final long HTTP_CACHE_SECONDS;
public StaticFileHandler(Blade blade) {
this.showFileList = blade.environment().getBoolean(Const.ENV_KEY_STATIC_LIST, false);
this.HTTP_CACHE_SECONDS = blade.environment().getLong(Const.ENV_KEY_HTTP_CACHE_TIMEOUT, 86400 * 30);
}
/**
* print static file to client
*
* @param webContext web context
* @throws Exception
*/
@Override
public void handle(WebContext webContext) throws Exception {
Request request = webContext.getRequest();
ChannelHandlerContext ctx = webContext.getChannelHandlerContext();
if (!HttpConst.METHOD_GET.equals(request.method())) {
sendError(ctx, METHOD_NOT_ALLOWED);
return;
}
String uri = URLDecoder.decode(request.uri(), "UTF-8");
Instant start = Instant.now();
String cleanUri = PathKit.cleanPath(uri.replaceFirst(request.contextPath(), "/"));
String method = StringKit.padRight(request.method(), 6);
// webjars
if (cleanUri.startsWith(Const.WEB_JARS)) {
InputStream input = StaticFileHandler.class.getResourceAsStream("/META-INF/resources" + uri);
if (null == input) {
log404(log, method, uri);
throw new NotFoundException(uri);
}
if (writeJarResource(ctx, request, cleanUri, input)) {
log200AndCost(log, start, method, uri);
}
return;
}
// jar file
if (BladeKit.isInJar()) {
InputStream input = StaticFileHandler.class.getResourceAsStream(cleanUri);
if (null == input) {
log404(log, method, uri);
throw new NotFoundException(uri);
}
if (writeJarResource(ctx, request, cleanUri, input)) {
log200AndCost(log, start, method, uri);
}
return;
}
// disk file
final String path = sanitizeUri(cleanUri);
if (path == null) {
log403(log, method, uri);
throw new ForbiddenException();
}
File file = new File(path);
if (file.isHidden() || !file.exists()) {
// gradle resources path
File resourcesDirectory = new File(new File(Const.class.getResource("/").getPath()).getParent() + "/resources");
if (resourcesDirectory.isDirectory()) {
file = new File(resourcesDirectory.getPath() + "/" + cleanUri.substring(1));
if (file.isHidden() || !file.exists()) {
log404(log, method, uri);
throw new NotFoundException(uri);
}
} else {
log404(log, method, uri);
throw new NotFoundException(uri);
}
}
if (file.isDirectory() && showFileList) {
sendListing(ctx, uri, file, cleanUri);
return;
}
if (!file.isFile()) {
sendError(ctx, FORBIDDEN);
return;
}
// Cache Validation
if (isHttp304(ctx, request, file.length(), file.lastModified())) {
log304(log, method, uri);
return;
}
HttpResponse httpResponse = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
setContentTypeHeader(httpResponse, file);
setDateAndCacheHeaders(httpResponse, file);
if (request.useGZIP()) {
File output = new File(file.getPath() + ".gz");
IOKit.compressGZIP(file, output);
file = output;
setGzip(httpResponse);
}
RandomAccessFile raf;
try {
raf = new RandomAccessFile(file, "r");
} catch (FileNotFoundException ignore) {
sendError(ctx, NOT_FOUND);
return;
}
long fileLength = raf.length();
httpResponse.headers().set(HttpConst.CONTENT_LENGTH, fileLength);
if (request.keepAlive()) {
httpResponse.headers().set(HttpConst.CONNECTION, HttpConst.KEEP_ALIVE);
}
// Write the initial line and the header.
ctx.write(httpResponse);
// Write the content.
ChannelFuture sendFileFuture;
ChannelFuture lastContentFuture;
if (ctx.pipeline().get(SslHandler.class) == null) {
sendFileFuture = ctx.write(new DefaultFileRegion(raf.getChannel(), 0, fileLength), ctx.newProgressivePromise());
// Write the end marker.
lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
} else {
sendFileFuture = ctx.writeAndFlush(
new HttpChunkedInput(new ChunkedFile(raf, 0, fileLength, 8192)),
ctx.newProgressivePromise());
// HttpChunkedInput will write the end marker (LastHttpContent) for us.
lastContentFuture = sendFileFuture;
}
sendFileFuture.addListener(ProgressiveFutureListener.build(raf));
// Decide whether to close the connection or not.
if (!request.keepAlive()) {
lastContentFuture.addListener(ChannelFutureListener.CLOSE);
}
log200AndCost(log, start, method, uri);
}
private boolean writeJarResource(ChannelHandlerContext ctx, Request request, String uri, InputStream input) throws IOException {
var staticInputStream = new StaticInputStream(input);
int size = staticInputStream.size();
if (isHttp304(ctx, request, size, -1)) {
log304(log, request.method(), uri);
return false;
}
FullHttpResponse httpResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, OK, staticInputStream.asByteBuf());
setDateAndCacheHeaders(httpResponse, null);
String contentType = StringKit.mimeType(uri);
if (null != contentType) {
httpResponse.headers().set(HttpConst.CONTENT_TYPE, contentType);
}
httpResponse.headers().set(HttpConst.CONTENT_LENGTH, size);
if (request.keepAlive()) {
httpResponse.headers().set(HttpConst.CONNECTION, HttpConst.KEEP_ALIVE);
}
// Write the initial line and the header.
ctx.writeAndFlush(httpResponse);
return true;
}
private boolean isHttp304(ChannelHandlerContext ctx, Request request, long size, long lastModified) {
String ifModifiedSince = request.header(HttpConst.IF_MODIFIED_SINCE);
if (StringKit.isNotEmpty(ifModifiedSince) && HTTP_CACHE_SECONDS > 0) {
Date ifModifiedSinceDate = format(ifModifiedSince, Const.HTTP_DATE_FORMAT);
// Only compare up to the second because the datetime format we send to the client
// does not have milliseconds
long ifModifiedSinceDateSeconds = ifModifiedSinceDate.getTime() / 1000;
if (ifModifiedSinceDateSeconds == lastModified / 1000) {
FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, NOT_MODIFIED);
String contentType = StringKit.mimeType(request.uri());
if (null != contentType) {
response.headers().set(HttpConst.CONTENT_TYPE, contentType);
}
response.headers().set(HttpConst.DATE, DateKit.gmtDate());
response.headers().set(HttpConst.CONTENT_LENGTH, size);
if (request.keepAlive()) {
response.headers().set(HttpConst.CONNECTION, HttpConst.KEEP_ALIVE);
}
// Close the connection as soon as the error message is sent.
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
return true;
}
}
return false;
}
public Date format(String date, String pattern) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern, Locale.US);
LocalDateTime formatted = LocalDateTime.parse(date, fmt);
Instant instant = formatted.atZone(ZoneId.systemDefault()).toInstant();
return Date.from(instant);
}
/**
* Sets the Date and Cache headers for the HTTP Response
*
* @param response HTTP response
* @param fileToCache file to extract content type
*/
private void setDateAndCacheHeaders(HttpResponse response, File fileToCache) {
response.headers().set(HttpConst.DATE, DateKit.gmtDate());
// Add cache headers
if (HTTP_CACHE_SECONDS > 0) {
response.headers().set(HttpConst.EXPIRES, DateKit.gmtDate(LocalDateTime.now().plusSeconds(HTTP_CACHE_SECONDS)));
response.headers().set(HttpConst.CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
if (null != fileToCache) {
response.headers().set(HttpConst.LAST_MODIFIED, DateKit.gmtDate(new Date(fileToCache.lastModified())));
} else {
response.headers().set(HttpConst.LAST_MODIFIED, DateKit.gmtDate(LocalDateTime.now().plusDays(-1)));
}
}
}
private static final Pattern ALLOWED_FILE_NAME = Pattern.compile("[^-._]?[^<>&\"]*");
private static void sendListing(ChannelHandlerContext ctx, String uri, File dir, String dirPath) {
var response = new DefaultFullHttpResponse(HTTP_1_1, OK);
response.headers().set(HttpConst.CONTENT_TYPE, "text/html; charset=UTF-8");
StringBuilder buf = new StringBuilder()
.append("\r\n")
.append("
")
.append("")
.append("")
.append("")
.append("Files within: ")
.append(dirPath)
.append(" ")
.append("Index of ");
String[] dirs = uri.split("/");
for (String s : dirs) {
if (StringKit.isEmpty(s)) {
continue;
}
String path = uri.substring(0, uri.indexOf(s) + s.length());
buf.append("" + s + "");
}
buf.append("
");
buf.append("");
buf.append(" ");
buf.append("");
if (dirs.length > 2) {
String parent = uri.substring(0, uri.lastIndexOf("/"));
buf.append("- ..
");
}
for (File f : Objects.requireNonNull(dir.listFiles())) {
if (f.isHidden() || !f.canRead()) {
continue;
}
String name = f.getName();
if (!ALLOWED_FILE_NAME.matcher(name).matches()) {
continue;
}
String subPath = (uri + "/" + name).replace("//", "/");
buf.append("- ");
buf.append(name).append("
");
} else {
buf.append(" class='css'>");
String size = ConvertKit.byte2FitMemoryString(f.length());
buf.append(name).append("");
buf.append(size).append("");
}
}
buf.append("
");
buf.append(" ");
buf.append("");
buf.append("");
ByteBuf buffer = Unpooled.copiedBuffer(buf, CharsetUtil.UTF_8);
response.content().writeBytes(buffer);
buffer.release();
// Close the connection as soon as the error message is sent.
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
var response = new DefaultFullHttpResponse(HTTP_1_1, status,
Unpooled.copiedBuffer("Failure: " + status + "\r\n", CharsetUtil.UTF_8));
response.headers().set(HttpConst.CONTENT_TYPE, Const.CONTENT_TYPE_TEXT);
// Close the connection as soon as the error message is sent.
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static final Pattern INSECURE_URI = Pattern.compile(".*[<>&\"].*");
private static String sanitizeUri(String uri) {
if (uri.isEmpty() || uri.charAt(0) != HttpConst.CHAR_SLASH) {
return null;
}
// Convert file separators.
uri = uri.replace(HttpConst.CHAR_SLASH, File.separatorChar);
// Simplistic dumb security check.
// You will have to do something serious in the production environment.
if (uri.contains(File.separator + HttpConst.CHAR_POINT) ||
uri.contains('.' + File.separator) ||
uri.charAt(0) == '.' || uri.charAt(uri.length() - 1) == '.' ||
INSECURE_URI.matcher(uri).matches()) {
return null;
}
// Maven resources path
String path = Const.CLASSPATH + File.separator + uri.substring(1);
return path.replace("//", "/");
}
/**
* Sets the content type header for the HTTP Response
*
* @param response HTTP response
* @param file file to extract content type
*/
private static void setContentTypeHeader(HttpResponse response, File file) {
String contentType = StringKit.mimeType(file.getName());
if (null == contentType) {
contentType = URLConnection.guessContentTypeFromName(file.getName());
}
response.headers().set(HttpConst.CONTENT_TYPE, contentType);
}
private void setGzip(HttpResponse response) {
response.headers().set(HttpConst.CONTENT_ENCODING, "gzip");
}
}