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 
SimpleWebServer(String host, int port, File wwwroot, boolean quiet)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 
SimpleWebServer(String host, int port, List<File> wwwroots, boolean quiet)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 	 */
init()115     public void init() {
116     }
117 
118     /**
119      * Starts as a standalone file server and waits for Enter.
120      */
main(String[] args)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 
registerPluginForMimeType(String[] indexFiles, String mimeType, WebServerPlugin plugin, Map<String, String> commandLineOptions)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 
getRootDir()211     private File getRootDir() {
212         return rootDirs.get(0);
213     }
214 
getRootDirs()215     private List<File> getRootDirs() {
216         return rootDirs;
217     }
218 
addWwwRootDir(File wwwroot)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      */
encodeUri(String uri)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 
serve(IHTTPSession session)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 
respond(Map<String, String> headers, IHTTPSession session, String uri)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 
getNotFoundResponse()337     protected Response getNotFoundResponse() {
338         return createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
339             "Error 404, file not found.");
340     }
341 
getForbiddenResponse(String s)342     protected Response getForbiddenResponse(String s) {
343         return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: "
344             + s);
345     }
346 
getInternalErrorResponse(String s)347     protected Response getInternalErrorResponse(String s) {
348         return createResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT,
349             "INTERNAL ERRROR: " + s);
350     }
351 
canServeUri(String uri, File homeDir)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      */
serveFile(String uri, Map<String, String> header, File file, String mime)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
getMimeTypeForFile(String uri)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
createResponse(Response.Status status, String mimeType, InputStream message)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
createResponse(Response.Status status, String mimeType, String message)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 
findIndexFileInDirectory(File directory)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 
listDirectory(String uri, File f)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