7
|
1 #!/usr/bin/env python3
|
|
2
|
|
3 # spec: simplest python web server with range support and multithreading that takes root path,
|
|
4 # port and bind address as command line arguments; by default uses the current dir as webroot,
|
|
5 # port 8000 and bind address of 0.0.0.0
|
|
6 # borrowed from https://github.com/danvk/RangeHTTPServer
|
|
7 # and reborrowed from https://gist.github.com/glowinthedark/b99900abe935e4ab4857314d647a9068
|
|
8
|
|
9
|
|
10 import argparse
|
|
11 import functools
|
|
12 import os
|
|
13 import re
|
|
14 import socketserver
|
|
15 import webbrowser
|
|
16 from http.server import SimpleHTTPRequestHandler
|
|
17
|
|
18
|
|
19 DEFAULT_PORT = 8080
|
|
20
|
|
21
|
|
22 def copy_byte_range(infile, outfile, start=None, stop=None, bufsize=16 * 1024):
|
|
23 """Like shutil.copyfileobj, but only copy a range of the streams.
|
|
24
|
|
25 Both start and stop are inclusive.
|
|
26 """
|
|
27 if start is not None:
|
|
28 infile.seek(start)
|
|
29 while 1:
|
|
30 to_read = min(bufsize, stop + 1 - infile.tell() if stop else bufsize)
|
|
31 buf = infile.read(to_read)
|
|
32 if not buf:
|
|
33 break
|
|
34 outfile.write(buf)
|
|
35
|
|
36
|
|
37 BYTE_RANGE_RE = re.compile(r"bytes=(\d+)-(\d+)?$")
|
|
38
|
|
39
|
|
40 def parse_byte_range(byte_range):
|
|
41 """Returns the two numbers in 'bytes=123-456' or throws ValueError.
|
|
42
|
|
43 The last number or both numbers may be None.
|
|
44 """
|
|
45 if byte_range.strip() == "":
|
|
46 return None, None
|
|
47
|
|
48 m = BYTE_RANGE_RE.match(byte_range)
|
|
49 if not m:
|
|
50 raise ValueError("Invalid byte range %s" % byte_range)
|
|
51
|
|
52 first, last = [x and int(x) for x in m.groups()]
|
|
53 if last and last < first:
|
|
54 raise ValueError("Invalid byte range %s" % byte_range)
|
|
55 return first, last
|
|
56
|
|
57
|
|
58 class RangeRequestHandler(SimpleHTTPRequestHandler):
|
|
59 """Adds support for HTTP 'Range' requests to SimpleHTTPRequestHandler
|
|
60
|
|
61 The approach is to:
|
|
62 - Override send_head to look for 'Range' and respond appropriately.
|
|
63 - Override copyfile to only transmit a range when requested.
|
|
64 """
|
|
65
|
|
66 def handle(self):
|
|
67 try:
|
|
68 SimpleHTTPRequestHandler.handle(self)
|
|
69 except Exception:
|
|
70 # ignored, thrown whenever the client aborts streaming (broken pipe)
|
|
71 pass
|
|
72
|
|
73 def send_head(self):
|
|
74 if "Range" not in self.headers:
|
|
75 self.range = None
|
|
76 return SimpleHTTPRequestHandler.send_head(self)
|
|
77 try:
|
|
78 self.range = parse_byte_range(self.headers["Range"])
|
|
79 except ValueError:
|
|
80 self.send_error(400, "Invalid byte range")
|
|
81 return None
|
|
82 first, last = self.range
|
|
83
|
|
84 # Mirroring SimpleHTTPServer.py here
|
|
85 path = self.translate_path(self.path)
|
|
86 f = None
|
|
87 ctype = self.guess_type(path)
|
|
88 try:
|
|
89 f = open(path, "rb")
|
|
90 except IOError:
|
|
91 self.send_error(404, "File not found")
|
|
92 return None
|
|
93
|
|
94 fs = os.fstat(f.fileno())
|
|
95 file_len = fs[6]
|
|
96 if first >= file_len:
|
|
97 self.send_error(416, "Requested Range Not Satisfiable")
|
|
98 return None
|
|
99
|
|
100 self.send_response(206)
|
|
101 self.send_header("Content-type", ctype)
|
|
102
|
|
103 if last is None or last >= file_len:
|
|
104 last = file_len - 1
|
|
105 response_length = last - first + 1
|
|
106
|
|
107 self.send_header("Content-Range", "bytes %s-%s/%s" % (first, last, file_len))
|
|
108 self.send_header("Content-Length", str(response_length))
|
|
109 self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
|
|
110 self.end_headers()
|
|
111 return f
|
|
112
|
|
113 def end_headers(self):
|
|
114 self.send_header("Accept-Ranges", "bytes")
|
|
115 return SimpleHTTPRequestHandler.end_headers(self)
|
|
116
|
|
117 def copyfile(self, source, outputfile):
|
|
118 if not self.range:
|
|
119 return SimpleHTTPRequestHandler.copyfile(self, source, outputfile)
|
|
120
|
|
121 # SimpleHTTPRequestHandler uses shutil.copyfileobj, which doesn't let
|
|
122 # you stop the copying before the end of the file.
|
|
123 start, stop = self.range # set in send_head()
|
|
124 copy_byte_range(source, outputfile, start, stop)
|
|
125
|
|
126
|
|
127 class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
|
128 allow_reuse_address = True
|
|
129
|
|
130
|
|
131 if __name__ == "__main__":
|
|
132 parser = argparse.ArgumentParser(
|
|
133 description="Simple Python Web Server with Range Support"
|
|
134 )
|
|
135 parser.add_argument(
|
|
136 "--root",
|
|
137 default=os.getcwd(),
|
|
138 help="Root path to serve files from (default: current working directory)",
|
|
139 )
|
|
140 parser.add_argument(
|
|
141 "--port",
|
|
142 type=int,
|
|
143 default=DEFAULT_PORT,
|
|
144 help=f"Port to listen on (default: {DEFAULT_PORT})",
|
|
145 )
|
|
146 parser.add_argument(
|
|
147 "--bind", default="0.0.0.0", help="IP address to bind to (default: 0.0.0.0)"
|
|
148 )
|
|
149 args = parser.parse_args()
|
|
150
|
|
151 handler = functools.partial(RangeRequestHandler, directory=args.root)
|
|
152
|
|
153 webbrowser.open(f"http://{args.bind}:{args.port}")
|
|
154
|
|
155 with ThreadedTCPServer((args.bind, args.port), handler) as httpd:
|
|
156 print(
|
|
157 f"Serving HTTP on {args.bind} port {args.port} (http://{args.bind}:{args.port}/)"
|
|
158 )
|
|
159 httpd.serve_forever()
|