package org.yamcs.web;
import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN;
import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND;
import static io.netty.handler.codec.http.HttpResponseStatus.OK;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.TimeZone;
import javax.activation.MimetypesFileTypeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.yamcs.ConfigurationException;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelProgressiveFuture;
import io.netty.channel.ChannelProgressiveFutureListener;
import io.netty.channel.DefaultFileRegion;
import io.netty.handler.codec.http.DefaultHttpResponse;
import io.netty.handler.codec.http.HttpChunkedInput;
import io.netty.handler.codec.http.HttpContentCompressor;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaderValues;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpUtil;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.stream.ChunkedFile;
import io.netty.handler.stream.ChunkedWriteHandler;
public class StaticFileHandler extends RouteHandler {
static MimetypesFileTypeMap mimeTypesMap;
public static final int HTTP_CACHE_SECONDS = 60;
private static WebConfig webConfig;
private static final Logger log = LoggerFactory.getLogger(StaticFileHandler.class.getName());
public static void init() throws ConfigurationException {
if(mimeTypesMap!=null) {
return;
}
try(InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream("mime.types")) {
if(is==null) {
throw new ConfigurationException("Cannot find the mime.types file in the classpath");
}
mimeTypesMap = new MimetypesFileTypeMap(is);
webConfig = WebConfig.getInstance();
} catch (IOException e) {
log.error("Error when closing the stream", e);
}
}
void handleStaticFileRequest(ChannelHandlerContext ctx, HttpRequest req, String rawPath) throws IOException {
log.debug("Handling static file request for {}", rawPath);
String path = sanitizePath(rawPath);
if (path == null) {
HttpRequestHandler.sendPlainTextError(ctx, req, FORBIDDEN);
return;
}
File file = null;
boolean match = false;
for (String webRoot : webConfig.getWebRoots()) { // Stop on first match
file = new File(webRoot + File.separator + path);
if (!file.isHidden() && file.exists()) {
match = true;
break;
}
}
if (!match) {
log.warn("File {} does not exist or is hidden. Searched under {}", path, webConfig.getWebRoots());
HttpRequestHandler.sendPlainTextError(ctx, req, NOT_FOUND);
return;
}
if (!file.isFile()) {
HttpRequestHandler.sendPlainTextError(ctx, req, FORBIDDEN);
return;
}
// Cache Validation
String ifModifiedSince = req.headers().get(HttpHeaderNames.IF_MODIFIED_SINCE);
if (ifModifiedSince != null && !ifModifiedSince.equals("")) {
SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT);
Date ifModifiedSinceDate;
try {
ifModifiedSinceDate = dateFormatter.parse(ifModifiedSince);
// Only compare up to the second because the datetime format we send to the client does not have milliseconds
long ifModifiedSinceDateSeconds = ifModifiedSinceDate.getTime() / 1000;
long fileLastModifiedSeconds = file.lastModified() / 1000;
if (ifModifiedSinceDateSeconds == fileLastModifiedSeconds) {
sendNotModified(ctx, req);
return;
}
} catch (ParseException e) {
log.debug("Cannot parse {} header'{}'", HttpHeaderNames.IF_MODIFIED_SINCE, ifModifiedSince);
}
}
boolean zeroCopy = webConfig.isZeroCopyEnabled() && ctx.pipeline().get(SslHandler.class) == null;
RandomAccessFile raf;
try {
raf = new RandomAccessFile(file, "r");
} catch (FileNotFoundException ignore) {
HttpRequestHandler.sendPlainTextError(ctx, req, NOT_FOUND);
return;
}
long fileLength = raf.length();
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
setContentTypeHeader(response, file);
setDateAndCacheHeaders(response, file);
if (HttpUtil.isKeepAlive(req)) {
response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
}
if(zeroCopy) {
HttpUtil.setContentLength(response, fileLength);
} else {
HttpUtil.setTransferEncodingChunked(response, true);
ctx.pipeline().addLast(new HttpContentCompressor());
ctx.pipeline().addLast(new ChunkedWriteHandler());
//propagate the request to the new handlers in the pipeline that need to configure themselves
ctx.fireChannelRead(req);
}
// Write the initial line and the header.
ctx.channel().writeAndFlush(response);
// Write the content.
ChannelFuture sendFileFuture;
ChannelFuture lastContentFuture;
if (zeroCopy) {
sendFileFuture = ctx.writeAndFlush(new DefaultFileRegion(raf.getChannel(), 0, fileLength), ctx.newProgressivePromise());
// Write the end marker.
lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
} else {
sendFileFuture = ctx.channel().writeAndFlush(new HttpChunkedInput(new ChunkedFile(raf, 0, fileLength, 8192)), ctx.newProgressivePromise());
lastContentFuture = sendFileFuture;
}
final File finalFile = file;
sendFileFuture.addListener(new ChannelProgressiveFutureListener() {
@Override
public void operationProgressed(ChannelProgressiveFuture future, long progress, long total) {
if (log.isTraceEnabled()) {
if (total < 0) { // total unknown
log.trace(future.channel() + " Transfer progress: " + progress);
} else {
log.trace(future.channel() + " Transfer progress: " + progress + " / " + total);
}
}
}
@Override
public void operationComplete(ChannelProgressiveFuture future) {
if (log.isDebugEnabled()) {
log.debug(future.channel() + " Transfer complete: " +finalFile);
}
}
});
log.info("{} {} 200", req.method(), req.uri());
if (!HttpUtil.isKeepAlive(req)) {
lastContentFuture.addListener(ChannelFutureListener.CLOSE);
}
}
/**
* Sets the content type header for the HTTP Response
*
* @param file
* file to extract content type
*/
private static void setContentTypeHeader(HttpResponse response, File file) {
response.headers().set(HttpHeaderNames.CONTENT_TYPE, mimeTypesMap.getContentType(file.getPath()));
}
/**
* Sets the Date and Cache headers for the HTTP Response
*
* @param fileToCache
* file to extract content type
*/
private static void setDateAndCacheHeaders(HttpResponse response, File fileToCache) {
SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
// Date header
Calendar time = new GregorianCalendar();
response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime()));
// Add cache headers
time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
response.headers().set(HttpHeaderNames.EXPIRES, dateFormatter.format(time.getTime()));
response.headers().set(HttpHeaderNames.CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
response.headers().set(HttpHeaderNames.LAST_MODIFIED, dateFormatter.format(new Date(fileToCache.lastModified())));
}
/**
* When file timestamp is the same as what the browser is sending up, send a "304 Not Modified"
*/
private void sendNotModified(ChannelHandlerContext ctx, HttpRequest req) {
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.NOT_MODIFIED);
setDateHeader(response);
log.info("{} {} 304", req.method(), req.uri());
// Close the connection as soon as the error message is sent.
ctx.channel().writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private String sanitizePath(String path) {
path = path.replace('/', File.separatorChar);
if (path.contains(File.separator + ".") ||
path.contains("." + File.separator) ||
path.startsWith(".") || path.endsWith(".")) {
return null;
}
return path;
}
}