Home | History | Annotate | Download | only in elonen
      1 package fi.iki.elonen;
      2 
      3 import java.io.File;
      4 import java.io.FileInputStream;
      5 import java.io.FilenameFilter;
      6 import java.io.IOException;
      7 import java.io.InputStream;
      8 import java.io.UnsupportedEncodingException;
      9 import java.net.URLEncoder;
     10 import java.util.ArrayList;
     11 import java.util.Arrays;
     12 import java.util.Collections;
     13 import java.util.HashMap;
     14 import java.util.Iterator;
     15 import java.util.List;
     16 import java.util.Map;
     17 import java.util.ServiceLoader;
     18 import java.util.StringTokenizer;
     19 
     20 public class SimpleWebServer extends NanoHTTPD {
     21     /**
     22      * Common mime type for dynamic content: binary
     23      */
     24     public static final String MIME_DEFAULT_BINARY = "application/octet-stream";
     25     /**
     26      * Default Index file names.
     27      */
     28     public static final List<String> INDEX_FILE_NAMES = new ArrayList<String>() {{
     29         add("index.html");
     30         add("index.htm");
     31     }};
     32     /**
     33      * Hashtable mapping (String)FILENAME_EXTENSION -> (String)MIME_TYPE
     34      */
     35     private static final Map<String, String> MIME_TYPES = new HashMap<String, String>() {{
     36         put("css", "text/css");
     37         put("htm", "text/html");
     38         put("html", "text/html");
     39         put("xml", "text/xml");
     40         put("java", "text/x-java-source, text/java");
     41         put("md", "text/plain");
     42         put("txt", "text/plain");
     43         put("asc", "text/plain");
     44         put("gif", "image/gif");
     45         put("jpg", "image/jpeg");
     46         put("jpeg", "image/jpeg");
     47         put("png", "image/png");
     48         put("mp3", "audio/mpeg");
     49         put("m3u", "audio/mpeg-url");
     50         put("mp4", "video/mp4");
     51         put("ogv", "video/ogg");
     52         put("flv", "video/x-flv");
     53         put("mov", "video/quicktime");
     54         put("swf", "application/x-shockwave-flash");
     55         put("js", "application/javascript");
     56         put("pdf", "application/pdf");
     57         put("doc", "application/msword");
     58         put("ogg", "application/x-ogg");
     59         put("zip", "application/octet-stream");
     60         put("exe", "application/octet-stream");
     61         put("class", "application/octet-stream");
     62     }};
     63     /**
     64      * The distribution licence
     65      */
     66     private static final String LICENCE =
     67         "Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias\n"
     68             + "\n"
     69             + "Redistribution and use in source and binary forms, with or without\n"
     70             + "modification, are permitted provided that the following conditions\n"
     71             + "are met:\n"
     72             + "\n"
     73             + "Redistributions of source code must retain the above copyright notice,\n"
     74             + "this list of conditions and the following disclaimer. Redistributions in\n"
     75             + "binary form must reproduce the above copyright notice, this list of\n"
     76             + "conditions and the following disclaimer in the documentation and/or other\n"
     77             + "materials provided with the distribution. The name of the author may not\n"
     78             + "be used to endorse or promote products derived from this software without\n"
     79             + "specific prior written permission. \n"
     80             + " \n"
     81             + "THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR\n"
     82             + "IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES\n"
     83             + "OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.\n"
     84             + "IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,\n"
     85             + "INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT\n"
     86             + "NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\n"
     87             + "DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\n"
     88             + "THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n"
     89             + "(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n"
     90             + "OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.";
     91     private static Map<String, WebServerPlugin> mimeTypeHandlers = new HashMap<String, WebServerPlugin>();
     92     private final List<File> rootDirs;
     93     private final boolean quiet;
     94 
     95     public SimpleWebServer(String host, int port, File wwwroot, boolean quiet) {
     96         super(host, port);
     97         this.quiet = quiet;
     98         this.rootDirs = new ArrayList<File>();
     99         this.rootDirs.add(wwwroot);
    100 
    101         this.init();
    102     }
    103 
    104     public SimpleWebServer(String host, int port, List<File> wwwroots, boolean quiet) {
    105         super(host, port);
    106         this.quiet = quiet;
    107         this.rootDirs = new ArrayList<File>(wwwroots);
    108 
    109         this.init();
    110     }
    111 
    112 	/**
    113 	 * Used to initialize and customize the server.
    114 	 */
    115     public void init() {
    116     }
    117 
    118     /**
    119      * Starts as a standalone file server and waits for Enter.
    120      */
    121     public static void main(String[] args) {
    122         // Defaults
    123         int port = 8080;
    124 
    125         String host = "127.0.0.1";
    126         List<File> rootDirs = new ArrayList<File>();
    127         boolean quiet = false;
    128         Map<String, String> options = new HashMap<String, String>();
    129 
    130         // Parse command-line, with short and long versions of the options.
    131         for (int i = 0; i < args.length; ++i) {
    132             if (args[i].equalsIgnoreCase("-h") || args[i].equalsIgnoreCase("--host")) {
    133                 host = args[i + 1];
    134             } else if (args[i].equalsIgnoreCase("-p") || args[i].equalsIgnoreCase("--port")) {
    135                 port = Integer.parseInt(args[i + 1]);
    136             } else if (args[i].equalsIgnoreCase("-q") || args[i].equalsIgnoreCase("--quiet")) {
    137                 quiet = true;
    138             } else if (args[i].equalsIgnoreCase("-d") || args[i].equalsIgnoreCase("--dir")) {
    139                 rootDirs.add(new File(args[i + 1]).getAbsoluteFile());
    140             } else if (args[i].equalsIgnoreCase("--licence")) {
    141                 System.out.println(LICENCE + "\n");
    142             } else if (args[i].startsWith("-X:")) {
    143                 int dot = args[i].indexOf('=');
    144                 if (dot > 0) {
    145                     String name = args[i].substring(0, dot);
    146                     String value = args[i].substring(dot + 1, args[i].length());
    147                     options.put(name, value);
    148                 }
    149             }
    150         }
    151 
    152         if (rootDirs.isEmpty()) {
    153             rootDirs.add(new File(".").getAbsoluteFile());
    154         }
    155 
    156         options.put("host", host);
    157         options.put("port", ""+port);
    158         options.put("quiet", String.valueOf(quiet));
    159         StringBuilder sb = new StringBuilder();
    160         for (File dir : rootDirs) {
    161             if (sb.length() > 0) {
    162                 sb.append(":");
    163             }
    164             try {
    165                 sb.append(dir.getCanonicalPath());
    166             } catch (IOException ignored) {}
    167         }
    168         options.put("home", sb.toString());
    169 
    170         ServiceLoader<WebServerPluginInfo> serviceLoader = ServiceLoader.load(WebServerPluginInfo.class);
    171         for (WebServerPluginInfo info : serviceLoader) {
    172             String[] mimeTypes = info.getMimeTypes();
    173             for (String mime : mimeTypes) {
    174                 String[] indexFiles = info.getIndexFilesForMimeType(mime);
    175                 if (!quiet) {
    176                     System.out.print("# Found plugin for Mime type: \"" + mime + "\"");
    177                     if (indexFiles != null) {
    178                         System.out.print(" (serving index files: ");
    179                         for (String indexFile : indexFiles) {
    180                             System.out.print(indexFile + " ");
    181                         }
    182                     }
    183                     System.out.println(").");
    184                 }
    185                 registerPluginForMimeType(indexFiles, mime, info.getWebServerPlugin(mime), options);
    186             }
    187         }
    188 
    189         ServerRunner.executeInstance(new SimpleWebServer(host, port, rootDirs, quiet));
    190     }
    191 
    192     protected static void registerPluginForMimeType(String[] indexFiles, String mimeType, WebServerPlugin plugin, Map<String, String> commandLineOptions) {
    193         if (mimeType == null || plugin == null) {
    194             return;
    195         }
    196 
    197         if (indexFiles != null) {
    198             for (String filename : indexFiles) {
    199                 int dot = filename.lastIndexOf('.');
    200                 if (dot >= 0) {
    201                     String extension = filename.substring(dot + 1).toLowerCase();
    202                     MIME_TYPES.put(extension, mimeType);
    203                 }
    204             }
    205             INDEX_FILE_NAMES.addAll(Arrays.asList(indexFiles));
    206         }
    207         mimeTypeHandlers.put(mimeType, plugin);
    208         plugin.initialize(commandLineOptions);
    209     }
    210 
    211     private File getRootDir() {
    212         return rootDirs.get(0);
    213     }
    214 
    215     private List<File> getRootDirs() {
    216         return rootDirs;
    217     }
    218 
    219     private void addWwwRootDir(File wwwroot) {
    220         rootDirs.add(wwwroot);
    221     }
    222 
    223     /**
    224      * URL-encodes everything between "/"-characters. Encodes spaces as '%20' instead of '+'.
    225      */
    226     private String encodeUri(String uri) {
    227         String newUri = "";
    228         StringTokenizer st = new StringTokenizer(uri, "/ ", true);
    229         while (st.hasMoreTokens()) {
    230             String tok = st.nextToken();
    231             if (tok.equals("/"))
    232                 newUri += "/";
    233             else if (tok.equals(" "))
    234                 newUri += "%20";
    235             else {
    236                 try {
    237                     newUri += URLEncoder.encode(tok, "UTF-8");
    238                 } catch (UnsupportedEncodingException ignored) {
    239                 }
    240             }
    241         }
    242         return newUri;
    243     }
    244 
    245     public Response serve(IHTTPSession session) {
    246         Map<String, String> header = session.getHeaders();
    247         Map<String, String> parms = session.getParms();
    248         String uri = session.getUri();
    249 
    250         if (!quiet) {
    251             System.out.println(session.getMethod() + " '" + uri + "' ");
    252 
    253             Iterator<String> e = header.keySet().iterator();
    254             while (e.hasNext()) {
    255                 String value = e.next();
    256                 System.out.println("  HDR: '" + value + "' = '" + header.get(value) + "'");
    257             }
    258             e = parms.keySet().iterator();
    259             while (e.hasNext()) {
    260                 String value = e.next();
    261                 System.out.println("  PRM: '" + value + "' = '" + parms.get(value) + "'");
    262             }
    263         }
    264 
    265         for (File homeDir : getRootDirs()) {
    266             // Make sure we won't die of an exception later
    267             if (!homeDir.isDirectory()) {
    268                 return getInternalErrorResponse("given path is not a directory (" + homeDir + ").");
    269             }
    270         }
    271         return respond(Collections.unmodifiableMap(header), session, uri);
    272     }
    273 
    274     private Response respond(Map<String, String> headers, IHTTPSession session, String uri) {
    275         // Remove URL arguments
    276         uri = uri.trim().replace(File.separatorChar, '/');
    277         if (uri.indexOf('?') >= 0) {
    278             uri = uri.substring(0, uri.indexOf('?'));
    279         }
    280 
    281         // Prohibit getting out of current directory
    282         if (uri.startsWith("src/main") || uri.endsWith("src/main") || uri.contains("../")) {
    283             return getForbiddenResponse("Won't serve ../ for security reasons.");
    284         }
    285 
    286         boolean canServeUri = false;
    287         File homeDir = null;
    288         List<File> roots = getRootDirs();
    289         for (int i = 0; !canServeUri && i < roots.size(); i++) {
    290             homeDir = roots.get(i);
    291             canServeUri = canServeUri(uri, homeDir);
    292         }
    293         if (!canServeUri) {
    294             return getNotFoundResponse();
    295         }
    296 
    297         // Browsers get confused without '/' after the directory, send a redirect.
    298         File f = new File(homeDir, uri);
    299         if (f.isDirectory() && !uri.endsWith("/")) {
    300             uri += "/";
    301             Response res = createResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, "<html><body>Redirected: <a href=\"" +
    302                 uri + "\">" + uri + "</a></body></html>");
    303             res.addHeader("Location", uri);
    304             return res;
    305         }
    306 
    307         if (f.isDirectory()) {
    308             // First look for index files (index.html, index.htm, etc) and if none found, list the directory if readable.
    309             String indexFile = findIndexFileInDirectory(f);
    310             if (indexFile == null) {
    311                 if (f.canRead()) {
    312                     // No index file, list the directory if it is readable
    313                     return createResponse(Response.Status.OK, NanoHTTPD.MIME_HTML, listDirectory(uri, f));
    314                 } else {
    315                     return getForbiddenResponse("No directory listing.");
    316                 }
    317             } else {
    318                 return respond(headers, session, uri + indexFile);
    319             }
    320         }
    321 
    322         String mimeTypeForFile = getMimeTypeForFile(uri);
    323         WebServerPlugin plugin = mimeTypeHandlers.get(mimeTypeForFile);
    324         Response response = null;
    325         if (plugin != null) {
    326             response = plugin.serveFile(uri, headers, session, f, mimeTypeForFile);
    327             if (response != null && response instanceof InternalRewrite) {
    328                 InternalRewrite rewrite = (InternalRewrite) response;
    329                 return respond(rewrite.getHeaders(), session, rewrite.getUri());
    330             }
    331         } else {
    332             response = serveFile(uri, headers, f, mimeTypeForFile);
    333         }
    334         return response != null ? response : getNotFoundResponse();
    335     }
    336 
    337     protected Response getNotFoundResponse() {
    338         return createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
    339             "Error 404, file not found.");
    340     }
    341 
    342     protected Response getForbiddenResponse(String s) {
    343         return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: "
    344             + s);
    345     }
    346 
    347     protected Response getInternalErrorResponse(String s) {
    348         return createResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT,
    349             "INTERNAL ERRROR: " + s);
    350     }
    351 
    352     private boolean canServeUri(String uri, File homeDir) {
    353         boolean canServeUri;
    354         File f = new File(homeDir, uri);
    355         canServeUri = f.exists();
    356         if (!canServeUri) {
    357             String mimeTypeForFile = getMimeTypeForFile(uri);
    358             WebServerPlugin plugin = mimeTypeHandlers.get(mimeTypeForFile);
    359             if (plugin != null) {
    360                 canServeUri = plugin.canServeUri(uri, homeDir);
    361             }
    362         }
    363         return canServeUri;
    364     }
    365 
    366     /**
    367      * Serves file from homeDir and its' subdirectories (only). Uses only URI, ignores all headers and HTTP parameters.
    368      */
    369     Response serveFile(String uri, Map<String, String> header, File file, String mime) {
    370         Response res;
    371         try {
    372             // Calculate etag
    373             String etag = Integer.toHexString((file.getAbsolutePath() + file.lastModified() + "" + file.length()).hashCode());
    374 
    375             // Support (simple) skipping:
    376             long startFrom = 0;
    377             long endAt = -1;
    378             String range = header.get("range");
    379             if (range != null) {
    380                 if (range.startsWith("bytes=")) {
    381                     range = range.substring("bytes=".length());
    382                     int minus = range.indexOf('-');
    383                     try {
    384                         if (minus > 0) {
    385                             startFrom = Long.parseLong(range.substring(0, minus));
    386                             endAt = Long.parseLong(range.substring(minus + 1));
    387                         }
    388                     } catch (NumberFormatException ignored) {
    389                     }
    390                 }
    391             }
    392 
    393             // Change return code and add Content-Range header when skipping is requested
    394             long fileLen = file.length();
    395             if (range != null && startFrom >= 0) {
    396                 if (startFrom >= fileLen) {
    397                     res = createResponse(Response.Status.RANGE_NOT_SATISFIABLE, NanoHTTPD.MIME_PLAINTEXT, "");
    398                     res.addHeader("Content-Range", "bytes 0-0/" + fileLen);
    399                     res.addHeader("ETag", etag);
    400                 } else {
    401                     if (endAt < 0) {
    402                         endAt = fileLen - 1;
    403                     }
    404                     long newLen = endAt - startFrom + 1;
    405                     if (newLen < 0) {
    406                         newLen = 0;
    407                     }
    408 
    409                     final long dataLen = newLen;
    410                     FileInputStream fis = new FileInputStream(file) {
    411                         @Override
    412                         public int available() throws IOException {
    413                             return (int) dataLen;
    414                         }
    415                     };
    416                     fis.skip(startFrom);
    417 
    418                     res = createResponse(Response.Status.PARTIAL_CONTENT, mime, fis);
    419                     res.addHeader("Content-Length", "" + dataLen);
    420                     res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + fileLen);
    421                     res.addHeader("ETag", etag);
    422                 }
    423             } else {
    424                 if (etag.equals(header.get("if-none-match")))
    425                     res = createResponse(Response.Status.NOT_MODIFIED, mime, "");
    426                 else {
    427                     res = createResponse(Response.Status.OK, mime, new FileInputStream(file));
    428                     res.addHeader("Content-Length", "" + fileLen);
    429                     res.addHeader("ETag", etag);
    430                 }
    431             }
    432         } catch (IOException ioe) {
    433             res = getForbiddenResponse("Reading file failed.");
    434         }
    435 
    436         return res;
    437     }
    438 
    439     // Get MIME type from file name extension, if possible
    440     private String getMimeTypeForFile(String uri) {
    441         int dot = uri.lastIndexOf('.');
    442         String mime = null;
    443         if (dot >= 0) {
    444             mime = MIME_TYPES.get(uri.substring(dot + 1).toLowerCase());
    445         }
    446         return mime == null ? MIME_DEFAULT_BINARY : mime;
    447     }
    448 
    449     // Announce that the file server accepts partial content requests
    450     private Response createResponse(Response.Status status, String mimeType, InputStream message) {
    451         Response res = new Response(status, mimeType, message);
    452         res.addHeader("Accept-Ranges", "bytes");
    453         return res;
    454     }
    455 
    456     // Announce that the file server accepts partial content requests
    457     private Response createResponse(Response.Status status, String mimeType, String message) {
    458         Response res = new Response(status, mimeType, message);
    459         res.addHeader("Accept-Ranges", "bytes");
    460         return res;
    461     }
    462 
    463     private String findIndexFileInDirectory(File directory) {
    464         for (String fileName : INDEX_FILE_NAMES) {
    465             File indexFile = new File(directory, fileName);
    466             if (indexFile.exists()) {
    467                 return fileName;
    468             }
    469         }
    470         return null;
    471     }
    472 
    473     protected String listDirectory(String uri, File f) {
    474         String heading = "Directory " + uri;
    475         StringBuilder msg = new StringBuilder("<html><head><title>" + heading + "</title><style><!--\n" +
    476             "span.dirname { font-weight: bold; }\n" +
    477             "span.filesize { font-size: 75%; }\n" +
    478             "// -->\n" +
    479             "</style>" +
    480             "</head><body><h1>" + heading + "</h1>");
    481 
    482         String up = null;
    483         if (uri.length() > 1) {
    484             String u = uri.substring(0, uri.length() - 1);
    485             int slash = u.lastIndexOf('/');
    486             if (slash >= 0 && slash < u.length()) {
    487                 up = uri.substring(0, slash + 1);
    488             }
    489         }
    490 
    491         List<String> files = Arrays.asList(f.list(new FilenameFilter() {
    492             @Override
    493             public boolean accept(File dir, String name) {
    494                 return new File(dir, name).isFile();
    495             }
    496         }));
    497         Collections.sort(files);
    498         List<String> directories = Arrays.asList(f.list(new FilenameFilter() {
    499             @Override
    500             public boolean accept(File dir, String name) {
    501                 return new File(dir, name).isDirectory();
    502             }
    503         }));
    504         Collections.sort(directories);
    505         if (up != null || directories.size() + files.size() > 0) {
    506             msg.append("<ul>");
    507             if (up != null || directories.size() > 0) {
    508                 msg.append("<section class=\"directories\">");
    509                 if (up != null) {
    510                     msg.append("<li><a rel=\"directory\" href=\"").append(up).append("\"><span class=\"dirname\">..</span></a></b></li>");
    511                 }
    512                 for (String directory : directories) {
    513                     String dir = directory + "/";
    514                     msg.append("<li><a rel=\"directory\" href=\"").append(encodeUri(uri + dir)).append("\"><span class=\"dirname\">").append(dir).append("</span></a></b></li>");
    515                 }
    516                 msg.append("</section>");
    517             }
    518             if (files.size() > 0) {
    519                 msg.append("<section class=\"files\">");
    520                 for (String file : files) {
    521                     msg.append("<li><a href=\"").append(encodeUri(uri + file)).append("\"><span class=\"filename\">").append(file).append("</span></a>");
    522                     File curFile = new File(f, file);
    523                     long len = curFile.length();
    524                     msg.append("&nbsp;<span class=\"filesize\">(");
    525                     if (len < 1024) {
    526                         msg.append(len).append(" bytes");
    527                     } else if (len < 1024 * 1024) {
    528                         msg.append(len / 1024).append(".").append(len % 1024 / 10 % 100).append(" KB");
    529                     } else {
    530                         msg.append(len / (1024 * 1024)).append(".").append(len % (1024 * 1024) / 10 % 100).append(" MB");
    531                     }
    532                     msg.append(")</span></li>");
    533                 }
    534                 msg.append("</section>");
    535             }
    536             msg.append("</ul>");
    537         }
    538         msg.append("</body></html>");
    539         return msg.toString();
    540     }
    541 }
    542