1 | !636 |
2 | |
3 | import java.util.*; |
4 | import java.io.*; |
5 | import java.net.*; |
6 | |
7 | import java.nio.ByteBuffer; |
8 | import java.nio.channels.FileChannel; |
9 | import java.nio.charset.Charset; |
10 | import java.security.KeyStore; |
11 | import java.text.SimpleDateFormat; |
12 | import java.util.logging.Level; |
13 | import java.util.logging.Logger; |
14 | import java.util.regex.Matcher; |
15 | import java.util.regex.Pattern; |
16 | import java.util.zip.GZIPOutputStream; |
17 | |
18 | import javax.net.ssl.*; |
19 | |
20 | abstract class NanoHTTPD { |
21 | |
22 | static ThreadLocal<IHTTPSession> currentSession = new ThreadLocal<IHttpSession>(); |
23 | |
24 | /** |
25 | * Pluggable strategy for asynchronously executing requests. |
26 | */ |
27 | public interface AsyncRunner { |
28 | |
29 | void closeAll(); |
30 | |
31 | void closed(ClientHandler clientHandler); |
32 | |
33 | void exec(ClientHandler code); |
34 | } |
35 | |
36 | /** |
37 | * The runnable that will be used for every new client connection. |
38 | */ |
39 | public class ClientHandler implements Runnable { |
40 | |
41 | private final InputStream inputStream; |
42 | |
43 | private final Socket acceptSocket; |
44 | |
45 | private ClientHandler(InputStream inputStream, Socket acceptSocket) { |
46 | this.inputStream = inputStream; |
47 | this.acceptSocket = acceptSocket; |
48 | } |
49 | |
50 | public void close() { |
51 | safeClose(this.inputStream); |
52 | safeClose(this.acceptSocket); |
53 | } |
54 | |
55 | @Override |
56 | public void run() { |
57 | OutputStream outputStream = null; |
58 | try { |
59 | outputStream = this.acceptSocket.getOutputStream(); |
60 | TempFileManager tempFileManager = NanoHTTPD.this.tempFileManagerFactory.create(); |
61 | HTTPSession session = new HTTPSession(tempFileManager, this.inputStream, outputStream, this.acceptSocket.getInetAddress()); |
62 | while (!this.acceptSocket.isClosed()) { |
63 | session.execute(); |
64 | } |
65 | } catch (Exception e) { |
66 | // When the socket is closed by the client, |
67 | // we throw our own SocketException |
68 | // to break the "keep alive" loop above. If |
69 | // the exception was anything other |
70 | // than the expected SocketException OR a |
71 | // SocketTimeoutException, print the |
72 | // stacktrace |
73 | if (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage())) && !(e instanceof SocketTimeoutException)) { |
74 | NanoHTTPD.LOG.log(Level.FINE, "Communication with the client broken", e); |
75 | } |
76 | } finally { |
77 | safeClose(outputStream); |
78 | safeClose(this.inputStream); |
79 | safeClose(this.acceptSocket); |
80 | NanoHTTPD.this.asyncRunner.closed(this); |
81 | } |
82 | } |
83 | } |
84 | |
85 | public static class Cookie { |
86 | |
87 | public static String getHTTPTime(int days) { |
88 | Calendar calendar = Calendar.getInstance(); |
89 | SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); |
90 | dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); |
91 | calendar.add(Calendar.DAY_OF_MONTH, days); |
92 | return dateFormat.format(calendar.getTime()); |
93 | } |
94 | |
95 | private final String n, v, e; |
96 | |
97 | public Cookie(String name, String value) { |
98 | this(name, value, 30); |
99 | } |
100 | |
101 | public Cookie(String name, String value, int numDays) { |
102 | this.n = name; |
103 | this.v = value; |
104 | this.e = getHTTPTime(numDays); |
105 | } |
106 | |
107 | public Cookie(String name, String value, String expires) { |
108 | this.n = name; |
109 | this.v = value; |
110 | this.e = expires; |
111 | } |
112 | |
113 | public String getHTTPHeader() { |
114 | String fmt = "%s=%s; expires=%s"; |
115 | return String.format(fmt, this.n, this.v, this.e); |
116 | } |
117 | } |
118 | |
119 | /** |
120 | * Provides rudimentary support for cookies. Doesn't support 'path', |
121 | * 'secure' nor 'httpOnly'. Feel free to improve it and/or add unsupported |
122 | * features. |
123 | * |
124 | * @author LordFokas |
125 | */ |
126 | public class CookieHandler implements Iterable<String> { |
127 | |
128 | private final HashMap<String, String> cookies = new HashMap<String, String>(); |
129 | |
130 | private final ArrayList<Cookie> queue = new ArrayList<Cookie>(); |
131 | |
132 | public CookieHandler(Map<String, String> httpHeaders) { |
133 | String raw = httpHeaders.get("cookie"); |
134 | if (raw != null) { |
135 | String[] tokens = raw.split(";"); |
136 | for (String token : tokens) { |
137 | String[] data = token.trim().split("="); |
138 | if (data.length == 2) { |
139 | this.cookies.put(data[0], data[1]); |
140 | } |
141 | } |
142 | } |
143 | } |
144 | |
145 | /** |
146 | * Set a cookie with an expiration date from a month ago, effectively |
147 | * deleting it on the client side. |
148 | * |
149 | * @param name |
150 | * The cookie name. |
151 | */ |
152 | public void delete(String name) { |
153 | set(name, "-delete-", -30); |
154 | } |
155 | |
156 | @Override |
157 | public Iterator<String> iterator() { |
158 | return this.cookies.keySet().iterator(); |
159 | } |
160 | |
161 | /** |
162 | * Read a cookie from the HTTP Headers. |
163 | * |
164 | * @param name |
165 | * The cookie's name. |
166 | * @return The cookie's value if it exists, null otherwise. |
167 | */ |
168 | public String read(String name) { |
169 | return this.cookies.get(name); |
170 | } |
171 | |
172 | public void set(Cookie cookie) { |
173 | this.queue.add(cookie); |
174 | } |
175 | |
176 | /** |
177 | * Sets a cookie. |
178 | * |
179 | * @param name |
180 | * The cookie's name. |
181 | * @param value |
182 | * The cookie's value. |
183 | * @param expires |
184 | * How many days until the cookie expires. |
185 | */ |
186 | public void set(String name, String value, int expires) { |
187 | this.queue.add(new Cookie(name, value, Cookie.getHTTPTime(expires))); |
188 | } |
189 | |
190 | /** |
191 | * Internally used by the webserver to add all queued cookies into the |
192 | * Response's HTTP Headers. |
193 | * |
194 | * @param response |
195 | * The Response object to which headers the queued cookies |
196 | * will be added. |
197 | */ |
198 | public void unloadQueue(Response response) { |
199 | for (Cookie cookie : this.queue) { |
200 | response.addHeader("Set-Cookie", cookie.getHTTPHeader()); |
201 | } |
202 | } |
203 | } |
204 | |
205 | /** |
206 | * Default threading strategy for NanoHTTPD. |
207 | * <p/> |
208 | * <p> |
209 | * By default, the server spawns a new Thread for every incoming request. |
210 | * These are set to <i>daemon</i> status, and named according to the request |
211 | * number. The name is useful when profiling the application. |
212 | * </p> |
213 | */ |
214 | public static class DefaultAsyncRunner implements AsyncRunner { |
215 | |
216 | private long requestCount; |
217 | |
218 | private final List<ClientHandler> running = Collections.synchronizedList(new ArrayList<NanoHTTPD.ClientHandler>()); |
219 | |
220 | /** |
221 | * @return a list with currently running clients. |
222 | */ |
223 | public List<ClientHandler> getRunning() { |
224 | return running; |
225 | } |
226 | |
227 | @Override |
228 | public void closeAll() { |
229 | // copy of the list for concurrency |
230 | for (ClientHandler clientHandler : new ArrayList<ClientHandler>(this.running)) { |
231 | clientHandler.close(); |
232 | } |
233 | } |
234 | |
235 | @Override |
236 | public void closed(ClientHandler clientHandler) { |
237 | this.running.remove(clientHandler); |
238 | } |
239 | |
240 | @Override |
241 | public void exec(ClientHandler clientHandler) { |
242 | ++this.requestCount; |
243 | Thread t = new Thread(clientHandler); |
244 | t.setDaemon(true); |
245 | t.setName("NanoHttpd Request Processor (#" + this.requestCount + ")"); |
246 | this.running.add(clientHandler); |
247 | t.start(); |
248 | } |
249 | } |
250 | |
251 | /** |
252 | * Default strategy for creating and cleaning up temporary files. |
253 | * <p/> |
254 | * <p> |
255 | * By default, files are created by <code>File.createTempFile()</code> in |
256 | * the directory specified. |
257 | * </p> |
258 | */ |
259 | public static class DefaultTempFile implements TempFile { |
260 | |
261 | private final File file; |
262 | |
263 | private final OutputStream fstream; |
264 | |
265 | public DefaultTempFile(String tempdir) throws IOException { |
266 | this.file = File.createTempFile("NanoHTTPD-", "", new File(tempdir)); |
267 | this.fstream = new FileOutputStream(this.file); |
268 | System.err.println("Temp file created: " + file); |
269 | } |
270 | |
271 | @Override |
272 | public void delete() throws Exception { |
273 | safeClose(this.fstream); |
274 | System.err.println("Temp file deleted: " + file); |
275 | if (!this.file.delete()) { |
276 | throw new Exception("could not delete temporary file"); |
277 | } |
278 | } |
279 | |
280 | @Override |
281 | public String getName() { |
282 | return this.file.getAbsolutePath(); |
283 | } |
284 | |
285 | @Override |
286 | public OutputStream open() throws Exception { |
287 | return this.fstream; |
288 | } |
289 | } |
290 | |
291 | /** |
292 | * Default strategy for creating and cleaning up temporary files. |
293 | * <p/> |
294 | * <p> |
295 | * This class stores its files in the standard location (that is, wherever |
296 | * <code>java.io.tmpdir</code> points to). Files are added to an internal |
297 | * list, and deleted when no longer needed (that is, when |
298 | * <code>clear()</code> is invoked at the end of processing a request). |
299 | * </p> |
300 | */ |
301 | public static class DefaultTempFileManager implements TempFileManager { |
302 | |
303 | private final String tmpdir; |
304 | |
305 | private final List<TempFile> tempFiles; |
306 | |
307 | public DefaultTempFileManager() { |
308 | this.tmpdir = tempDir().getPath(); // use JavaX, dude |
309 | // System.getProperty("java.io.tmpdir"); |
310 | this.tempFiles = new ArrayList<TempFile>(); |
311 | } |
312 | |
313 | @Override |
314 | public void clear() { |
315 | for (TempFile file : this.tempFiles) { |
316 | try { |
317 | file.delete(); |
318 | } catch (Exception ignored) { |
319 | NanoHTTPD.LOG.log(Level.WARNING, "could not delete file ", ignored); |
320 | } |
321 | } |
322 | this.tempFiles.clear(); |
323 | } |
324 | |
325 | @Override |
326 | public TempFile createTempFile() throws Exception { |
327 | DefaultTempFile tempFile = new DefaultTempFile(this.tmpdir); |
328 | this.tempFiles.add(tempFile); |
329 | return tempFile; |
330 | } |
331 | } |
332 | |
333 | /** |
334 | * Default strategy for creating and cleaning up temporary files. |
335 | */ |
336 | private class DefaultTempFileManagerFactory implements TempFileManagerFactory { |
337 | |
338 | @Override |
339 | public TempFileManager create() { |
340 | return new DefaultTempFileManager(); |
341 | } |
342 | } |
343 | |
344 | private static final String CONTENT_DISPOSITION_REGEX = "([ |\t]*Content-Disposition[ |\t]*:)(.*)"; |
345 | |
346 | private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern.compile(CONTENT_DISPOSITION_REGEX, Pattern.CASE_INSENSITIVE); |
347 | |
348 | private static final String CONTENT_TYPE_REGEX = "([ |\t]*content-type[ |\t]*:)(.*)"; |
349 | |
350 | private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile(CONTENT_TYPE_REGEX, Pattern.CASE_INSENSITIVE); |
351 | |
352 | private static final String CONTENT_DISPOSITION_ATTRIBUTE_REGEX = "[ |\t]*([a-zA-Z]*)[ |\t]*=[ |\t]*['|\"]([^\"^']*)['|\"]"; |
353 | |
354 | private static final Pattern CONTENT_DISPOSITION_ATTRIBUTE_PATTERN = Pattern.compile(CONTENT_DISPOSITION_ATTRIBUTE_REGEX); |
355 | |
356 | protected class HTTPSession implements IHTTPSession { |
357 | |
358 | public static final int BUFSIZE = 8192; |
359 | |
360 | private final TempFileManager tempFileManager; |
361 | |
362 | private final OutputStream outputStream; |
363 | |
364 | private final PushbackInputStream inputStream; |
365 | |
366 | private int splitbyte; |
367 | |
368 | private int rlen; |
369 | |
370 | private String uri; |
371 | |
372 | private Method method; |
373 | |
374 | private Map<String, String> parms; |
375 | |
376 | private Map<String, String> headers; |
377 | |
378 | private CookieHandler cookies; |
379 | |
380 | private String queryParameterString; |
381 | |
382 | private String remoteIp; |
383 | |
384 | private String protocolVersion; |
385 | |
386 | public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream) { |
387 | this.tempFileManager = tempFileManager; |
388 | this.inputStream = new PushbackInputStream(inputStream, HTTPSession.BUFSIZE); |
389 | this.outputStream = outputStream; |
390 | } |
391 | |
392 | public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream, InetAddress inetAddress) { |
393 | this.tempFileManager = tempFileManager; |
394 | this.inputStream = new PushbackInputStream(inputStream, HTTPSession.BUFSIZE); |
395 | this.outputStream = outputStream; |
396 | this.remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString(); |
397 | this.headers = new HashMap<String, String>(); |
398 | } |
399 | |
400 | /** |
401 | * Decodes the sent headers and loads the data into Key/value pairs |
402 | */ |
403 | private void decodeHeader(BufferedReader in, Map<String, String> pre, Map<String, String> parms, Map<String, String> headers) throws ResponseException { |
404 | try { |
405 | // Read the request line |
406 | String inLine = in.readLine(); |
407 | if (inLine == null) { |
408 | return; |
409 | } |
410 | |
411 | StringTokenizer st = new StringTokenizer(inLine); |
412 | if (!st.hasMoreTokens()) { |
413 | throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Syntax error. Usage: GET /example/file.html"); |
414 | } |
415 | |
416 | pre.put("method", st.nextToken()); |
417 | |
418 | if (!st.hasMoreTokens()) { |
419 | throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Missing URI. Usage: GET /example/file.html"); |
420 | } |
421 | |
422 | String uri = st.nextToken(); |
423 | |
424 | // Decode parameters from the URI |
425 | int qmi = uri.indexOf('?'); |
426 | if (qmi >= 0) { |
427 | decodeParms(uri.substring(qmi + 1), parms); |
428 | uri = decodePercent(uri.substring(0, qmi)); |
429 | } else { |
430 | uri = decodePercent(uri); |
431 | } |
432 | |
433 | // If there's another token, its protocol version, |
434 | // followed by HTTP headers. |
435 | // NOTE: this now forces header names lower case since they are |
436 | // case insensitive and vary by client. |
437 | if (st.hasMoreTokens()) { |
438 | protocolVersion = st.nextToken(); |
439 | } else { |
440 | protocolVersion = "HTTP/1.1"; |
441 | NanoHTTPD.LOG.log(Level.FINE, "no protocol version specified, strange. Assuming HTTP/1.1."); |
442 | } |
443 | String line = in.readLine(); |
444 | while (line != null && line.trim().length() > 0) { |
445 | int p = line.indexOf(':'); |
446 | if (p >= 0) { |
447 | headers.put(line.substring(0, p).trim().toLowerCase(Locale.US), line.substring(p + 1).trim()); |
448 | } |
449 | line = in.readLine(); |
450 | } |
451 | |
452 | pre.put("uri", uri); |
453 | } catch (IOException ioe) { |
454 | throw new ResponseException(Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe); |
455 | } |
456 | } |
457 | |
458 | /** |
459 | * Decodes the Multipart Body data and put it into Key/Value pairs. |
460 | */ |
461 | private void decodeMultipartFormData(String boundary, ByteBuffer fbuf, Map<String, String> parms, Map<String, String> files) throws ResponseException { |
462 | try { |
463 | int[] boundary_idxs = getBoundaryPositions(fbuf, boundary.getBytes()); |
464 | if (boundary_idxs.length < 2) { |
465 | throw new ResponseException( |
466 | Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but contains less than two boundary strings."); |
467 | } |
468 | |
469 | final int MAX_HEADER_SIZE = 1024; |
470 | byte[] part_header_buff = new byte[MAX_HEADER_SIZE]; |
471 | for (int bi = 0; bi < boundary_idxs.length - 1; bi++) { |
472 | fbuf.position(boundary_idxs[bi]); |
473 | int len = (fbuf.remaining() < MAX_HEADER_SIZE) ? fbuf.remaining() : MAX_HEADER_SIZE; |
474 | fbuf.get(part_header_buff, 0, len); |
475 | ByteArrayInputStream bais = new ByteArrayInputStream(part_header_buff, 0, len); |
476 | BufferedReader in = new BufferedReader(new InputStreamReader(bais, Charset.forName("US-ASCII"))); |
477 | |
478 | // First line is boundary string |
479 | String mpline = in.readLine(); |
480 | if (!mpline.contains(boundary)) { |
481 | throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but chunk does not start with boundary."); |
482 | } |
483 | |
484 | String part_name = null, file_name = null, content_type = null; |
485 | // Parse the reset of the header lines |
486 | mpline = in.readLine(); |
487 | while (mpline != null && mpline.trim().length() > 0) { |
488 | Matcher matcher = CONTENT_DISPOSITION_PATTERN.matcher(mpline); |
489 | if (matcher.matches()) { |
490 | String attributeString = matcher.group(2); |
491 | matcher = CONTENT_DISPOSITION_ATTRIBUTE_PATTERN.matcher(attributeString); |
492 | while (matcher.find()) { |
493 | String key = matcher.group(1); |
494 | if (key.equalsIgnoreCase("name")) { |
495 | part_name = matcher.group(2); |
496 | } else if (key.equalsIgnoreCase("filename")) { |
497 | file_name = matcher.group(2); |
498 | } |
499 | } |
500 | } |
501 | matcher = CONTENT_TYPE_PATTERN.matcher(mpline); |
502 | if (matcher.matches()) { |
503 | content_type = matcher.group(2).trim(); |
504 | } |
505 | mpline = in.readLine(); |
506 | } |
507 | |
508 | // Read the part data |
509 | int part_header_len = len - (int) in.skip(MAX_HEADER_SIZE); |
510 | if (part_header_len >= len - 4) { |
511 | throw new ResponseException(Status.INTERNAL_ERROR, "Multipart header size exceeds MAX_HEADER_SIZE."); |
512 | } |
513 | int part_data_start = boundary_idxs[bi] + part_header_len; |
514 | int part_data_end = boundary_idxs[bi + 1] - 4; |
515 | |
516 | fbuf.position(part_data_start); |
517 | if (content_type == null) { |
518 | // Read the part into a string |
519 | byte[] data_bytes = new byte[part_data_end - part_data_start]; |
520 | fbuf.get(data_bytes); |
521 | parms.put(part_name, new String(data_bytes)); |
522 | } else { |
523 | // Read it into a file |
524 | String path = saveTmpFile(fbuf, part_data_start, part_data_end - part_data_start); |
525 | if (!files.containsKey(part_name)) { |
526 | files.put(part_name, path); |
527 | } else { |
528 | int count = 2; |
529 | while (files.containsKey(part_name + count)) { |
530 | count++; |
531 | } |
532 | files.put(part_name + count, path); |
533 | } |
534 | parms.put(part_name, file_name); |
535 | } |
536 | } |
537 | } catch (ResponseException re) { |
538 | throw re; |
539 | } catch (Exception e) { |
540 | throw new ResponseException(Status.INTERNAL_ERROR, e.toString()); |
541 | } |
542 | } |
543 | |
544 | /** |
545 | * Decodes parameters in percent-encoded URI-format ( e.g. |
546 | * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given |
547 | * Map. NOTE: this doesn't support multiple identical keys due to the |
548 | * simplicity of Map. |
549 | */ |
550 | private void decodeParms(String parms, Map<String, String> p) { |
551 | if (parms == null) { |
552 | this.queryParameterString = ""; |
553 | return; |
554 | } |
555 | |
556 | this.queryParameterString = parms; |
557 | StringTokenizer st = new StringTokenizer(parms, "&"); |
558 | while (st.hasMoreTokens()) { |
559 | String e = st.nextToken(); |
560 | int sep = e.indexOf('='); |
561 | if (sep >= 0) { |
562 | p.put(decodePercent(e.substring(0, sep)).trim(), decodePercent(e.substring(sep + 1))); |
563 | } else { |
564 | p.put(decodePercent(e).trim(), ""); |
565 | } |
566 | } |
567 | } |
568 | |
569 | @Override |
570 | public void execute() throws IOException { |
571 | Response r = null; |
572 | try { |
573 | // Read the first 8192 bytes. |
574 | // The full header should fit in here. |
575 | // Apache's default header limit is 8KB. |
576 | // Do NOT assume that a single read will get the entire header |
577 | // at once! |
578 | byte[] buf = new byte[HTTPSession.BUFSIZE]; |
579 | this.splitbyte = 0; |
580 | this.rlen = 0; |
581 | |
582 | int read = -1; |
583 | try { |
584 | read = this.inputStream.read(buf, 0, HTTPSession.BUFSIZE); |
585 | } catch (Exception e) { |
586 | safeClose(this.inputStream); |
587 | safeClose(this.outputStream); |
588 | throw new SocketException("NanoHttpd Shutdown"); |
589 | } |
590 | if (read == -1) { |
591 | // socket was been closed |
592 | safeClose(this.inputStream); |
593 | safeClose(this.outputStream); |
594 | throw new SocketException("NanoHttpd Shutdown"); |
595 | } |
596 | while (read > 0) { |
597 | this.rlen += read; |
598 | this.splitbyte = findHeaderEnd(buf, this.rlen); |
599 | if (this.splitbyte > 0) { |
600 | break; |
601 | } |
602 | read = this.inputStream.read(buf, this.rlen, HTTPSession.BUFSIZE - this.rlen); |
603 | } |
604 | |
605 | if (this.splitbyte < this.rlen) { |
606 | this.inputStream.unread(buf, this.splitbyte, this.rlen - this.splitbyte); |
607 | } |
608 | |
609 | this.parms = new HashMap<String, String>(); |
610 | if (null == this.headers) { |
611 | this.headers = new HashMap<String, String>(); |
612 | } else { |
613 | this.headers.clear(); |
614 | } |
615 | |
616 | if (null != this.remoteIp) { |
617 | this.headers.put("remote-addr", this.remoteIp); |
618 | this.headers.put("http-client-ip", this.remoteIp); |
619 | } |
620 | |
621 | // Create a BufferedReader for parsing the header. |
622 | BufferedReader hin = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, this.rlen))); |
623 | |
624 | // Decode the header into parms and header java properties |
625 | Map<String, String> pre = new HashMap<String, String>(); |
626 | decodeHeader(hin, pre, this.parms, this.headers); |
627 | |
628 | this.method = Method.lookup(pre.get("method")); |
629 | if (this.method == null) { |
630 | throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Syntax error."); |
631 | } |
632 | |
633 | this.uri = pre.get("uri"); |
634 | |
635 | this.cookies = new CookieHandler(this.headers); |
636 | |
637 | String connection = this.headers.get("connection"); |
638 | boolean keepAlive = protocolVersion.equals("HTTP/1.1") && (connection == null || !connection.matches("(?i).*close.*")); |
639 | |
640 | // Ok, now do the serve() |
641 | r = serve(this); |
642 | if (r == null) { |
643 | throw new ResponseException(Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response."); |
644 | } else { |
645 | String acceptEncoding = this.headers.get("accept-encoding"); |
646 | this.cookies.unloadQueue(r); |
647 | r.setRequestMethod(this.method); |
648 | r.setGzipEncoding(useGzipWhenAccepted(r) && acceptEncoding != null && acceptEncoding.contains("gzip")); |
649 | r.setKeepAlive(keepAlive); |
650 | r.send(this.outputStream); |
651 | } |
652 | if (!keepAlive || "close".equalsIgnoreCase(r.getHeader("connection"))) { |
653 | throw new SocketException("NanoHttpd Shutdown"); |
654 | } |
655 | } catch (SocketException e) { |
656 | // throw it out to close socket object (finalAccept) |
657 | throw e; |
658 | } catch (SocketTimeoutException ste) { |
659 | // treat socket timeouts the same way we treat socket exceptions |
660 | // i.e. close the stream & finalAccept object by throwing the |
661 | // exception up the call stack. |
662 | throw ste; |
663 | } catch (IOException ioe) { |
664 | Response resp = newFixedLengthResponse(Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); |
665 | resp.send(this.outputStream); |
666 | safeClose(this.outputStream); |
667 | } catch (ResponseException re) { |
668 | Response resp = newFixedLengthResponse(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage()); |
669 | resp.send(this.outputStream); |
670 | safeClose(this.outputStream); |
671 | } finally { |
672 | safeClose(r); |
673 | this.tempFileManager.clear(); |
674 | } |
675 | } |
676 | |
677 | /** |
678 | * Find byte index separating header from body. It must be the last byte |
679 | * of the first two sequential new lines. |
680 | */ |
681 | private int findHeaderEnd(final byte[] buf, int rlen) { |
682 | int splitbyte = 0; |
683 | while (splitbyte + 3 < rlen) { |
684 | if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') { |
685 | return splitbyte + 4; |
686 | } |
687 | splitbyte++; |
688 | } |
689 | return 0; |
690 | } |
691 | |
692 | /** |
693 | * Find the byte positions where multipart boundaries start. This reads |
694 | * a large block at a time and uses a temporary buffer to optimize |
695 | * (memory mapped) file access. |
696 | */ |
697 | private int[] getBoundaryPositions(ByteBuffer b, byte[] boundary) { |
698 | int[] res = new int[0]; |
699 | if (b.remaining() < boundary.length) { |
700 | return res; |
701 | } |
702 | |
703 | int search_window_pos = 0; |
704 | byte[] search_window = new byte[4 * 1024 + boundary.length]; |
705 | |
706 | int first_fill = (b.remaining() < search_window.length) ? b.remaining() : search_window.length; |
707 | b.get(search_window, 0, first_fill); |
708 | int new_bytes = first_fill - boundary.length; |
709 | |
710 | do { |
711 | // Search the search_window |
712 | for (int j = 0; j < new_bytes; j++) { |
713 | for (int i = 0; i < boundary.length; i++) { |
714 | if (search_window[j + i] != boundary[i]) |
715 | break; |
716 | if (i == boundary.length - 1) { |
717 | // Match found, add it to results |
718 | int[] new_res = new int[res.length + 1]; |
719 | System.arraycopy(res, 0, new_res, 0, res.length); |
720 | new_res[res.length] = search_window_pos + j; |
721 | res = new_res; |
722 | } |
723 | } |
724 | } |
725 | search_window_pos += new_bytes; |
726 | |
727 | // Copy the end of the buffer to the start |
728 | System.arraycopy(search_window, search_window.length - boundary.length, search_window, 0, boundary.length); |
729 | |
730 | // Refill search_window |
731 | new_bytes = search_window.length - boundary.length; |
732 | new_bytes = (b.remaining() < new_bytes) ? b.remaining() : new_bytes; |
733 | b.get(search_window, boundary.length, new_bytes); |
734 | } while (new_bytes > 0); |
735 | return res; |
736 | } |
737 | |
738 | @Override |
739 | public CookieHandler getCookies() { |
740 | return this.cookies; |
741 | } |
742 | |
743 | @Override |
744 | public final Map<String, String> getHeaders() { |
745 | return this.headers; |
746 | } |
747 | |
748 | @Override |
749 | public final InputStream getInputStream() { |
750 | return this.inputStream; |
751 | } |
752 | |
753 | @Override |
754 | public final Method getMethod() { |
755 | return this.method; |
756 | } |
757 | |
758 | @Override |
759 | public final Map<String, String> getParms() { |
760 | return this.parms; |
761 | } |
762 | |
763 | @Override |
764 | public String getQueryParameterString() { |
765 | return this.queryParameterString; |
766 | } |
767 | |
768 | private RandomAccessFile getTmpBucket() { |
769 | try { |
770 | TempFile tempFile = this.tempFileManager.createTempFile(); |
771 | return new RandomAccessFile(tempFile.getName(), "rw"); |
772 | } catch (Exception e) { |
773 | throw new Error(e); // we won't recover, so throw an error |
774 | } |
775 | } |
776 | |
777 | @Override |
778 | public final String getUri() { |
779 | return this.uri; |
780 | } |
781 | |
782 | @Override |
783 | public void parseBody(Map<String, String> files) throws IOException, ResponseException { |
784 | final int REQUEST_BUFFER_LEN = 512; |
785 | final int MEMORY_STORE_LIMIT = 1024; |
786 | RandomAccessFile randomAccessFile = null; |
787 | try { |
788 | long size; |
789 | if (this.headers.containsKey("content-length")) { |
790 | size = Integer.parseInt(this.headers.get("content-length")); |
791 | } else if (this.splitbyte < this.rlen) { |
792 | size = this.rlen - this.splitbyte; |
793 | } else { |
794 | size = 0; |
795 | } |
796 | |
797 | ByteArrayOutputStream baos = null; |
798 | DataOutput request_data_output = null; |
799 | |
800 | // Store the request in memory or a file, depending on size |
801 | if (size < MEMORY_STORE_LIMIT) { |
802 | baos = new ByteArrayOutputStream(); |
803 | request_data_output = new DataOutputStream(baos); |
804 | } else { |
805 | randomAccessFile = getTmpBucket(); |
806 | request_data_output = randomAccessFile; |
807 | } |
808 | |
809 | // Read all the body and write it to request_data_output |
810 | byte[] buf = new byte[REQUEST_BUFFER_LEN]; |
811 | while (this.rlen >= 0 && size > 0) { |
812 | this.rlen = this.inputStream.read(buf, 0, (int) Math.min(size, REQUEST_BUFFER_LEN)); |
813 | size -= this.rlen; |
814 | if (this.rlen > 0) { |
815 | request_data_output.write(buf, 0, this.rlen); |
816 | } |
817 | } |
818 | |
819 | ByteBuffer fbuf = null; |
820 | if (baos != null) { |
821 | fbuf = ByteBuffer.wrap(baos.toByteArray(), 0, baos.size()); |
822 | } else { |
823 | fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length()); |
824 | randomAccessFile.seek(0); |
825 | } |
826 | |
827 | // If the method is POST, there may be parameters |
828 | // in data section, too, read it: |
829 | if (Method.POST.equals(this.method)) { |
830 | String contentType = ""; |
831 | String contentTypeHeader = this.headers.get("content-type"); |
832 | |
833 | StringTokenizer st = null; |
834 | if (contentTypeHeader != null) { |
835 | st = new StringTokenizer(contentTypeHeader, ",; "); |
836 | if (st.hasMoreTokens()) { |
837 | contentType = st.nextToken(); |
838 | } |
839 | } |
840 | |
841 | if ("multipart/form-data".equalsIgnoreCase(contentType)) { |
842 | // Handle multipart/form-data |
843 | if (!st.hasMoreTokens()) { |
844 | throw new ResponseException(Status.BAD_REQUEST, |
845 | "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html"); |
846 | } |
847 | |
848 | String boundaryStartString = "boundary="; |
849 | int boundaryContentStart = contentTypeHeader.indexOf(boundaryStartString) + boundaryStartString.length(); |
850 | String boundary = contentTypeHeader.substring(boundaryContentStart, contentTypeHeader.length()); |
851 | if (boundary.startsWith("\"") && boundary.endsWith("\"")) { |
852 | boundary = boundary.substring(1, boundary.length() - 1); |
853 | } |
854 | |
855 | decodeMultipartFormData(boundary, fbuf, this.parms, files); |
856 | } else { |
857 | byte[] postBytes = new byte[fbuf.remaining()]; |
858 | fbuf.get(postBytes); |
859 | String postLine = new String(postBytes).trim(); |
860 | // Handle application/x-www-form-urlencoded |
861 | if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType)) { |
862 | decodeParms(postLine, this.parms); |
863 | } else if (postLine.length() != 0) { |
864 | // Special case for raw POST data => create a |
865 | // special files entry "postData" with raw content |
866 | // data |
867 | files.put("postData", postLine); |
868 | } |
869 | } |
870 | } else if (Method.PUT.equals(this.method)) { |
871 | files.put("content", saveTmpFile(fbuf, 0, fbuf.limit())); |
872 | } |
873 | } finally { |
874 | safeClose(randomAccessFile); |
875 | } |
876 | } |
877 | |
878 | /** |
879 | * Retrieves the content of a sent file and saves it to a temporary |
880 | * file. The full path to the saved file is returned. |
881 | */ |
882 | private String saveTmpFile(ByteBuffer b, int offset, int len) { |
883 | String path = ""; |
884 | if (len > 0) { |
885 | FileOutputStream fileOutputStream = null; |
886 | try { |
887 | TempFile tempFile = this.tempFileManager.createTempFile(); |
888 | ByteBuffer src = b.duplicate(); |
889 | fileOutputStream = new FileOutputStream(tempFile.getName()); |
890 | FileChannel dest = fileOutputStream.getChannel(); |
891 | src.position(offset).limit(offset + len); |
892 | dest.write(src.slice()); |
893 | path = tempFile.getName(); |
894 | } catch (Exception e) { // Catch exception if any |
895 | throw new Error(e); // we won't recover, so throw an error |
896 | } finally { |
897 | safeClose(fileOutputStream); |
898 | } |
899 | } |
900 | return path; |
901 | } |
902 | } |
903 | |
904 | /** |
905 | * Handles one session, i.e. parses the HTTP request and returns the |
906 | * response. |
907 | */ |
908 | public interface IHTTPSession { |
909 | |
910 | void execute() throws IOException; |
911 | |
912 | CookieHandler getCookies(); |
913 | |
914 | Map<String, String> getHeaders(); |
915 | |
916 | InputStream getInputStream(); |
917 | |
918 | Method getMethod(); |
919 | |
920 | Map<String, String> getParms(); |
921 | |
922 | String getQueryParameterString(); |
923 | |
924 | /** |
925 | * @return the path part of the URL. |
926 | */ |
927 | String getUri(); |
928 | |
929 | /** |
930 | * Adds the files in the request body to the files map. |
931 | * |
932 | * @param files |
933 | * map to modify |
934 | */ |
935 | void parseBody(Map<String, String> files) throws IOException, ResponseException; |
936 | } |
937 | |
938 | /** |
939 | * HTTP Request methods, with the ability to decode a <code>String</code> |
940 | * back to its enum value. |
941 | */ |
942 | public enum Method { |
943 | GET, |
944 | PUT, |
945 | POST, |
946 | DELETE, |
947 | HEAD, |
948 | OPTIONS, |
949 | TRACE, |
950 | CONNECT, |
951 | PATCH; |
952 | |
953 | static Method lookup(String method) { |
954 | for (Method m : Method.values()) { |
955 | if (m.toString().equalsIgnoreCase(method)) { |
956 | return m; |
957 | } |
958 | } |
959 | return null; |
960 | } |
961 | } |
962 | |
963 | /** |
964 | * HTTP response. Return one of these from serve(). |
965 | */ |
966 | public static class Response implements Closeable { |
967 | |
968 | |
969 | /** |
970 | * Output stream that will automatically send every write to the wrapped |
971 | * OutputStream according to chunked transfer: |
972 | * http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1 |
973 | */ |
974 | private static class ChunkedOutputStream extends FilterOutputStream { |
975 | |
976 | public ChunkedOutputStream(OutputStream out) { |
977 | super(out); |
978 | } |
979 | |
980 | @Override |
981 | public void write(int b) throws IOException { |
982 | byte[] data = { |
983 | (byte) b |
984 | }; |
985 | write(data, 0, 1); |
986 | } |
987 | |
988 | @Override |
989 | public void write(byte[] b) throws IOException { |
990 | write(b, 0, b.length); |
991 | } |
992 | |
993 | @Override |
994 | public void write(byte[] b, int off, int len) throws IOException { |
995 | if (len == 0) |
996 | return; |
997 | out.write(String.format("%x\r\n", len).getBytes()); |
998 | out.write(b, off, len); |
999 | out.write("\r\n".getBytes()); |
1000 | } |
1001 | |
1002 | public void finish() throws IOException { |
1003 | out.write("0\r\n\r\n".getBytes()); |
1004 | } |
1005 | |
1006 | } |
1007 | |
1008 | /** |
1009 | * HTTP status code after processing, e.g. "200 OK", Status.OK |
1010 | */ |
1011 | private IStatus status; |
1012 | |
1013 | /** |
1014 | * MIME type of content, e.g. "text/html" |
1015 | */ |
1016 | private String mimeType; |
1017 | |
1018 | /** |
1019 | * Data of the response, may be null. |
1020 | */ |
1021 | private InputStream data; |
1022 | |
1023 | private long contentLength; |
1024 | |
1025 | /** |
1026 | * Headers for the HTTP response. Use addHeader() to add lines. |
1027 | */ |
1028 | private final Map<String, String> header = new HashMap<String, String>(); |
1029 | |
1030 | /** |
1031 | * The request method that spawned this response. |
1032 | */ |
1033 | private Method requestMethod; |
1034 | |
1035 | /** |
1036 | * Use chunkedTransfer |
1037 | */ |
1038 | private boolean chunkedTransfer; |
1039 | |
1040 | private boolean encodeAsGzip; |
1041 | |
1042 | private boolean keepAlive; |
1043 | |
1044 | /** |
1045 | * Creates a fixed length response if totalBytes>=0, otherwise chunked. |
1046 | */ |
1047 | protected Response(IStatus status, String mimeType, InputStream data, long totalBytes) { |
1048 | this.status = status; |
1049 | this.mimeType = mimeType; |
1050 | if (data == null) { |
1051 | this.data = new ByteArrayInputStream(new byte[0]); |
1052 | this.contentLength = 0L; |
1053 | } else { |
1054 | this.data = data; |
1055 | this.contentLength = totalBytes; |
1056 | } |
1057 | this.chunkedTransfer = this.contentLength < 0; |
1058 | keepAlive = true; |
1059 | } |
1060 | |
1061 | @Override |
1062 | public void close() throws IOException { |
1063 | if (this.data != null) { |
1064 | this.data.close(); |
1065 | } |
1066 | } |
1067 | |
1068 | /** |
1069 | * Adds given line to the header. |
1070 | */ |
1071 | public void addHeader(String name, String value) { |
1072 | this.header.put(name, value); |
1073 | } |
1074 | |
1075 | public InputStream getData() { |
1076 | return this.data; |
1077 | } |
1078 | |
1079 | public String getHeader(String name) { |
1080 | for (String headerName : header.keySet()) { |
1081 | if (headerName.equalsIgnoreCase(name)) { |
1082 | return header.get(headerName); |
1083 | } |
1084 | } |
1085 | return null; |
1086 | } |
1087 | |
1088 | public String getMimeType() { |
1089 | return this.mimeType; |
1090 | } |
1091 | |
1092 | public Method getRequestMethod() { |
1093 | return this.requestMethod; |
1094 | } |
1095 | |
1096 | public IStatus getStatus() { |
1097 | return this.status; |
1098 | } |
1099 | |
1100 | public void setGzipEncoding(boolean encodeAsGzip) { |
1101 | this.encodeAsGzip = encodeAsGzip; |
1102 | } |
1103 | |
1104 | public void setKeepAlive(boolean useKeepAlive) { |
1105 | this.keepAlive = useKeepAlive; |
1106 | } |
1107 | |
1108 | private boolean headerAlreadySent(Map<String, String> header, String name) { |
1109 | boolean alreadySent = false; |
1110 | for (String headerName : header.keySet()) { |
1111 | alreadySent |= headerName.equalsIgnoreCase(name); |
1112 | } |
1113 | return alreadySent; |
1114 | } |
1115 | |
1116 | /** |
1117 | * Sends given response to the socket. |
1118 | */ |
1119 | protected void send(OutputStream outputStream) { |
1120 | String mime = this.mimeType; |
1121 | SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US); |
1122 | gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT")); |
1123 | |
1124 | try { |
1125 | if (this.status == null) { |
1126 | throw new Error("sendResponse(): Status can't be null."); |
1127 | } |
1128 | PrintWriter pw = new PrintWriter(new BufferedWriter(new OutputStreamWriter(outputStream, "UTF-8")), false); |
1129 | pw.print("HTTP/1.1 " + this.status.getDescription() + " \r\n"); |
1130 | |
1131 | if (mime != null) { |
1132 | pw.print("Content-Type: " + mime + "\r\n"); |
1133 | } |
1134 | |
1135 | if (this.header == null || this.header.get("Date") == null) { |
1136 | pw.print("Date: " + gmtFrmt.format(new Date()) + "\r\n"); |
1137 | } |
1138 | |
1139 | if (this.header != null) { |
1140 | for (String key : this.header.keySet()) { |
1141 | String value = this.header.get(key); |
1142 | pw.print(key + ": " + value + "\r\n"); |
1143 | } |
1144 | } |
1145 | |
1146 | if (!headerAlreadySent(header, "connection")) { |
1147 | pw.print("Connection: " + (this.keepAlive ? "keep-alive" : "close") + "\r\n"); |
1148 | } |
1149 | |
1150 | if (headerAlreadySent(this.header, "content-length")) { |
1151 | encodeAsGzip = false; |
1152 | } |
1153 | |
1154 | if (encodeAsGzip) { |
1155 | pw.print("Content-Encoding: gzip\r\n"); |
1156 | setChunkedTransfer(true); |
1157 | } |
1158 | |
1159 | long pending = this.data != null ? this.contentLength : 0; |
1160 | if (this.requestMethod != Method.HEAD && this.chunkedTransfer) { |
1161 | pw.print("Transfer-Encoding: chunked\r\n"); |
1162 | } else if (!encodeAsGzip) { |
1163 | pending = sendContentLengthHeaderIfNotAlreadyPresent(pw, this.header, pending); |
1164 | } |
1165 | pw.print("\r\n"); |
1166 | pw.flush(); |
1167 | sendBodyWithCorrectTransferAndEncoding(outputStream, pending); |
1168 | outputStream.flush(); |
1169 | safeClose(this.data); |
1170 | } catch (IOException ioe) { |
1171 | NanoHTTPD.LOG.log(Level.SEVERE, "Could not send response to the client", ioe); |
1172 | } |
1173 | } |
1174 | |
1175 | private void sendBodyWithCorrectTransferAndEncoding(OutputStream outputStream, long pending) throws IOException { |
1176 | if (this.requestMethod != Method.HEAD && this.chunkedTransfer) { |
1177 | ChunkedOutputStream chunkedOutputStream = new ChunkedOutputStream(outputStream); |
1178 | sendBodyWithCorrectEncoding(chunkedOutputStream, -1); |
1179 | chunkedOutputStream.finish(); |
1180 | } else { |
1181 | sendBodyWithCorrectEncoding(outputStream, pending); |
1182 | } |
1183 | } |
1184 | |
1185 | private void sendBodyWithCorrectEncoding(OutputStream outputStream, long pending) throws IOException { |
1186 | if (encodeAsGzip) { |
1187 | GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream); |
1188 | sendBody(gzipOutputStream, -1); |
1189 | gzipOutputStream.finish(); |
1190 | } else { |
1191 | sendBody(outputStream, pending); |
1192 | } |
1193 | } |
1194 | |
1195 | /** |
1196 | * Sends the body to the specified OutputStream. The pending parameter |
1197 | * limits the maximum amounts of bytes sent unless it is -1, in which |
1198 | * case everything is sent. |
1199 | * |
1200 | * @param outputStream |
1201 | * the OutputStream to send data to |
1202 | * @param pending |
1203 | * -1 to send everything, otherwise sets a max limit to the |
1204 | * number of bytes sent |
1205 | * @throws IOException |
1206 | * if something goes wrong while sending the data. |
1207 | */ |
1208 | private void sendBody(OutputStream outputStream, long pending) throws IOException { |
1209 | long BUFFER_SIZE = 16 * 1024; |
1210 | byte[] buff = new byte[(int) BUFFER_SIZE]; |
1211 | boolean sendEverything = pending == -1; |
1212 | while (pending > 0 || sendEverything) { |
1213 | long bytesToRead = sendEverything ? BUFFER_SIZE : Math.min(pending, BUFFER_SIZE); |
1214 | int read = this.data.read(buff, 0, (int) bytesToRead); |
1215 | if (read <= 0) { |
1216 | break; |
1217 | } |
1218 | outputStream.write(buff, 0, read); |
1219 | if (!sendEverything) { |
1220 | pending -= read; |
1221 | } |
1222 | } |
1223 | } |
1224 | |
1225 | protected long sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, Map<String, String> header, long size) { |
1226 | for (String headerName : header.keySet()) { |
1227 | if (headerName.equalsIgnoreCase("content-length")) { |
1228 | try { |
1229 | return Long.parseLong(header.get(headerName)); |
1230 | } catch (NumberFormatException ex) { |
1231 | return size; |
1232 | } |
1233 | } |
1234 | } |
1235 | |
1236 | pw.print("Content-Length: " + size + "\r\n"); |
1237 | return size; |
1238 | } |
1239 | |
1240 | public void setChunkedTransfer(boolean chunkedTransfer) { |
1241 | this.chunkedTransfer = chunkedTransfer; |
1242 | } |
1243 | |
1244 | public void setData(InputStream data) { |
1245 | this.data = data; |
1246 | } |
1247 | |
1248 | public void setMimeType(String mimeType) { |
1249 | this.mimeType = mimeType; |
1250 | } |
1251 | |
1252 | public void setRequestMethod(Method requestMethod) { |
1253 | this.requestMethod = requestMethod; |
1254 | } |
1255 | |
1256 | public void setStatus(IStatus status) { |
1257 | this.status = status; |
1258 | } |
1259 | } |
1260 | |
1261 | public static final class ResponseException extends Exception { |
1262 | |
1263 | private static final long serialVersionUID = 6569838532917408380L; |
1264 | |
1265 | private final Status status; |
1266 | |
1267 | public ResponseException(Status status, String message) { |
1268 | super(message); |
1269 | this.status = status; |
1270 | } |
1271 | |
1272 | public ResponseException(Status status, String message, Exception e) { |
1273 | super(message, e); |
1274 | this.status = status; |
1275 | } |
1276 | |
1277 | public Status getStatus() { |
1278 | return this.status; |
1279 | } |
1280 | } |
1281 | |
1282 | /** |
1283 | * The runnable that will be used for the main listening thread. |
1284 | */ |
1285 | public class ServerRunnable implements Runnable { |
1286 | |
1287 | private final int timeout; |
1288 | |
1289 | private IOException bindException; |
1290 | |
1291 | private boolean hasBinded = false; |
1292 | |
1293 | private ServerRunnable(int timeout) { |
1294 | this.timeout = timeout; |
1295 | } |
1296 | |
1297 | @Override |
1298 | public void run() { |
1299 | try { |
1300 | myServerSocket.bind(hostname != null ? new InetSocketAddress(hostname, myPort) : new InetSocketAddress(myPort)); |
1301 | hasBinded = true; |
1302 | } catch (IOException e) { |
1303 | this.bindException = e; |
1304 | return; |
1305 | } |
1306 | do { |
1307 | try { |
1308 | final Socket finalAccept = NanoHTTPD.this.myServerSocket.accept(); |
1309 | if (this.timeout > 0) { |
1310 | finalAccept.setSoTimeout(this.timeout); |
1311 | } |
1312 | final InputStream inputStream = finalAccept.getInputStream(); |
1313 | NanoHTTPD.this.asyncRunner.exec(createClientHandler(finalAccept, inputStream)); |
1314 | } catch (IOException e) { |
1315 | NanoHTTPD.LOG.log(Level.FINE, "Communication with the client broken", e); |
1316 | } |
1317 | } while (!NanoHTTPD.this.myServerSocket.isClosed()); |
1318 | } |
1319 | } |
1320 | |
1321 | /** |
1322 | * A temp file. |
1323 | * <p/> |
1324 | * <p> |
1325 | * Temp files are responsible for managing the actual temporary storage and |
1326 | * cleaning themselves up when no longer needed. |
1327 | * </p> |
1328 | */ |
1329 | public interface TempFile { |
1330 | |
1331 | void delete() throws Exception; |
1332 | |
1333 | String getName(); |
1334 | |
1335 | OutputStream open() throws Exception; |
1336 | } |
1337 | |
1338 | /** |
1339 | * Temp file manager. |
1340 | * <p/> |
1341 | * <p> |
1342 | * Temp file managers are created 1-to-1 with incoming requests, to create |
1343 | * and cleanup temporary files created as a result of handling the request. |
1344 | * </p> |
1345 | */ |
1346 | public interface TempFileManager { |
1347 | |
1348 | void clear(); |
1349 | |
1350 | TempFile createTempFile() throws Exception; |
1351 | } |
1352 | |
1353 | /** |
1354 | * Factory to create temp file managers. |
1355 | */ |
1356 | public interface TempFileManagerFactory { |
1357 | |
1358 | TempFileManager create(); |
1359 | } |
1360 | |
1361 | /** |
1362 | * Maximum time to wait on Socket.getInputStream().read() (in milliseconds) |
1363 | * This is required as the Keep-Alive HTTP connections would otherwise block |
1364 | * the socket reading thread forever (or as long the browser is open). |
1365 | */ |
1366 | public static final int SOCKET_READ_TIMEOUT = 5000; |
1367 | |
1368 | /** |
1369 | * Common MIME type for dynamic content: plain text |
1370 | */ |
1371 | public static final String MIME_PLAINTEXT = "text/plain"; |
1372 | |
1373 | /** |
1374 | * Common MIME type for dynamic content: html |
1375 | */ |
1376 | public static final String MIME_HTML = "text/html"; |
1377 | |
1378 | /** |
1379 | * Pseudo-Parameter to use to store the actual query string in the |
1380 | * parameters map for later re-processing. |
1381 | */ |
1382 | private static final String QUERY_STRING_PARAMETER = "NanoHttpd.QUERY_STRING"; |
1383 | |
1384 | /** |
1385 | * logger to log to. |
1386 | */ |
1387 | private static final Logger LOG = Logger.getLogger(NanoHTTPD.class.getName()); |
1388 | |
1389 | /** |
1390 | * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and an |
1391 | * array of loaded KeyManagers. These objects must properly |
1392 | * loaded/initialized by the caller. |
1393 | */ |
1394 | public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManager[] keyManagers) throws IOException { |
1395 | SSLServerSocketFactory res = null; |
1396 | try { |
1397 | TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); |
1398 | trustManagerFactory.init(loadedKeyStore); |
1399 | SSLContext ctx = SSLContext.getInstance("TLS"); |
1400 | ctx.init(keyManagers, trustManagerFactory.getTrustManagers(), null); |
1401 | res = ctx.getServerSocketFactory(); |
1402 | } catch (Exception e) { |
1403 | throw new IOException(e.getMessage()); |
1404 | } |
1405 | return res; |
1406 | } |
1407 | |
1408 | /** |
1409 | * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and a |
1410 | * loaded KeyManagerFactory. These objects must properly loaded/initialized |
1411 | * by the caller. |
1412 | */ |
1413 | public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManagerFactory loadedKeyFactory) throws IOException { |
1414 | SSLServerSocketFactory res = null; |
1415 | try { |
1416 | TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); |
1417 | trustManagerFactory.init(loadedKeyStore); |
1418 | SSLContext ctx = SSLContext.getInstance("TLS"); |
1419 | ctx.init(loadedKeyFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); |
1420 | res = ctx.getServerSocketFactory(); |
1421 | } catch (Exception e) { |
1422 | throw new IOException(e.getMessage()); |
1423 | } |
1424 | return res; |
1425 | } |
1426 | |
1427 | /** |
1428 | * Creates an SSLSocketFactory for HTTPS. Pass a KeyStore resource with your |
1429 | * certificate and passphrase |
1430 | */ |
1431 | public static SSLServerSocketFactory makeSSLSocketFactory(String keyAndTrustStoreClasspathPath, char[] passphrase) throws IOException { |
1432 | SSLServerSocketFactory res = null; |
1433 | try { |
1434 | KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); |
1435 | InputStream keystoreStream = NanoHTTPD.class.getResourceAsStream(keyAndTrustStoreClasspathPath); |
1436 | keystore.load(keystoreStream, passphrase); |
1437 | TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); |
1438 | trustManagerFactory.init(keystore); |
1439 | KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); |
1440 | keyManagerFactory.init(keystore, passphrase); |
1441 | SSLContext ctx = SSLContext.getInstance("TLS"); |
1442 | ctx.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); |
1443 | res = ctx.getServerSocketFactory(); |
1444 | } catch (Exception e) { |
1445 | throw new IOException(e.getMessage()); |
1446 | } |
1447 | return res; |
1448 | } |
1449 | |
1450 | private static final void safeClose(Object closeable) { |
1451 | try { |
1452 | if (closeable != null) { |
1453 | if (closeable instanceof Closeable) { |
1454 | ((Closeable) closeable).close(); |
1455 | } else if (closeable instanceof Socket) { |
1456 | ((Socket) closeable).close(); |
1457 | } else if (closeable instanceof ServerSocket) { |
1458 | ((ServerSocket) closeable).close(); |
1459 | } else { |
1460 | throw new IllegalArgumentException("Unknown object to close"); |
1461 | } |
1462 | } |
1463 | } catch (IOException e) { |
1464 | NanoHTTPD.LOG.log(Level.SEVERE, "Could not close", e); |
1465 | } |
1466 | } |
1467 | |
1468 | private final String hostname; |
1469 | |
1470 | private final int myPort; |
1471 | |
1472 | private ServerSocket myServerSocket; |
1473 | |
1474 | private SSLServerSocketFactory sslServerSocketFactory; |
1475 | |
1476 | private Thread myThread; |
1477 | |
1478 | /** |
1479 | * Pluggable strategy for asynchronously executing requests. |
1480 | */ |
1481 | protected AsyncRunner asyncRunner; |
1482 | |
1483 | /** |
1484 | * Pluggable strategy for creating and cleaning up temporary files. |
1485 | */ |
1486 | private TempFileManagerFactory tempFileManagerFactory; |
1487 | |
1488 | /** |
1489 | * Constructs an HTTP server on given port. |
1490 | */ |
1491 | public NanoHTTPD(int port) { |
1492 | this(null, port); |
1493 | } |
1494 | |
1495 | // ------------------------------------------------------------------------------- |
1496 | // // |
1497 | // |
1498 | // Threading Strategy. |
1499 | // |
1500 | // ------------------------------------------------------------------------------- |
1501 | // // |
1502 | |
1503 | /** |
1504 | * Constructs an HTTP server on given hostname and port. |
1505 | */ |
1506 | public NanoHTTPD(String hostname, int port) { |
1507 | this.hostname = hostname; |
1508 | this.myPort = port; |
1509 | setTempFileManagerFactory(new DefaultTempFileManagerFactory()); |
1510 | setAsyncRunner(new DefaultAsyncRunner()); |
1511 | } |
1512 | |
1513 | /** |
1514 | * Forcibly closes all connections that are open. |
1515 | */ |
1516 | public synchronized void closeAllConnections() { |
1517 | stop(); |
1518 | } |
1519 | |
1520 | /** |
1521 | * create a instance of the client handler, subclasses can return a subclass |
1522 | * of the ClientHandler. |
1523 | * |
1524 | * @param finalAccept |
1525 | * the socket the cleint is connected to |
1526 | * @param inputStream |
1527 | * the input stream |
1528 | * @return the client handler |
1529 | */ |
1530 | protected ClientHandler createClientHandler(final Socket finalAccept, final InputStream inputStream) { |
1531 | return new ClientHandler(inputStream, finalAccept); |
1532 | } |
1533 | |
1534 | /** |
1535 | * Instantiate the server runnable, can be overwritten by subclasses to |
1536 | * provide a subclass of the ServerRunnable. |
1537 | * |
1538 | * @param timeout |
1539 | * the socet timeout to use. |
1540 | * @return the server runnable. |
1541 | */ |
1542 | protected ServerRunnable createServerRunnable(final int timeout) { |
1543 | return new ServerRunnable(timeout); |
1544 | } |
1545 | |
1546 | /** |
1547 | * Decode parameters from a URL, handing the case where a single parameter |
1548 | * name might have been supplied several times, by return lists of values. |
1549 | * In general these lists will contain a single element. |
1550 | * |
1551 | * @param parms |
1552 | * original <b>NanoHTTPD</b> parameters values, as passed to the |
1553 | * <code>serve()</code> method. |
1554 | * @return a map of <code>String</code> (parameter name) to |
1555 | * <code>List<String></code> (a list of the values supplied). |
1556 | */ |
1557 | protected Map<String, List<String>> decodeParameters(Map<String, String> parms) { |
1558 | return this.decodeParameters(parms.get(NanoHTTPD.QUERY_STRING_PARAMETER)); |
1559 | } |
1560 | |
1561 | // ------------------------------------------------------------------------------- |
1562 | // // |
1563 | |
1564 | /** |
1565 | * Decode parameters from a URL, handing the case where a single parameter |
1566 | * name might have been supplied several times, by return lists of values. |
1567 | * In general these lists will contain a single element. |
1568 | * |
1569 | * @param queryString |
1570 | * a query string pulled from the URL. |
1571 | * @return a map of <code>String</code> (parameter name) to |
1572 | * <code>List<String></code> (a list of the values supplied). |
1573 | */ |
1574 | protected Map<String, List<String>> decodeParameters(String queryString) { |
1575 | Map<String, List<String>> parms = new HashMap<String, List<String>>(); |
1576 | if (queryString != null) { |
1577 | StringTokenizer st = new StringTokenizer(queryString, "&"); |
1578 | while (st.hasMoreTokens()) { |
1579 | String e = st.nextToken(); |
1580 | int sep = e.indexOf('='); |
1581 | String propertyName = sep >= 0 ? decodePercent(e.substring(0, sep)).trim() : decodePercent(e).trim(); |
1582 | if (!parms.containsKey(propertyName)) { |
1583 | parms.put(propertyName, new ArrayList<String>()); |
1584 | } |
1585 | String propertyValue = sep >= 0 ? decodePercent(e.substring(sep + 1)) : null; |
1586 | if (propertyValue != null) { |
1587 | parms.get(propertyName).add(propertyValue); |
1588 | } |
1589 | } |
1590 | } |
1591 | return parms; |
1592 | } |
1593 | |
1594 | /** |
1595 | * Decode percent encoded <code>String</code> values. |
1596 | * |
1597 | * @param str |
1598 | * the percent encoded <code>String</code> |
1599 | * @return expanded form of the input, for example "foo%20bar" becomes |
1600 | * "foo bar" |
1601 | */ |
1602 | protected String decodePercent(String str) { |
1603 | String decoded = null; |
1604 | try { |
1605 | decoded = URLDecoder.decode(str, "UTF8"); |
1606 | } catch (UnsupportedEncodingException ignored) { |
1607 | NanoHTTPD.LOG.log(Level.WARNING, "Encoding not supported, ignored", ignored); |
1608 | } |
1609 | return decoded; |
1610 | } |
1611 | |
1612 | /** |
1613 | * @return true if the gzip compression should be used if the client |
1614 | * accespts it. Default this option is on for text content and off |
1615 | * for everything else. |
1616 | */ |
1617 | protected boolean useGzipWhenAccepted(Response r) { |
1618 | return r.getMimeType() != null && r.getMimeType().toLowerCase().contains("text/"); |
1619 | } |
1620 | |
1621 | public final int getListeningPort() { |
1622 | return this.myServerSocket == null ? -1 : this.myServerSocket.getLocalPort(); |
1623 | } |
1624 | |
1625 | public final boolean isAlive() { |
1626 | return wasStarted() && !this.myServerSocket.isClosed() && this.myThread.isAlive(); |
1627 | } |
1628 | |
1629 | public void join() throws InterruptedException { |
1630 | myThread.join(); |
1631 | } |
1632 | |
1633 | /** |
1634 | * Call before start() to serve over HTTPS instead of HTTP |
1635 | */ |
1636 | public void makeSecure(SSLServerSocketFactory sslServerSocketFactory) { |
1637 | this.sslServerSocketFactory = sslServerSocketFactory; |
1638 | } |
1639 | |
1640 | /** |
1641 | * Create a response with unknown length (using HTTP 1.1 chunking). |
1642 | */ |
1643 | public Response newChunkedResponse(IStatus status, String mimeType, InputStream data) { |
1644 | return new Response(status, mimeType, data, -1); |
1645 | } |
1646 | |
1647 | /** |
1648 | * Create a response with known length. |
1649 | */ |
1650 | public static Response newFixedLengthResponse(IStatus status, String mimeType, InputStream data, long totalBytes) { |
1651 | return new Response(status, mimeType, data, totalBytes); |
1652 | } |
1653 | |
1654 | /** |
1655 | * Create a text response with known length. |
1656 | */ |
1657 | public Response newFixedLengthResponse(IStatus status, String mimeType, String txt) { |
1658 | if (txt == null) { |
1659 | return newFixedLengthResponse(status, mimeType, new ByteArrayInputStream(new byte[0]), 0); |
1660 | } else { |
1661 | byte[] bytes; |
1662 | try { |
1663 | bytes = txt.getBytes("UTF-8"); |
1664 | } catch (UnsupportedEncodingException e) { |
1665 | NanoHTTPD.LOG.log(Level.SEVERE, "encoding problem, responding nothing", e); |
1666 | bytes = new byte[0]; |
1667 | } |
1668 | return newFixedLengthResponse(status, mimeType, new ByteArrayInputStream(bytes), bytes.length); |
1669 | } |
1670 | } |
1671 | |
1672 | /** |
1673 | * Create a text response with known length. |
1674 | */ |
1675 | public Response newFixedLengthResponse(String msg) { |
1676 | return newFixedLengthResponse(Status.OK, NanoHTTPD.MIME_HTML, msg); |
1677 | } |
1678 | |
1679 | /** |
1680 | * Override this to customize the server. |
1681 | * <p/> |
1682 | * <p/> |
1683 | * (By default, this returns a 404 "Not Found" plain text error response.) |
1684 | * |
1685 | * @param session |
1686 | * The HTTP session |
1687 | * @return HTTP response, see class Response for details |
1688 | */ |
1689 | public Response serve(IHTTPSession session) { |
1690 | currentSession.set(session); |
1691 | Map<String, String> files = new HashMap<String, String>(); |
1692 | Method method = session.getMethod(); |
1693 | if (Method.PUT.equals(method) || Method.POST.equals(method)) { |
1694 | try { |
1695 | session.parseBody(files); |
1696 | } catch (IOException ioe) { |
1697 | return newFixedLengthResponse(Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); |
1698 | } catch (ResponseException re) { |
1699 | return newFixedLengthResponse(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage()); |
1700 | } |
1701 | } |
1702 | |
1703 | Map<String, String> parms = session.getParms(); |
1704 | parms.put(NanoHTTPD.QUERY_STRING_PARAMETER, session.getQueryParameterString()); |
1705 | return serve(session.getUri(), method, session.getHeaders(), parms, files); |
1706 | } |
1707 | |
1708 | /** |
1709 | * Override this to customize the server. |
1710 | * <p/> |
1711 | * <p/> |
1712 | * (By default, this returns a 404 "Not Found" plain text error response.) |
1713 | * |
1714 | * @param uri |
1715 | * Percent-decoded URI without parameters, for example |
1716 | * "/index.cgi" |
1717 | * @param method |
1718 | * "GET", "POST" etc. |
1719 | * @param parms |
1720 | * Parsed, percent decoded parameters from URI and, in case of |
1721 | * POST, data. |
1722 | * @param headers |
1723 | * Header entries, percent decoded |
1724 | * @return HTTP response, see class Response for details |
1725 | */ |
1726 | @Deprecated |
1727 | public Response serve(String uri, Method method, Map<String, String> headers, Map<String, String> parms, Map<String, String> files) { |
1728 | return newFixedLengthResponse(Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Not Found"); |
1729 | } |
1730 | |
1731 | /** |
1732 | * Pluggable strategy for asynchronously executing requests. |
1733 | * |
1734 | * @param asyncRunner |
1735 | * new strategy for handling threads. |
1736 | */ |
1737 | public void setAsyncRunner(AsyncRunner asyncRunner) { |
1738 | this.asyncRunner = asyncRunner; |
1739 | } |
1740 | |
1741 | /** |
1742 | * Pluggable strategy for creating and cleaning up temporary files. |
1743 | * |
1744 | * @param tempFileManagerFactory |
1745 | * new strategy for handling temp files. |
1746 | */ |
1747 | public void setTempFileManagerFactory(TempFileManagerFactory tempFileManagerFactory) { |
1748 | this.tempFileManagerFactory = tempFileManagerFactory; |
1749 | } |
1750 | |
1751 | /** |
1752 | * Start the server. |
1753 | * |
1754 | * @throws IOException |
1755 | * if the socket is in use. |
1756 | */ |
1757 | public void start() throws IOException { |
1758 | start(NanoHTTPD.SOCKET_READ_TIMEOUT); |
1759 | } |
1760 | |
1761 | /** |
1762 | * Start the server. |
1763 | * |
1764 | * @param timeout |
1765 | * timeout to use for socket connections. |
1766 | * @throws IOException |
1767 | * if the socket is in use. |
1768 | */ |
1769 | public void start(final int timeout) throws IOException { |
1770 | if (this.sslServerSocketFactory != null) { |
1771 | SSLServerSocket ss = (SSLServerSocket) this.sslServerSocketFactory.createServerSocket(); |
1772 | ss.setNeedClientAuth(false); |
1773 | this.myServerSocket = ss; |
1774 | } else { |
1775 | this.myServerSocket = new ServerSocket(); |
1776 | } |
1777 | this.myServerSocket.setReuseAddress(true); |
1778 | |
1779 | ServerRunnable serverRunnable = createServerRunnable(timeout); |
1780 | this.myThread = new Thread(serverRunnable); |
1781 | this.myThread.setDaemon(true); |
1782 | this.myThread.setName("NanoHttpd Main Listener"); |
1783 | this.myThread.start(); |
1784 | while (!serverRunnable.hasBinded && serverRunnable.bindException == null) { |
1785 | try { |
1786 | Thread.sleep(10L); |
1787 | } catch (Throwable e) { |
1788 | // on android this may not be allowed, that's why we |
1789 | // catch throwable the wait should be very short because we are |
1790 | // just waiting for the bind of the socket |
1791 | } |
1792 | } |
1793 | if (serverRunnable.bindException != null) { |
1794 | throw serverRunnable.bindException; |
1795 | } |
1796 | |
1797 | System.out.println("HTTP server started (listening on port " + myPort + "!)"); |
1798 | printMyIPs(); |
1799 | } |
1800 | |
1801 | /** |
1802 | * Stop the server. |
1803 | */ |
1804 | public void stop() { |
1805 | try { |
1806 | safeClose(this.myServerSocket); |
1807 | this.asyncRunner.closeAll(); |
1808 | if (this.myThread != null) { |
1809 | this.myThread.join(); |
1810 | } |
1811 | } catch (Exception e) { |
1812 | NanoHTTPD.LOG.log(Level.SEVERE, "Could not stop all connections", e); |
1813 | } |
1814 | } |
1815 | |
1816 | public final boolean wasStarted() { |
1817 | return this.myServerSocket != null && this.myThread != null; |
1818 | } |
1819 | |
1820 | !include #1000841 // printMyIPs |
1821 | } |
1822 | |
1823 | interface IStatus { |
1824 | String getDescription(); |
1825 | int getRequestStatus(); |
1826 | } |
1827 | |
1828 | /** |
1829 | * Some HTTP response status codes |
1830 | */ |
1831 | enum Status implements IStatus { |
1832 | SWITCH_PROTOCOL(101, "Switching Protocols"), |
1833 | OK(200, "OK"), |
1834 | CREATED(201, "Created"), |
1835 | ACCEPTED(202, "Accepted"), |
1836 | NO_CONTENT(204, "No Content"), |
1837 | PARTIAL_CONTENT(206, "Partial Content"), |
1838 | REDIRECT(301, "Moved Permanently"), |
1839 | NOT_MODIFIED(304, "Not Modified"), |
1840 | BAD_REQUEST(400, "Bad Request"), |
1841 | UNAUTHORIZED(401, "Unauthorized"), |
1842 | FORBIDDEN(403, "Forbidden"), |
1843 | NOT_FOUND(404, "Not Found"), |
1844 | METHOD_NOT_ALLOWED(405, "Method Not Allowed"), |
1845 | REQUEST_TIMEOUT(408, "Request Timeout"), |
1846 | RANGE_NOT_SATISFIABLE(416, "Requested Range Not Satisfiable"), |
1847 | INTERNAL_ERROR(500, "Internal Server Error"), |
1848 | UNSUPPORTED_HTTP_VERSION(505, "HTTP Version Not Supported"); |
1849 | |
1850 | private final int requestStatus; |
1851 | |
1852 | private final String description; |
1853 | |
1854 | Status(int requestStatus, String description) { |
1855 | this.requestStatus = requestStatus; |
1856 | this.description = description; |
1857 | } |
1858 | |
1859 | @Override |
1860 | public String getDescription() { |
1861 | return "" + this.requestStatus + " " + this.description; |
1862 | } |
1863 | |
1864 | @Override |
1865 | public int getRequestStatus() { |
1866 | return this.requestStatus; |
1867 | } |
1868 | } |
download show line numbers debug dex old transpilations
Travelled to 5 computer(s): cfunsshuasjs, gwrvuhgaqvyk, ishqpsrjomds, mqqgnosmbjvj, tvejysmllsmz
No comments. add comment
Snippet ID: | #1000433 |
Snippet name: | NanoHTTPD (old) |
Eternal ID of this version: | #1000433/2 |
Text MD5: | a0e6f8769c0a464a096703034a531e60 |
Author: | stefan |
Category: | |
Type: | JavaX fragment (include) |
Public (visible to everyone): | Yes |
Archived (hidden from active list): | Yes |
Created/modified: | 2018-07-12 13:48:11 |
Source code size: | 70256 bytes / 1868 lines |
Pitched / IR pitched: | No / No |
Views / Downloads: | 754 / 2305 |
Version history: | 1 change(s) |
Referenced in: | [show references] |