/**
* Copyright 2011-2014 the original author or authors.
*/
package com.jetdrone.vertx.yoke.middleware;
import com.jetdrone.vertx.yoke.MimeType;
import com.jetdrone.vertx.yoke.util.Utils;
import org.jetbrains.annotations.NotNull;
import org.vertx.java.core.*;
import org.vertx.java.core.file.FileProps;
import org.vertx.java.core.file.FileSystem;
import org.vertx.java.core.json.JsonArray;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;
/**
* # Static
*
* Static file server with the given ```root``` path. Optionaly will also generate index pages for directory listings.
*/
public class Static extends AbstractMiddleware {
/**
* SimpleDateFormat to format date objects into ISO format.
*/
private final SimpleDateFormat ISODATE;
/**
* Cache for the HTML template of the directory listing page
*/
private final String directoryTemplate;
/**
* Root directory where to look files from
*/
private final String root;
/**
* Max age allowed for cache of resources
*/
private final long maxAge;
/**
* Allow directory listing
*/
private final boolean directoryListing;
/**
* Include hidden files (Hiden files are files start start with dot (.).
*/
private final boolean includeHidden;
/**
* Create a new Static File Server Middleware
*
* <pre>
* new Yoke(...)
* .use(new Static("webroot", 0, true, false));
* </pre>
*
* @param root the root location of the static files in the file system (relative to the main Verticle).
* @param maxAge cache-control max-age directive
* @param directoryListing generate index pages for directories
* @param includeHidden in the directory listing show dot files
*/
public Static(@NotNull String root, final long maxAge, final boolean directoryListing, final boolean includeHidden) {
// if the root is not empty it should end with / for convenience
if (!"".equals(root)) {
if (!root.endsWith("/")) {
root = root + "/";
}
}
this.root = root;
this.maxAge = maxAge;
this.includeHidden = includeHidden;
this.directoryListing = directoryListing;
this.directoryTemplate = Utils.readResourceToBuffer(getClass(), "directory.html").toString();
ISODATE = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz");
ISODATE.setTimeZone(TimeZone.getTimeZone("UTC"));
}
/**
* Create a new Static File Server Middleware that does not generate directory listings or hidden files
*
* <pre>
* new Yoke(...)
* .use(new Static("webroot", 0));
* </pre>
*
* @param root the root location of the static files in the file system (relative to the main Verticle).
* @param maxAge cache-control max-age directive
*/
public Static(@NotNull final String root, final long maxAge) {
this(root, maxAge, false, false);
}
/**
* Create a new Static File Server Middleware that does not generate directory listings or hidden files and files
* are cache for 1 full day
*
* <pre>
* new Yoke(...)
* .use(new Static("webroot"));
* </pre>
*
* @param root the root location of the static files in the file system (relative to the main Verticle).
*/
public Static(@NotNull final String root) {
this(root, 86400000, false, false);
}
/**
* Create all required header so content can be cache by Caching servers or Browsers
*
* @param request
* @param props
*/
private void writeHeaders(final YokeRequest request, final FileProps props) {
MultiMap headers = request.response().headers();
if (!headers.contains("etag")) {
headers.set("etag", "\"" + props.size() + "-" + props.lastModifiedTime().getTime() + "\"");
}
if (!headers.contains("date")) {
headers.set("date", ISODATE.format(new Date()));
}
if (!headers.contains("cache-control")) {
headers.set("cache-control", "public, max-age=" + maxAge / 1000);
}
if (!headers.contains("last-modified")) {
headers.set("last-modified", ISODATE.format(props.lastModifiedTime()));
}
}
/**
* Write a file into the response body
*
* @param request
* @param file
* @param props
*/
private void sendFile(final YokeRequest request, final String file, final FileProps props) {
// write content type
String contentType = MimeType.getMime(file);
String charset = MimeType.getCharset(contentType);
request.response().setContentType(contentType, charset);
request.response().putHeader("Content-Length", Long.toString(props.size()));
// head support
if ("HEAD".equals(request.method())) {
request.response().end();
} else {
request.response().sendFile(file);
}
}
/**
* Generate Directory listing
*
* @param request
* @param dir
* @param next
*/
private void sendDirectory(final YokeRequest request, final String dir, final Handler<Object> next) {
final FileSystem fileSystem = vertx().fileSystem();
fileSystem.readDir(dir, new AsyncResultHandler<String[]>() {
@Override
public void handle(AsyncResult<String[]> asyncResult) {
if (asyncResult.failed()) {
next.handle(asyncResult.cause());
} else {
String accept = request.getHeader("accept", "text/plain");
if (accept.contains("html")) {
String normalizedDir = dir.substring(root.length());
if (!normalizedDir.endsWith("/")) {
normalizedDir += "/";
}
String file;
StringBuilder files = new StringBuilder("<ul id=\"files\">");
for (String s : asyncResult.result()) {
file = s.substring(s.lastIndexOf('/') + 1);
// skip dot files
if (!includeHidden && file.charAt(0) == '.') {
continue;
}
files.append("<li><a href=\"");
files.append(normalizedDir);
files.append(file);
files.append("\" title=\"");
files.append(file);
files.append("\">");
files.append(file);
files.append("</a></li>");
}
files.append("</ul>");
StringBuilder directory = new StringBuilder();
// define access to root
directory.append("<a href=\"/\">/</a> ");
StringBuilder expandingPath = new StringBuilder();
String[] dirParts = normalizedDir.split("/");
for (int i = 1; i < dirParts.length; i++) {
// dynamic expansion
expandingPath.append("/");
expandingPath.append(dirParts[i]);
// anchor building
if (i > 1) {
directory.append(" / ");
}
directory.append("<a href=\"");
directory.append(expandingPath.toString());
directory.append("\">");
directory.append(dirParts[i]);
directory.append("</a>");
}
request.response().setContentType("text/html");
request.response().end(
directoryTemplate.replace("{title}", (String) request.get("title")).replace("{directory}", normalizedDir)
.replace("{linked-path}", directory.toString())
.replace("{files}", files.toString()));
} else if (accept.contains("json")) {
String file;
JsonArray json = new JsonArray();
for (String s : asyncResult.result()) {
file = s.substring(s.lastIndexOf('/') + 1);
// skip dot files
if (!includeHidden && file.charAt(0) == '.') {
continue;
}
json.addString(file);
}
request.response().end(json);
} else {
String file;
StringBuilder buffer = new StringBuilder();
for (String s : asyncResult.result()) {
file = s.substring(s.lastIndexOf('/') + 1);
// skip dot files
if (!includeHidden && file.charAt(0) == '.') {
continue;
}
buffer.append(file);
buffer.append('\n');
}
request.response().setContentType("text/plain");
request.response().end(buffer.toString());
}
}
}
});
}
/**
* Verify if a resource is fresh, fresh means that its cache headers are validated against the local resource and
* etags last-modified headers are still the same.
*
* @param request
* @return {boolean}
*/
private boolean isFresh(final YokeRequest request) {
// defaults
boolean etagMatches = true;
boolean notModified = true;
// fields
String modifiedSince = request.getHeader("if-modified-since");
String noneMatch = request.getHeader("if-none-match");
String[] noneMatchTokens = null;
String lastModified = request.response().getHeader("last-modified");
String etag = request.response().getHeader("etag");
// unconditional request
if (modifiedSince == null && noneMatch == null) {
return false;
}
// parse if-none-match
if (noneMatch != null) {
noneMatchTokens = noneMatch.split(" *, *");
}
// if-none-match
if (noneMatchTokens != null) {
etagMatches = false;
for (String s : noneMatchTokens) {
if (etag.equals(s) || "*".equals(noneMatchTokens[0])) {
etagMatches = true;
break;
}
}
}
// if-modified-since
if (modifiedSince != null) {
try {
Date modifiedSinceDate = ISODATE.parse(modifiedSince);
Date lastModifiedDate = ISODATE.parse(lastModified);
notModified = lastModifiedDate.getTime() <= modifiedSinceDate.getTime();
} catch (ParseException e) {
notModified = false;
}
}
return etagMatches && notModified;
}
@Override
public void handle(@NotNull final YokeRequest request, @NotNull final Handler<Object> next) {
if (!"GET".equals(request.method()) && !"HEAD".equals(request.method())) {
next.handle(null);
} else {
String path = request.normalizedPath();
// if the normalized path is null it cannot be resolved
if (path == null) {
next.handle(404);
return;
}
// map file path from the request
// the final path is, root + request.path excluding mount
final String file = root + path.substring(mount.length());
if (!includeHidden) {
int idx = file.lastIndexOf('/');
String name = file.substring(idx + 1);
if (name.length() > 0 && name.charAt(0) == '.') {
next.handle(null);
return;
}
}
final FileSystem fileSystem = vertx().fileSystem();
fileSystem.exists(file, new AsyncResultHandler<Boolean>() {
@Override
public void handle(AsyncResult<Boolean> asyncResult) {
if (asyncResult.failed()) {
next.handle(asyncResult.cause());
} else {
if (!asyncResult.result()) {
// no static file found, let the next middleware handle it
next.handle(null);
} else {
fileSystem.props(file, new AsyncResultHandler<FileProps>() {
@Override
public void handle(AsyncResult<FileProps> props) {
if (props.failed()) {
next.handle(props.cause());
} else {
if (props.result().isDirectory()) {
if (directoryListing) {
// write cache control headers
writeHeaders(request, props.result());
// verify if we are still fresh
if (isFresh(request)) {
request.response().setStatusCode(304);
request.response().end();
} else {
sendDirectory(request, file, next);
}
} else {
// we are not listing directories
next.handle(null);
}
} else {
// write cache control headers
writeHeaders(request, props.result());
// verify if we are still fresh
if (isFresh(request)) {
request.response().setStatusCode(304);
request.response().end();
} else {
sendFile(request, file, props.result());
}
}
}
}
});
}
}
}
});
}
}
}