39 | *
40 | * (By default, this delegates to serveFile() and allows directory listing.) 41 | * 42 | * @param uri Percent-decoded URI without parameters, for example "/index.cgi" 43 | * @param method "GET", "POST" etc. 44 | * @param parms Parsed, percent decoded parameters from URI and, in case of POST, data. 45 | * @param header Header entries, percent decoded 46 | * @return HTTP response, see class Response for details 47 | */ 48 | public Response serve(String uri, String method, Properties header, Properties parms, Properties files) { 49 | System.out.println(method + " '" + uri + "' "); 50 | 51 | Enumeration e = header.propertyNames(); 52 | while (e.hasMoreElements()) { 53 | String value = (String) e.nextElement(); 54 | System.out.println(" HDR: '" + value + "' = '" + 55 | header.getProperty(value) + "'"); 56 | } 57 | e = parms.propertyNames(); 58 | while (e.hasMoreElements()) { 59 | String value = (String) e.nextElement(); 60 | System.out.println(" PRM: '" + value + "' = '" + 61 | parms.getProperty(value) + "'"); 62 | } 63 | e = files.propertyNames(); 64 | while (e.hasMoreElements()) { 65 | String value = (String) e.nextElement(); 66 | System.out.println(" UPLOADED: '" + value + "' = '" + 67 | files.getProperty(value) + "'"); 68 | } 69 | 70 | //Map uri to acture file in ContentTree 71 | 72 | String itemId = uri.replaceFirst("/", ""); 73 | itemId = URLDecoder.decode(itemId); 74 | String newUri = null; 75 | 76 | if (ContentTree.hasNode(itemId)) { 77 | ContentNode node = ContentTree.getNode(itemId); 78 | if (node.isItem()) { 79 | newUri = node.getFullPath(); 80 | } 81 | } 82 | 83 | if (newUri != null) uri = newUri; 84 | return serveFile(uri, header, myRootDir, false); 85 | } 86 | 87 | /** 88 | * HTTP response. 89 | * Return one of these from serve(). 90 | */ 91 | public class Response { 92 | /** 93 | * Default constructor: response = HTTP_OK, data = mime = 'null' 94 | */ 95 | public Response() { 96 | this.status = HTTP_OK; 97 | } 98 | 99 | /** 100 | * Basic constructor. 101 | */ 102 | public Response(String status, String mimeType, InputStream data) { 103 | this.status = status; 104 | this.mimeType = mimeType; 105 | this.data = data; 106 | } 107 | 108 | /** 109 | * Convenience method that makes an InputStream out of 110 | * given text. 111 | */ 112 | public Response(String status, String mimeType, String txt) { 113 | this.status = status; 114 | this.mimeType = mimeType; 115 | try { 116 | this.data = new ByteArrayInputStream(txt.getBytes("UTF-8")); 117 | } catch (java.io.UnsupportedEncodingException uee) { 118 | uee.printStackTrace(); 119 | } 120 | } 121 | 122 | /** 123 | * Adds given line to the header. 124 | */ 125 | public void addHeader(String name, String value) { 126 | header.put(name, value); 127 | } 128 | 129 | /** 130 | * HTTP status code after processing, e.g. "200 OK", HTTP_OK 131 | */ 132 | public String status; 133 | 134 | /** 135 | * MIME type of content, e.g. "text/html" 136 | */ 137 | public String mimeType; 138 | 139 | /** 140 | * Data of the response, may be null. 141 | */ 142 | public InputStream data; 143 | 144 | /** 145 | * Headers for the HTTP response. Use addHeader() 146 | * to add lines. 147 | */ 148 | public Properties header = new Properties(); 149 | } 150 | 151 | /** 152 | * Some HTTP response status codes 153 | */ 154 | public static final String 155 | HTTP_OK = "200 OK", 156 | HTTP_PARTIALCONTENT = "206 Partial Content", 157 | HTTP_RANGE_NOT_SATISFIABLE = "416 Requested Range Not Satisfiable", 158 | HTTP_REDIRECT = "301 Moved Permanently", 159 | HTTP_FORBIDDEN = "403 Forbidden", 160 | HTTP_NOTFOUND = "404 Not Found", 161 | HTTP_BADREQUEST = "400 Bad Request", 162 | HTTP_INTERNALERROR = "500 Internal Server Error", 163 | HTTP_NOTIMPLEMENTED = "501 Not Implemented"; 164 | 165 | /** 166 | * Common mime types for dynamic content 167 | */ 168 | public static final String 169 | MIME_PLAINTEXT = "text/plain", 170 | MIME_HTML = "text/html", 171 | MIME_DEFAULT_BINARY = "application/octet-stream", 172 | MIME_XML = "text/xml"; 173 | 174 | // ================================================== 175 | // Socket & server code 176 | // ================================================== 177 | 178 | /** 179 | * Starts a HTTP server to given port.
180 | * Throws an IOException if the socket is already in use
181 | */
182 | public HttpServer(int port) throws IOException {
183 | myTcpPort = port;
184 | this.myRootDir = new File("/");
185 | myServerSocket = new ServerSocket(myTcpPort);
186 | myThread = new Thread(new Runnable() {
187 | public void run() {
188 | try {
189 | while (true)
190 | new HTTPSession(myServerSocket.accept());
191 | } catch (IOException e) {
192 | e.printStackTrace();
193 | }
194 | }
195 | });
196 | myThread.setDaemon(true);
197 | myThread.start();
198 | }
199 |
200 | /**
201 | * Stops the server.
202 | */
203 | public void stop() {
204 | try {
205 | myServerSocket.close();
206 | myThread.join();
207 | } catch (IOException e) {
208 | e.printStackTrace();
209 | } catch (InterruptedException e) {
210 | e.printStackTrace();
211 | }
212 | }
213 |
214 | /**
215 | * Handles one session, i.e. parses the HTTP request
216 | * and returns the response.
217 | */
218 | private class HTTPSession implements Runnable {
219 | public HTTPSession(Socket s) {
220 | mySocket = s;
221 | Thread t = new Thread(this);
222 | t.setDaemon(true);
223 | t.start();
224 | }
225 |
226 | public void run() {
227 | try {
228 | InputStream is = mySocket.getInputStream();
229 | if (is == null) return;
230 |
231 | // Read the first 8192 bytes.
232 | // The full header should fit in here.
233 | // Apache's default header limit is 8KB.
234 | int bufferSize = 8192;
235 | byte[] buf = new byte[bufferSize];
236 | int length = is.read(buf, 0, bufferSize);
237 | if (length <= 0) return;
238 |
239 | // Create a BufferedReader for parsing the header.
240 | ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(buf, 0, length);
241 | BufferedReader hin = new BufferedReader(new InputStreamReader(byteArrayInputStream));
242 | Properties pre = new Properties();
243 | Properties properties = new Properties();
244 | Properties header = new Properties();
245 | Properties files = new Properties();
246 |
247 | // Decode the header into parms and header java properties
248 | decodeHeader(hin, pre, properties, header);
249 | String method = pre.getProperty("method");
250 | String uri = pre.getProperty("uri");
251 |
252 | long size = 0x7FFFFFFFFFFFFFFFL;
253 | String contentLength = header.getProperty("content-length");
254 | if (contentLength != null) {
255 | try {
256 | size = Integer.parseInt(contentLength);
257 | } catch (NumberFormatException ex) {
258 | ex.printStackTrace();
259 | }
260 | }
261 |
262 | // We are looking for the byte separating header from body.
263 | // It must be the last byte of the first two sequential new lines.
264 | int split = 0;
265 | boolean found = false;
266 | while (split < length) {
267 | if (buf[split] == '\r' && buf[++split] == '\n' && buf[++split] == '\r' && buf[++split] == '\n') {
268 | found = true;
269 | break;
270 | }
271 | split++;
272 | }
273 | split++;
274 |
275 | // Write the part of body already read to ByteArrayOutputStream f
276 | ByteArrayOutputStream f = new ByteArrayOutputStream();
277 | if (split < length) f.write(buf, split, length - split);
278 |
279 | // While Firefox sends on the first read all the data fitting
280 | // our buffer, Chrome and Opera sends only the headers even if
281 | // there is data for the body. So we do some magic here to find
282 | // out whether we have already consumed part of body, if we
283 | // have reached the end of the data to be sent or we should
284 | // expect the first byte of the body at the next read.
285 | if (split < length)
286 | size -= length - split + 1;
287 | else if (!found || size == 0x7FFFFFFFFFFFFFFFL)
288 | size = 0;
289 |
290 | // Now read all the body and write it to f
291 | buf = new byte[512];
292 | while (length >= 0 && size > 0) {
293 | length = is.read(buf, 0, 512);
294 | size -= length;
295 | if (length > 0)
296 | f.write(buf, 0, length);
297 | }
298 |
299 | // Get the raw body as a byte []
300 | byte[] rawBuff = f.toByteArray();
301 |
302 | // Create a BufferedReader for easily reading it as string.
303 | ByteArrayInputStream bin = new ByteArrayInputStream(rawBuff);
304 | BufferedReader in = new BufferedReader(new InputStreamReader(bin));
305 |
306 | // If the method is POST, there may be parameters
307 | // in data section, too, read it:
308 | if (method.equalsIgnoreCase("POST")) {
309 | String contentType = "";
310 | String contentTypeHeader = header.getProperty("content-type");
311 | StringTokenizer st = new StringTokenizer(contentTypeHeader, "; ");
312 | if (st.hasMoreTokens()) {
313 | contentType = st.nextToken();
314 | }
315 |
316 | if (contentType.equalsIgnoreCase("multipart/form-data")) {
317 | // Handle multipart/form-data
318 | if (!st.hasMoreTokens())
319 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html");
320 | String boundaryExp = st.nextToken();
321 | st = new StringTokenizer(boundaryExp, "=");
322 | if (st.countTokens() != 2)
323 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Content type is multipart/form-data but boundary syntax error. Usage: GET /example/file.html");
324 | st.nextToken();
325 | String boundary = st.nextToken();
326 |
327 | decodeMultipartData(boundary, rawBuff, in, properties, files);
328 | } else {
329 | // Handle application/x-www-form-urlencoded
330 | String postLine = "";
331 | char buff[] = new char[512];
332 | int read = in.read(buff);
333 | while (read >= 0 && !postLine.endsWith("\r\n")) {
334 | postLine += String.valueOf(buff, 0, read);
335 | read = in.read(buff);
336 | }
337 | postLine = postLine.trim();
338 | decodeParams(postLine, properties);
339 | }
340 | }
341 |
342 | // Ok, now do the serve()
343 | Response r = serve(uri, method, header, properties, files);
344 | if (r == null)
345 | sendError(HTTP_INTERNALERROR, "SERVER INTERNAL ERROR: Serve() returned a null response.");
346 | else
347 | sendResponse(r.status, r.mimeType, r.header, r.data);
348 |
349 | in.close();
350 | is.close();
351 | } catch (IOException ioe) {
352 | try {
353 | sendError(HTTP_INTERNALERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
354 | } catch (Throwable t) {
355 | t.printStackTrace();
356 | }
357 | } catch (InterruptedException ie) {
358 | // Thrown by sendError, ignore and exit the thread.
359 | ie.printStackTrace();
360 | }
361 | }
362 |
363 | /**
364 | * Decodes the sent headers and loads the data into
365 | * java Properties' key - value pairs
366 | **/
367 | private void decodeHeader(BufferedReader in, Properties pre, Properties parms, Properties header)
368 | throws InterruptedException {
369 | try {
370 | // Read the request line
371 | String inLine = in.readLine();
372 | if (inLine == null) return;
373 | StringTokenizer st = new StringTokenizer(inLine);
374 | if (!st.hasMoreTokens())
375 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Syntax error. Usage: GET /example/file.html");
376 |
377 | String method = st.nextToken();
378 | pre.put("method", method);
379 |
380 | if (!st.hasMoreTokens())
381 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Missing URI. Usage: GET /example/file.html");
382 |
383 | String uri = st.nextToken();
384 |
385 | // Decode parameters from the URI
386 | int qmi = uri.indexOf('?');
387 | if (qmi >= 0) {
388 | decodeParams(uri.substring(qmi + 1), parms);
389 | uri = decodePercent(uri.substring(0, qmi));
390 | } else uri = decodePercent(uri);
391 |
392 | // If there's another token, it's protocol version,
393 | // followed by HTTP headers. Ignore version but parse headers.
394 | // NOTE: this now forces header names lowercase since they are
395 | // case insensitive and vary by client.
396 | if (st.hasMoreTokens()) {
397 | String line = in.readLine();
398 | while (line != null && line.trim().length() > 0) {
399 | int p = line.indexOf(':');
400 | if (p >= 0)
401 | header.put(line.substring(0, p).trim().toLowerCase(), line.substring(p + 1).trim());
402 | line = in.readLine();
403 | }
404 | }
405 |
406 | pre.put("uri", uri);
407 | } catch (IOException ioe) {
408 | sendError(HTTP_INTERNALERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
409 | }
410 | }
411 |
412 | /**
413 | * Decodes the Multipart Body data and put it
414 | * into java Properties' key - value pairs.
415 | **/
416 | private void decodeMultipartData(String boundary, byte[] fbuf, BufferedReader in, Properties parms, Properties files)
417 | throws InterruptedException {
418 | try {
419 | int[] boundaryPositions = getBoundaryPositions(fbuf, boundary.getBytes());
420 | int boundaryCount = 1;
421 | String line = in.readLine();
422 | while (line != null) {
423 | if (!line.contains(boundary))
424 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Content type is multipart/form-data but next chunk does not start with boundary. Usage: GET /example/file.html");
425 | boundaryCount++;
426 | Properties item = new Properties();
427 | line = in.readLine();
428 | while (line != null && line.trim().length() > 0) {
429 | int p = line.indexOf(':');
430 | if (p != -1)
431 | item.put(line.substring(0, p).trim().toLowerCase(), line.substring(p + 1).trim());
432 | line = in.readLine();
433 | }
434 | if (line != null) {
435 | String contentDisposition = item.getProperty("content-disposition");
436 | if (contentDisposition == null) {
437 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Content type is multipart/form-data but no content-disposition info found. Usage: GET /example/file.html");
438 | }
439 | StringTokenizer st = new StringTokenizer(contentDisposition, "; ");
440 | Properties disposition = new Properties();
441 | while (st.hasMoreTokens()) {
442 | String token = st.nextToken();
443 | int p = token.indexOf('=');
444 | if (p != -1)
445 | disposition.put(token.substring(0, p).trim().toLowerCase(), token.substring(p + 1).trim());
446 | }
447 | String pname = disposition.getProperty("name");
448 | pname = pname.substring(1, pname.length() - 1);
449 |
450 | String value = "";
451 | if (item.getProperty("content-type") == null) {
452 | while (line != null && !line.contains(boundary)) {
453 | line = in.readLine();
454 | if (line != null) {
455 | int d = line.indexOf(boundary);
456 | if (d == -1)
457 | value += line;
458 | else
459 | value += line.substring(0, d - 2);
460 | }
461 | }
462 | } else {
463 | if (boundaryCount > boundaryPositions.length)
464 | sendError(HTTP_INTERNALERROR, "Error processing request");
465 | int offset = stripMultipartHeaders(fbuf, boundaryPositions[boundaryCount - 2]);
466 | String path = saveTmpFile(fbuf, offset, boundaryPositions[boundaryCount - 1] - offset - 4);
467 | files.put(pname, path);
468 | value = disposition.getProperty("filename");
469 | value = value.substring(1, value.length() - 1);
470 | do {
471 | line = in.readLine();
472 | } while (line != null && !line.contains(boundary));
473 | }
474 | parms.put(pname, value);
475 | }
476 | }
477 | } catch (IOException ioe) {
478 | sendError(HTTP_INTERNALERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
479 | }
480 | }
481 |
482 | /**
483 | * Find the byte positions where multipart boundaries start.
484 | **/
485 | public int[] getBoundaryPositions(byte[] b, byte[] boundary) {
486 | int matchCount = 0;
487 | int matchByte = -1;
488 | Vector matchBytes = new Vector();
489 | for (int i = 0; i < b.length; i++) {
490 | if (b[i] == boundary[matchCount]) {
491 | if (matchCount == 0)
492 | matchByte = i;
493 | matchCount++;
494 | if (matchCount == boundary.length) {
495 | matchBytes.addElement(new Integer(matchByte));
496 | matchCount = 0;
497 | matchByte = -1;
498 | }
499 | } else {
500 | i -= matchCount;
501 | matchCount = 0;
502 | matchByte = -1;
503 | }
504 | }
505 | int[] ret = new int[matchBytes.size()];
506 | for (int i = 0; i < ret.length; i++) {
507 | ret[i] = ((Integer) matchBytes.elementAt(i)).intValue();
508 | }
509 | return ret;
510 | }
511 |
512 | /**
513 | * Retrieves the content of a sent file and saves it
514 | * to a temporary file.
515 | * The full path to the saved file is returned.
516 | **/
517 | private String saveTmpFile(byte[] b, int offset, int len) {
518 | String path = "";
519 | if (len > 0) {
520 | String tmpdir = System.getProperty("java.io.tmpdir");
521 | try {
522 | File temp = File.createTempFile("NanoHTTPD", "", new File(tmpdir));
523 | OutputStream out = new FileOutputStream(temp);
524 | out.write(b, offset, len);
525 | out.close();
526 | path = temp.getAbsolutePath();
527 | } catch (Exception e) { // Catch exception if any
528 | System.err.println("Error: " + e.getMessage());
529 | }
530 | }
531 | return path;
532 | }
533 |
534 |
535 | /**
536 | * It returns the offset separating multipart file headers
537 | * from the file's data.
538 | **/
539 | private int stripMultipartHeaders(byte[] b, int offset) {
540 | int i = 0;
541 | for (i = offset; i < b.length; i++) {
542 | if (b[i] == '\r' && b[++i] == '\n' && b[++i] == '\r' && b[++i] == '\n')
543 | break;
544 | }
545 | return i + 1;
546 | }
547 |
548 | /**
549 | * Decodes the percent encoding scheme.
550 | * For example: "an+example%20string" -> "an example string"
551 | */
552 | private String decodePercent(String str) throws InterruptedException {
553 | try {
554 | StringBuilder sb = new StringBuilder();
555 | for (int i = 0; i < str.length(); i++) {
556 | char c = str.charAt(i);
557 | switch (c) {
558 | case '+':
559 | sb.append(' ');
560 | break;
561 | case '%':
562 | sb.append((char) Integer.parseInt(str.substring(i + 1, i + 3), 16));
563 | i += 2;
564 | break;
565 | default:
566 | sb.append(c);
567 | break;
568 | }
569 | }
570 | return sb.toString();
571 | } catch (Exception e) {
572 | sendError(HTTP_BADREQUEST, "BAD REQUEST: Bad percent-encoding.");
573 | return null;
574 | }
575 | }
576 |
577 | /**
578 | * Decodes parameters in percent-encoded URI-format
579 | * ( e.g. "name=Jack%20Daniels&pass=Single%20Malt" ) and
580 | * adds them to given Properties. NOTE: this doesn't support multiple
581 | * identical keys due to the simplicity of Properties -- if you need multiples,
582 | * you might want to replace the Properties with a Hashtable of Vectors or such.
583 | */
584 | private void decodeParams(String params, Properties p)
585 | throws InterruptedException {
586 | if (params == null)
587 | return;
588 |
589 | StringTokenizer st = new StringTokenizer(params, "&");
590 | while (st.hasMoreTokens()) {
591 | String e = st.nextToken();
592 | int sep = e.indexOf('=');
593 | if (sep >= 0)
594 | p.put(decodePercent(e.substring(0, sep)).trim(),
595 | decodePercent(e.substring(sep + 1)));
596 | }
597 | }
598 |
599 | /**
600 | * Returns an error message as a HTTP response and
601 | * throws InterruptedException to stop further request processing.
602 | */
603 | private void sendError(String status, String msg) throws InterruptedException {
604 | sendResponse(status, MIME_PLAINTEXT, null, new ByteArrayInputStream(msg.getBytes()));
605 | throw new InterruptedException();
606 | }
607 |
608 | /**
609 | * Sends given response to the socket.
610 | */
611 | private void sendResponse(String status, String mime, Properties header, InputStream data) {
612 | try {
613 | if (status == null)
614 | throw new Error("sendResponse(): Status can't be null.");
615 |
616 | OutputStream out = mySocket.getOutputStream();
617 | PrintWriter pw = new PrintWriter(out);
618 | pw.print("HTTP/1.0 " + status + " \r\n");
619 |
620 | if (mime != null)
621 | pw.print("Content-Type: " + mime + "\r\n");
622 |
623 | if (header == null || header.getProperty("Date") == null)
624 | pw.print("Date: " + gmtFrmt.format(new Date()) + "\r\n");
625 |
626 | if (header != null) {
627 | Enumeration e = header.keys();
628 | while (e.hasMoreElements()) {
629 | String key = (String) e.nextElement();
630 | String value = header.getProperty(key);
631 | pw.print(key + ": " + value + "\r\n");
632 | }
633 | }
634 |
635 | pw.print("\r\n");
636 | pw.flush();
637 |
638 | if (data != null) {
639 | int pending = data.available(); // This is to support partial sends, see serveFile()
640 | byte[] buff = new byte[2048];
641 | while (pending > 0) {
642 | int read = data.read(buff, 0, ((pending > 2048) ? 2048 : pending));
643 | if (read <= 0) break;
644 | out.write(buff, 0, read);
645 | pending -= read;
646 | }
647 | }
648 | out.flush();
649 | out.close();
650 | if (data != null)
651 | data.close();
652 | } catch (IOException ioe) {
653 | // Couldn't write? No can do.
654 | try {
655 | mySocket.close();
656 | } catch (Throwable t) {
657 | t.printStackTrace();
658 | }
659 | }
660 | }
661 |
662 | private Socket mySocket;
663 | }
664 |
665 | /**
666 | * URL-encodes everything between "/"-characters.
667 | * Encodes spaces as '%20' instead of '+'.
668 | */
669 | private String encodeUri(String uri) {
670 | String newUri = "";
671 | StringTokenizer st = new StringTokenizer(uri, "/ ", true);
672 | while (st.hasMoreTokens()) {
673 | String tok = st.nextToken();
674 | if (tok.equals("/"))
675 | newUri += "/";
676 | else if (tok.equals(" "))
677 | newUri += "%20";
678 | else {
679 | newUri += URLEncoder.encode(tok);
680 | // For Java 1.4 you'll want to use this instead:
681 | // try { newUri += URLEncoder.encode( tok, "UTF-8" ); } catch ( java.io.UnsupportedEncodingException uee ) {}
682 | }
683 | }
684 | return newUri;
685 | }
686 |
687 | private int myTcpPort;
688 | private final ServerSocket myServerSocket;
689 | private Thread myThread;
690 | private File myRootDir;
691 |
692 | // ==================================================
693 | // File server code
694 | // ==================================================
695 |
696 | /**
697 | * Serves file from homeDir and its' subdirectories (only).
698 | * Uses only URI, ignores all headers and HTTP parameters.
699 | */
700 | public Response serveFile(String uri, Properties header, File homeDir,
701 | boolean allowDirectoryListing) {
702 | Response res = null;
703 |
704 | // Make sure we won't die of an exception later
705 | if (!homeDir.isDirectory())
706 | res = new Response(HTTP_INTERNALERROR, MIME_PLAINTEXT,
707 | "INTERNAL ERRROR: serveFile(): given homeDir is not a directory.");
708 |
709 | if (res == null) {
710 | // Remove URL arguments
711 | uri = uri.trim().replace(File.separatorChar, '/');
712 | if (uri.indexOf('?') >= 0)
713 | uri = uri.substring(0, uri.indexOf('?'));
714 |
715 | // Prohibit getting out of current directory
716 | if (uri.startsWith("..") || uri.endsWith("..") || uri.indexOf("../") >= 0)
717 | res = new Response(HTTP_FORBIDDEN, MIME_PLAINTEXT,
718 | "FORBIDDEN: Won't serve ../ for security reasons.");
719 | }
720 |
721 | File f = new File(homeDir, uri);
722 | if (res == null && !f.exists())
723 | res = new Response(HTTP_NOTFOUND, MIME_PLAINTEXT,
724 | "Error 404, file not found.");
725 |
726 | // List the directory, if necessary
727 | if (res == null && f.isDirectory()) {
728 | // Browsers get confused without '/' after the
729 | // directory, send a redirect.
730 | if (!uri.endsWith("/")) {
731 | uri += "/";
732 | res = new Response(HTTP_REDIRECT, MIME_HTML,
733 | "