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(" <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