1package fi.iki.elonen;
2
3import java.io.File;
4import java.io.FileInputStream;
5import java.io.FilenameFilter;
6import java.io.IOException;
7import java.io.InputStream;
8import java.io.UnsupportedEncodingException;
9import java.net.URLEncoder;
10import java.util.ArrayList;
11import java.util.Arrays;
12import java.util.Collections;
13import java.util.HashMap;
14import java.util.Iterator;
15import java.util.List;
16import java.util.Map;
17import java.util.ServiceLoader;
18import java.util.StringTokenizer;
19
20public 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