| package fi.iki.elonen; |
| |
| import java.io.File; |
| import java.io.FileInputStream; |
| import java.io.FilenameFilter; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.UnsupportedEncodingException; |
| import java.net.URLEncoder; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.Iterator; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.ServiceLoader; |
| import java.util.StringTokenizer; |
| |
| public class SimpleWebServer extends NanoHTTPD { |
| /** |
| * Common mime type for dynamic content: binary |
| */ |
| public static final String MIME_DEFAULT_BINARY = "application/octet-stream"; |
| /** |
| * Default Index file names. |
| */ |
| public static final List<String> INDEX_FILE_NAMES = new ArrayList<String>() {{ |
| add("index.html"); |
| add("index.htm"); |
| }}; |
| /** |
| * Hashtable mapping (String)FILENAME_EXTENSION -> (String)MIME_TYPE |
| */ |
| private static final Map<String, String> MIME_TYPES = new HashMap<String, String>() {{ |
| put("css", "text/css"); |
| put("htm", "text/html"); |
| put("html", "text/html"); |
| put("xml", "text/xml"); |
| put("java", "text/x-java-source, text/java"); |
| put("md", "text/plain"); |
| put("txt", "text/plain"); |
| put("asc", "text/plain"); |
| put("gif", "image/gif"); |
| put("jpg", "image/jpeg"); |
| put("jpeg", "image/jpeg"); |
| put("png", "image/png"); |
| put("mp3", "audio/mpeg"); |
| put("m3u", "audio/mpeg-url"); |
| put("mp4", "video/mp4"); |
| put("ogv", "video/ogg"); |
| put("flv", "video/x-flv"); |
| put("mov", "video/quicktime"); |
| put("swf", "application/x-shockwave-flash"); |
| put("js", "application/javascript"); |
| put("pdf", "application/pdf"); |
| put("doc", "application/msword"); |
| put("ogg", "application/x-ogg"); |
| put("zip", "application/octet-stream"); |
| put("exe", "application/octet-stream"); |
| put("class", "application/octet-stream"); |
| }}; |
| /** |
| * The distribution licence |
| */ |
| private static final String LICENCE = |
| "Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias\n" |
| + "\n" |
| + "Redistribution and use in source and binary forms, with or without\n" |
| + "modification, are permitted provided that the following conditions\n" |
| + "are met:\n" |
| + "\n" |
| + "Redistributions of source code must retain the above copyright notice,\n" |
| + "this list of conditions and the following disclaimer. Redistributions in\n" |
| + "binary form must reproduce the above copyright notice, this list of\n" |
| + "conditions and the following disclaimer in the documentation and/or other\n" |
| + "materials provided with the distribution. The name of the author may not\n" |
| + "be used to endorse or promote products derived from this software without\n" |
| + "specific prior written permission. \n" |
| + " \n" |
| + "THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR\n" |
| + "IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES\n" |
| + "OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.\n" |
| + "IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,\n" |
| + "INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT\n" |
| + "NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\n" |
| + "DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\n" |
| + "THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n" |
| + "(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n" |
| + "OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."; |
| private static Map<String, WebServerPlugin> mimeTypeHandlers = new HashMap<String, WebServerPlugin>(); |
| private final List<File> rootDirs; |
| private final boolean quiet; |
| |
| public SimpleWebServer(String host, int port, File wwwroot, boolean quiet) { |
| super(host, port); |
| this.quiet = quiet; |
| this.rootDirs = new ArrayList<File>(); |
| this.rootDirs.add(wwwroot); |
| |
| this.init(); |
| } |
| |
| public SimpleWebServer(String host, int port, List<File> wwwroots, boolean quiet) { |
| super(host, port); |
| this.quiet = quiet; |
| this.rootDirs = new ArrayList<File>(wwwroots); |
| |
| this.init(); |
| } |
| |
| /** |
| * Used to initialize and customize the server. |
| */ |
| public void init() { |
| } |
| |
| /** |
| * Starts as a standalone file server and waits for Enter. |
| */ |
| public static void main(String[] args) { |
| // Defaults |
| int port = 8080; |
| |
| String host = "127.0.0.1"; |
| List<File> rootDirs = new ArrayList<File>(); |
| boolean quiet = false; |
| Map<String, String> options = new HashMap<String, String>(); |
| |
| // Parse command-line, with short and long versions of the options. |
| for (int i = 0; i < args.length; ++i) { |
| if (args[i].equalsIgnoreCase("-h") || args[i].equalsIgnoreCase("--host")) { |
| host = args[i + 1]; |
| } else if (args[i].equalsIgnoreCase("-p") || args[i].equalsIgnoreCase("--port")) { |
| port = Integer.parseInt(args[i + 1]); |
| } else if (args[i].equalsIgnoreCase("-q") || args[i].equalsIgnoreCase("--quiet")) { |
| quiet = true; |
| } else if (args[i].equalsIgnoreCase("-d") || args[i].equalsIgnoreCase("--dir")) { |
| rootDirs.add(new File(args[i + 1]).getAbsoluteFile()); |
| } else if (args[i].equalsIgnoreCase("--licence")) { |
| System.out.println(LICENCE + "\n"); |
| } else if (args[i].startsWith("-X:")) { |
| int dot = args[i].indexOf('='); |
| if (dot > 0) { |
| String name = args[i].substring(0, dot); |
| String value = args[i].substring(dot + 1, args[i].length()); |
| options.put(name, value); |
| } |
| } |
| } |
| |
| if (rootDirs.isEmpty()) { |
| rootDirs.add(new File(".").getAbsoluteFile()); |
| } |
| |
| options.put("host", host); |
| options.put("port", ""+port); |
| options.put("quiet", String.valueOf(quiet)); |
| StringBuilder sb = new StringBuilder(); |
| for (File dir : rootDirs) { |
| if (sb.length() > 0) { |
| sb.append(":"); |
| } |
| try { |
| sb.append(dir.getCanonicalPath()); |
| } catch (IOException ignored) {} |
| } |
| options.put("home", sb.toString()); |
| |
| ServiceLoader<WebServerPluginInfo> serviceLoader = ServiceLoader.load(WebServerPluginInfo.class); |
| for (WebServerPluginInfo info : serviceLoader) { |
| String[] mimeTypes = info.getMimeTypes(); |
| for (String mime : mimeTypes) { |
| String[] indexFiles = info.getIndexFilesForMimeType(mime); |
| if (!quiet) { |
| System.out.print("# Found plugin for Mime type: \"" + mime + "\""); |
| if (indexFiles != null) { |
| System.out.print(" (serving index files: "); |
| for (String indexFile : indexFiles) { |
| System.out.print(indexFile + " "); |
| } |
| } |
| System.out.println(")."); |
| } |
| registerPluginForMimeType(indexFiles, mime, info.getWebServerPlugin(mime), options); |
| } |
| } |
| |
| ServerRunner.executeInstance(new SimpleWebServer(host, port, rootDirs, quiet)); |
| } |
| |
| protected static void registerPluginForMimeType(String[] indexFiles, String mimeType, WebServerPlugin plugin, Map<String, String> commandLineOptions) { |
| if (mimeType == null || plugin == null) { |
| return; |
| } |
| |
| if (indexFiles != null) { |
| for (String filename : indexFiles) { |
| int dot = filename.lastIndexOf('.'); |
| if (dot >= 0) { |
| String extension = filename.substring(dot + 1).toLowerCase(); |
| MIME_TYPES.put(extension, mimeType); |
| } |
| } |
| INDEX_FILE_NAMES.addAll(Arrays.asList(indexFiles)); |
| } |
| mimeTypeHandlers.put(mimeType, plugin); |
| plugin.initialize(commandLineOptions); |
| } |
| |
| private File getRootDir() { |
| return rootDirs.get(0); |
| } |
| |
| private List<File> getRootDirs() { |
| return rootDirs; |
| } |
| |
| private void addWwwRootDir(File wwwroot) { |
| rootDirs.add(wwwroot); |
| } |
| |
| /** |
| * URL-encodes everything between "/"-characters. Encodes spaces as '%20' instead of '+'. |
| */ |
| private String encodeUri(String uri) { |
| String newUri = ""; |
| StringTokenizer st = new StringTokenizer(uri, "/ ", true); |
| while (st.hasMoreTokens()) { |
| String tok = st.nextToken(); |
| if (tok.equals("/")) |
| newUri += "/"; |
| else if (tok.equals(" ")) |
| newUri += "%20"; |
| else { |
| try { |
| newUri += URLEncoder.encode(tok, "UTF-8"); |
| } catch (UnsupportedEncodingException ignored) { |
| } |
| } |
| } |
| return newUri; |
| } |
| |
| public Response serve(IHTTPSession session) { |
| Map<String, String> header = session.getHeaders(); |
| Map<String, String> parms = session.getParms(); |
| String uri = session.getUri(); |
| |
| if (!quiet) { |
| System.out.println(session.getMethod() + " '" + uri + "' "); |
| |
| Iterator<String> e = header.keySet().iterator(); |
| while (e.hasNext()) { |
| String value = e.next(); |
| System.out.println(" HDR: '" + value + "' = '" + header.get(value) + "'"); |
| } |
| e = parms.keySet().iterator(); |
| while (e.hasNext()) { |
| String value = e.next(); |
| System.out.println(" PRM: '" + value + "' = '" + parms.get(value) + "'"); |
| } |
| } |
| |
| for (File homeDir : getRootDirs()) { |
| // Make sure we won't die of an exception later |
| if (!homeDir.isDirectory()) { |
| return getInternalErrorResponse("given path is not a directory (" + homeDir + ")."); |
| } |
| } |
| return respond(Collections.unmodifiableMap(header), session, uri); |
| } |
| |
| private Response respond(Map<String, String> headers, IHTTPSession session, String uri) { |
| // Remove URL arguments |
| uri = uri.trim().replace(File.separatorChar, '/'); |
| if (uri.indexOf('?') >= 0) { |
| uri = uri.substring(0, uri.indexOf('?')); |
| } |
| |
| // Prohibit getting out of current directory |
| if (uri.startsWith("src/main") || uri.endsWith("src/main") || uri.contains("../")) { |
| return getForbiddenResponse("Won't serve ../ for security reasons."); |
| } |
| |
| boolean canServeUri = false; |
| File homeDir = null; |
| List<File> roots = getRootDirs(); |
| for (int i = 0; !canServeUri && i < roots.size(); i++) { |
| homeDir = roots.get(i); |
| canServeUri = canServeUri(uri, homeDir); |
| } |
| if (!canServeUri) { |
| return getNotFoundResponse(); |
| } |
| |
| // Browsers get confused without '/' after the directory, send a redirect. |
| File f = new File(homeDir, uri); |
| if (f.isDirectory() && !uri.endsWith("/")) { |
| uri += "/"; |
| Response res = createResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, "<html><body>Redirected: <a href=\"" + |
| uri + "\">" + uri + "</a></body></html>"); |
| res.addHeader("Location", uri); |
| return res; |
| } |
| |
| if (f.isDirectory()) { |
| // First look for index files (index.html, index.htm, etc) and if none found, list the directory if readable. |
| String indexFile = findIndexFileInDirectory(f); |
| if (indexFile == null) { |
| if (f.canRead()) { |
| // No index file, list the directory if it is readable |
| return createResponse(Response.Status.OK, NanoHTTPD.MIME_HTML, listDirectory(uri, f)); |
| } else { |
| return getForbiddenResponse("No directory listing."); |
| } |
| } else { |
| return respond(headers, session, uri + indexFile); |
| } |
| } |
| |
| String mimeTypeForFile = getMimeTypeForFile(uri); |
| WebServerPlugin plugin = mimeTypeHandlers.get(mimeTypeForFile); |
| Response response = null; |
| if (plugin != null) { |
| response = plugin.serveFile(uri, headers, session, f, mimeTypeForFile); |
| if (response != null && response instanceof InternalRewrite) { |
| InternalRewrite rewrite = (InternalRewrite) response; |
| return respond(rewrite.getHeaders(), session, rewrite.getUri()); |
| } |
| } else { |
| response = serveFile(uri, headers, f, mimeTypeForFile); |
| } |
| return response != null ? response : getNotFoundResponse(); |
| } |
| |
| protected Response getNotFoundResponse() { |
| return createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, |
| "Error 404, file not found."); |
| } |
| |
| protected Response getForbiddenResponse(String s) { |
| return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: " |
| + s); |
| } |
| |
| protected Response getInternalErrorResponse(String s) { |
| return createResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, |
| "INTERNAL ERRROR: " + s); |
| } |
| |
| private boolean canServeUri(String uri, File homeDir) { |
| boolean canServeUri; |
| File f = new File(homeDir, uri); |
| canServeUri = f.exists(); |
| if (!canServeUri) { |
| String mimeTypeForFile = getMimeTypeForFile(uri); |
| WebServerPlugin plugin = mimeTypeHandlers.get(mimeTypeForFile); |
| if (plugin != null) { |
| canServeUri = plugin.canServeUri(uri, homeDir); |
| } |
| } |
| return canServeUri; |
| } |
| |
| /** |
| * Serves file from homeDir and its' subdirectories (only). Uses only URI, ignores all headers and HTTP parameters. |
| */ |
| Response serveFile(String uri, Map<String, String> header, File file, String mime) { |
| Response res; |
| try { |
| // Calculate etag |
| String etag = Integer.toHexString((file.getAbsolutePath() + file.lastModified() + "" + file.length()).hashCode()); |
| |
| // Support (simple) skipping: |
| long startFrom = 0; |
| long endAt = -1; |
| String range = header.get("range"); |
| if (range != null) { |
| if (range.startsWith("bytes=")) { |
| range = range.substring("bytes=".length()); |
| int minus = range.indexOf('-'); |
| try { |
| if (minus > 0) { |
| startFrom = Long.parseLong(range.substring(0, minus)); |
| endAt = Long.parseLong(range.substring(minus + 1)); |
| } |
| } catch (NumberFormatException ignored) { |
| } |
| } |
| } |
| |
| // Change return code and add Content-Range header when skipping is requested |
| long fileLen = file.length(); |
| if (range != null && startFrom >= 0) { |
| if (startFrom >= fileLen) { |
| res = createResponse(Response.Status.RANGE_NOT_SATISFIABLE, NanoHTTPD.MIME_PLAINTEXT, ""); |
| res.addHeader("Content-Range", "bytes 0-0/" + fileLen); |
| res.addHeader("ETag", etag); |
| } else { |
| if (endAt < 0) { |
| endAt = fileLen - 1; |
| } |
| long newLen = endAt - startFrom + 1; |
| if (newLen < 0) { |
| newLen = 0; |
| } |
| |
| final long dataLen = newLen; |
| FileInputStream fis = new FileInputStream(file) { |
| @Override |
| public int available() throws IOException { |
| return (int) dataLen; |
| } |
| }; |
| fis.skip(startFrom); |
| |
| res = createResponse(Response.Status.PARTIAL_CONTENT, mime, fis); |
| res.addHeader("Content-Length", "" + dataLen); |
| res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + fileLen); |
| res.addHeader("ETag", etag); |
| } |
| } else { |
| if (etag.equals(header.get("if-none-match"))) |
| res = createResponse(Response.Status.NOT_MODIFIED, mime, ""); |
| else { |
| res = createResponse(Response.Status.OK, mime, new FileInputStream(file)); |
| res.addHeader("Content-Length", "" + fileLen); |
| res.addHeader("ETag", etag); |
| } |
| } |
| } catch (IOException ioe) { |
| res = getForbiddenResponse("Reading file failed."); |
| } |
| |
| return res; |
| } |
| |
| // Get MIME type from file name extension, if possible |
| private String getMimeTypeForFile(String uri) { |
| int dot = uri.lastIndexOf('.'); |
| String mime = null; |
| if (dot >= 0) { |
| mime = MIME_TYPES.get(uri.substring(dot + 1).toLowerCase()); |
| } |
| return mime == null ? MIME_DEFAULT_BINARY : mime; |
| } |
| |
| // Announce that the file server accepts partial content requests |
| private Response createResponse(Response.Status status, String mimeType, InputStream message) { |
| Response res = new Response(status, mimeType, message); |
| res.addHeader("Accept-Ranges", "bytes"); |
| return res; |
| } |
| |
| // Announce that the file server accepts partial content requests |
| private Response createResponse(Response.Status status, String mimeType, String message) { |
| Response res = new Response(status, mimeType, message); |
| res.addHeader("Accept-Ranges", "bytes"); |
| return res; |
| } |
| |
| private String findIndexFileInDirectory(File directory) { |
| for (String fileName : INDEX_FILE_NAMES) { |
| File indexFile = new File(directory, fileName); |
| if (indexFile.exists()) { |
| return fileName; |
| } |
| } |
| return null; |
| } |
| |
| protected String listDirectory(String uri, File f) { |
| String heading = "Directory " + uri; |
| StringBuilder msg = new StringBuilder("<html><head><title>" + heading + "</title><style><!--\n" + |
| "span.dirname { font-weight: bold; }\n" + |
| "span.filesize { font-size: 75%; }\n" + |
| "// -->\n" + |
| "</style>" + |
| "</head><body><h1>" + heading + "</h1>"); |
| |
| String up = null; |
| if (uri.length() > 1) { |
| String u = uri.substring(0, uri.length() - 1); |
| int slash = u.lastIndexOf('/'); |
| if (slash >= 0 && slash < u.length()) { |
| up = uri.substring(0, slash + 1); |
| } |
| } |
| |
| List<String> files = Arrays.asList(f.list(new FilenameFilter() { |
| @Override |
| public boolean accept(File dir, String name) { |
| return new File(dir, name).isFile(); |
| } |
| })); |
| Collections.sort(files); |
| List<String> directories = Arrays.asList(f.list(new FilenameFilter() { |
| @Override |
| public boolean accept(File dir, String name) { |
| return new File(dir, name).isDirectory(); |
| } |
| })); |
| Collections.sort(directories); |
| if (up != null || directories.size() + files.size() > 0) { |
| msg.append("<ul>"); |
| if (up != null || directories.size() > 0) { |
| msg.append("<section class=\"directories\">"); |
| if (up != null) { |
| msg.append("<li><a rel=\"directory\" href=\"").append(up).append("\"><span class=\"dirname\">..</span></a></b></li>"); |
| } |
| for (String directory : directories) { |
| String dir = directory + "/"; |
| msg.append("<li><a rel=\"directory\" href=\"").append(encodeUri(uri + dir)).append("\"><span class=\"dirname\">").append(dir).append("</span></a></b></li>"); |
| } |
| msg.append("</section>"); |
| } |
| if (files.size() > 0) { |
| msg.append("<section class=\"files\">"); |
| for (String file : files) { |
| msg.append("<li><a href=\"").append(encodeUri(uri + file)).append("\"><span class=\"filename\">").append(file).append("</span></a>"); |
| File curFile = new File(f, file); |
| long len = curFile.length(); |
| msg.append(" <span class=\"filesize\">("); |
| if (len < 1024) { |
| msg.append(len).append(" bytes"); |
| } else if (len < 1024 * 1024) { |
| msg.append(len / 1024).append(".").append(len % 1024 / 10 % 100).append(" KB"); |
| } else { |
| msg.append(len / (1024 * 1024)).append(".").append(len % (1024 * 1024) / 10 % 100).append(" MB"); |
| } |
| msg.append(")</span></li>"); |
| } |
| msg.append("</section>"); |
| } |
| msg.append("</ul>"); |
| } |
| msg.append("</body></html>"); |
| return msg.toString(); |
| } |
| } |