//
// Copyright 2010 Cinch Logic Pty Ltd.
//
// http://www.chililog.com
//
// 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 org.chililog.server.workbench;
import static org.jboss.netty.handler.codec.http.HttpHeaders.*;
import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*;
import static org.jboss.netty.handler.codec.http.HttpResponseStatus.*;
import static org.jboss.netty.handler.codec.http.HttpVersion.*;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.RandomAccessFile;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import javax.activation.MimetypesFileTypeMap;
import org.apache.commons.lang.StringUtils;
import org.chililog.server.common.AppProperties;
import org.chililog.server.common.Log4JLogger;
import org.jboss.netty.buffer.ChannelBuffers;
import org.jboss.netty.channel.Channel;
import org.jboss.netty.channel.ChannelFuture;
import org.jboss.netty.channel.ChannelFutureListener;
import org.jboss.netty.channel.ChannelHandlerContext;
import org.jboss.netty.channel.MessageEvent;
import org.jboss.netty.handler.codec.http.DefaultHttpResponse;
import org.jboss.netty.handler.codec.http.HttpHeaders;
import org.jboss.netty.handler.codec.http.HttpMethod;
import org.jboss.netty.handler.codec.http.HttpRequest;
import org.jboss.netty.handler.codec.http.HttpResponse;
import org.jboss.netty.handler.codec.http.HttpResponseStatus;
import org.jboss.netty.handler.stream.ChunkedFile;
import org.jboss.netty.util.CharsetUtil;
/**
* <p>
* Static file service serves static files stored on the file system.
* </p>
* <p>
* The root directory under which to search for file is specified by the <code>web.static_files.directory</code> in the
* <code>app.properties</code> file.
* </p>
* <p>
* The number of seconds that browsers are expected to cache all files is specified by the
* <code>web.static_files.cache_seconds</code> in the <code>app.properties</code> file. This is how caching works
* </p>
*
* <pre>
* Request #1 Headers
* ===================
* GET /static/file1.txt HTTP/1.1
*
* Response #1 Headers
* ===================
* HTTP/1.1 200 OK
* Date: Tue, 01 Mar 2011 22:44:26 GMT
* Last-Modified: Wed, 30 Jun 2010 21:36:48 GMT
* Expires: Tue, 01 Mar 2012 22:44:26 GMT
* Cache-Control: private, max-age=31536000
*
* Request #2 Headers
* ===================
* GET /static/file1.txt HTTP/1.1
* If-Modified-Since: Wed, 30 Jun 2010 21:36:48 GMT
*
* Response #2 Headers
* ===================
* HTTP/1.1 304 Not Modified
* Date: Tue, 01 Mar 2011 22:44:28 GMT
*
* </pre>
*
* <p>
* Compression is turned off for all files except those that:
* <ul>
* <li>have an extension of ".html", ".txt", ".json", ".js", ".xml" or ".css", and</li>
* <li>are between 4K and 1MB in size.</li>
* </ul>
* File extension restrictions are put in as these types of text files are the most common and compress well. We don't
* want to compress files that are too small because the result can be bigger than the original. Also, we don't want to
* compress files that are too big because it can waste CPU.
* </p>
* <p>
* This code is based on the Netty HTTP File Server sample (http://www.jboss.org/netty/documentation.html).
* </p>
*/
public class StaticFileRequestHandler extends WorkbenchRequestHandler {
private static Log4JLogger _logger = Log4JLogger.getLogger(StaticFileRequestHandler.class);
/**
* Process the message
*/
@Override
public void processMessage(ChannelHandlerContext ctx, MessageEvent e) throws Exception {
HttpRequest request = (HttpRequest) e.getMessage();
// We don't handle 100 Continue because we only allow GET method.
if (request.getMethod() != HttpMethod.GET) {
sendError(ctx, e, METHOD_NOT_ALLOWED, null);
return;
}
// Check
final String filePath = convertUriToPhysicalFilePath(request.getUri());
if (filePath == null) {
sendError(ctx, e, FORBIDDEN, null);
return;
}
File file = new File(filePath);
if (file.isHidden() || !file.exists()) {
sendError(ctx, e, NOT_FOUND, String.format("%s not exist", file.getCanonicalPath()));
return;
}
if (!file.isFile()) {
sendError(ctx, e, FORBIDDEN, String.format("%s not a file", file.getCanonicalPath()));
return;
}
// Cache Validation
String ifModifiedSince = request.getHeader(HttpHeaders.Names.IF_MODIFIED_SINCE);
if (!StringUtils.isBlank(ifModifiedSince)) {
SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
Date 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, e);
return;
}
}
// Open file for sending back
RandomAccessFile raf;
try {
raf = new RandomAccessFile(file, "r");
} catch (FileNotFoundException fnfe) {
sendError(ctx, e, NOT_FOUND, null);
return;
}
long fileLength = raf.length();
// Log
writeLogEntry(e, OK, null);
// Create the response
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
setContentLength(response, fileLength);
setContentTypeHeader(response, file);
setDateAndCacheHeaders(response, file);
// Write the content.
Channel ch = e.getChannel();
ChannelFuture writeFuture;
if (AppProperties.getInstance().getWorkbenchSslEnabled()) {
// Cannot use zero-copy with HTTPS
// Write the initial line and the header.
ch.write(response);
// Write chunks
writeFuture = ch.write(new ChunkedFile(raf, 0, fileLength, 8192));
} else {
// Now that we are using Execution Handlers, we cannot do zero-copy.
// Do as per with compression (which is what most browser will ask for)
byte[] buffer = new byte[(int) fileLength];
raf.readFully(buffer);
raf.close();
response.setContent(ChannelBuffers.copiedBuffer(buffer));
writeFuture = ch.write(response);
/*
* // No encryption - use zero-copy. // However zero-copy does not seem to work with compression // Only use
* zero-copy for large files like movies and music // Write the initial line and the header.
* ch.write(response); // Zero-copy final FileRegion region = new DefaultFileRegion(raf.getChannel(), 0,
* fileLength); writeFuture = ch.write(region); writeFuture.addListener(new ChannelFutureProgressListener()
* { public void operationComplete(ChannelFuture future) { region.releaseExternalResources(); } public void
* operationProgressed(ChannelFuture future, long amount, long current, long total) {
* _logger.debug("Zero-Coping file %s: %d / %d (+%d) bytes", filePath, current, total, amount); } });
*/
}
// Decide whether to close the connection or not.
if (!isKeepAlive(request)) {
// Close the connection when the whole content is written out.
writeFuture.addListener(ChannelFutureListener.CLOSE);
}
}
/**
* Converts the request URI to a file path
*
* @param uri
* @return
* @throws UnsupportedEncodingException
*/
private String convertUriToPhysicalFilePath(String uri) throws UnsupportedEncodingException {
// Decode the path.
try {
uri = URLDecoder.decode(uri, "UTF-8");
} catch (UnsupportedEncodingException e) {
uri = URLDecoder.decode(uri, "ISO-8859-1");
}
// Remove the initial /static/ or /workbench/ prefix
int idx = uri.indexOf('/', 1);
if (idx < 0) {
return null;
}
uri = uri.substring(idx);
if (StringUtils.isBlank(uri)) {
return null;
}
// Remove query string if any
idx = uri.indexOf('?');
if (idx > 0) {
uri = uri.substring(0, idx);
}
// Convert file separators.
uri = uri.replace('/', File.separatorChar);
// Simplistic dumb security check.
// You will have to do something serious in the production environment.
if (uri.contains(File.separator + ".") || uri.contains("." + File.separator) || uri.startsWith(".")
|| uri.endsWith(".")) {
return null;
}
// Convert to absolute path.
return AppProperties.getInstance().getWorkbenchStaticFilesDirectory() + uri;
}
/**
* Send error to client
*
* @param ctx
* Context
* @param e
* Message Event
* @param status
* HTTP response status
* @param moreInfo
* More details of the error to log
*/
private void sendError(ChannelHandlerContext ctx, MessageEvent e, HttpResponseStatus status, String moreInfo) {
writeLogEntry(e, status, moreInfo);
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, status);
setDateHeader(response);
// Send error back as plain text in the body
response.setHeader(CONTENT_TYPE, "text/plain; charset=UTF-8");
response.setContent(ChannelBuffers.copiedBuffer("Failure: " + status.toString() + "\r\n", CharsetUtil.UTF_8));
ChannelFuture writeFuture = ctx.getChannel().write(response);
// Decide whether to close the connection or not.
if (!isKeepAlive((HttpRequest)e.getMessage())) {
writeFuture.addListener(ChannelFutureListener.CLOSE);
}
}
/**
* When file timestamp is the same as what the browser is sending up, send a "304 Not Modified"
*
* @param ctx
* Context
* @param e
* Message Event
*/
private void sendNotModified(ChannelHandlerContext ctx, MessageEvent e) {
writeLogEntry(e, HttpResponseStatus.NOT_MODIFIED, null);
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.NOT_MODIFIED);
setDateHeader(response);
ChannelFuture writeFuture = ctx.getChannel().write(response);
// Decide whether to close the connection or not.
if (!isKeepAlive((HttpRequest)e.getMessage())) {
writeFuture.addListener(ChannelFutureListener.CLOSE);
}
}
/**
* Sets the content type header for the HTTP Response
*
* @param response
* HTTP response
* @param file
* file to extract content type
*/
private void setContentTypeHeader(HttpResponse response, File file) {
String mimeType = null;
String filePath = file.getPath();
int idx = filePath.lastIndexOf('.');
if (idx == -1) {
mimeType = "application/octet-stream";
} else {
String fileExtension = filePath.substring(idx).toLowerCase();
// Try common types first
if (fileExtension.equals(".html")) {
mimeType = "text/html";
} else if (fileExtension.equals(".css")) {
mimeType = "text/css";
} else if (fileExtension.equals(".js")) {
mimeType = "application/javascript";
} else if (fileExtension.equals(".gif")) {
mimeType = "image/gif";
} else if (fileExtension.equals(".png")) {
mimeType = "image/png";
} else if (fileExtension.equals(".txt")) {
mimeType = "text/plain";
} else if (fileExtension.equals(".xml")) {
mimeType = "application/xml";
} else if (fileExtension.equals(".json")) {
mimeType = "application/json";
} else {
MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
mimeType = mimeTypesMap.getContentType(file.getPath());
}
}
response.setHeader(HttpHeaders.Names.CONTENT_TYPE, mimeType);
}
/**
* Sets the Date header for the HTTP response
*
* @param response
* HTTP response
* @param file
* file to extract content type
*/
private void setDateHeader(HttpResponse response) {
SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
Calendar time = new GregorianCalendar();
response.setHeader(HttpHeaders.Names.DATE, dateFormatter.format(time.getTime()));
}
/**
* Sets the Date and Cache headers for the HTTP Response
*
* @param response
* HTTP response
* @param file
* file to extract content type
*/
private 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.setHeader(HttpHeaders.Names.DATE, dateFormatter.format(time.getTime()));
// Add cache headers
time.add(Calendar.SECOND, AppProperties.getInstance().getWorkbenchStaticFilesCacheSeconds());
response.setHeader(HttpHeaders.Names.EXPIRES, dateFormatter.format(time.getTime()));
response.setHeader(HttpHeaders.Names.CACHE_CONTROL, "private, max-age="
+ AppProperties.getInstance().getWorkbenchStaticFilesCacheSeconds());
response.setHeader(HttpHeaders.Names.LAST_MODIFIED, dateFormatter.format(new Date(filetoCache.lastModified())));
}
/**
* Write audit log entry
*
* @param e
* Message Event
*/
private void writeLogEntry(MessageEvent e, HttpResponseStatus status, String moreInfo) {
HttpRequest request = (HttpRequest) e.getMessage();
_logger.info("GET %s REMOTE_IP=%s STATUS=%s CHANNEL=%s %s", request.getUri(), e.getRemoteAddress().toString(),
status, e.getChannel().getId(), moreInfo == null ? StringUtils.EMPTY : moreInfo);
StringBuilder sb = new StringBuilder("Request Headers\n");
List<java.util.Map.Entry<String, String>> headers = request.getHeaders();
for (java.util.Map.Entry<String, String> entry : headers){
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("\n");
}
_logger.debug(sb.toString());
return;
}
}