else/SimpleServer/simpleserver.py

545 lines
16 KiB
Python
Raw Normal View History

2020-01-20 05:19:45 +00:00
import argparse
import base64
2020-01-20 05:19:45 +00:00
import cgi
import http.cookies
2015-09-03 21:09:35 +00:00
import http.server
import math
2016-07-05 07:24:08 +00:00
import mimetypes
2015-10-03 23:29:52 +00:00
import os
2016-07-05 07:24:08 +00:00
import sys
2016-07-20 03:31:47 +00:00
import types
2019-06-12 05:29:46 +00:00
import urllib.parse
import zipstream
2016-07-05 07:24:08 +00:00
2020-10-13 04:56:28 +00:00
from voussoirkit import betterhelp
2016-12-02 06:37:07 +00:00
from voussoirkit import bytestring
from voussoirkit import gentools
from voussoirkit import passwordy
2016-12-02 06:37:07 +00:00
from voussoirkit import pathclass
from voussoirkit import ratelimiter
2015-09-03 21:09:35 +00:00
CHUNK_SIZE = bytestring.MIBIBYTE
2016-07-20 03:31:47 +00:00
2016-07-05 07:24:08 +00:00
OPENDIR_TEMPLATE = '''
<html>
2021-07-17 04:06:45 +00:00
<head>
<title>{title}</title>
2016-07-05 07:24:08 +00:00
<meta charset="UTF-8">
2019-06-12 05:29:46 +00:00
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
2021-06-22 22:50:38 +00:00
<style type="text/css">
body {{font-family:Consolas;}}
.column_name {{word-break: break-all;}}
.column_size {{white-space: nowrap;}}
</style>
2021-07-17 04:06:45 +00:00
</head>
<body>
2019-06-12 05:29:46 +00:00
<table style="width: 100%">
{table_rows}
2016-07-05 07:24:08 +00:00
</table>
</body>
</html>
'''
2020-01-20 05:19:45 +00:00
PASSWORD_PROMPT_HTML = '''
<html>
<body>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style type="text/css">Body {{font-family:Consolas}}</style>
<form action="/password" method="post">
2021-06-22 22:50:38 +00:00
<input type="text" autofocus autocapitalize="off" name="password" placeholder="password" autocomplete="off"/>
2020-01-20 05:19:45 +00:00
<input type="hidden" name="goto" value="{goto}"/>
<input type="submit" value="Submit"/>
</form>
</body>
</html>
'''
2019-06-12 05:29:46 +00:00
ROOT_DIRECTORY = pathclass.Path(os.getcwd())
HIDDEN_FILENAMES = {'thumbs.db', 'desktop.ini', '$recycle.bin', 'system volume information'}
2020-10-10 15:43:12 +00:00
TOKEN_COOKIE_NAME = 'simpleserver_token'
2015-10-03 23:29:52 +00:00
2020-10-10 15:44:56 +00:00
# SERVER ###########################################################################################
2015-09-03 21:09:35 +00:00
class RequestHandler(http.server.BaseHTTPRequestHandler):
def __init__(self, request, client_info, server, individual_ratelimit):
self.individual_ratelimit = ratelimiter.Ratelimiter(individual_ratelimit)
super().__init__(request, client_info, server)
2020-01-20 05:19:45 +00:00
@property
def auth_cookie(self):
cookie = self.headers.get('Cookie')
if not cookie:
return None
cookie = http.cookies.SimpleCookie(cookie)
token = cookie.get(TOKEN_COOKIE_NAME)
if not token:
return None
return token
@property
def auth_header(self):
authorization = self.headers.get('Authorization')
if not authorization:
return None
(auth_type, authorization) = authorization.split(' ', 1)
if auth_type != 'Basic':
return None
authorization = base64.b64decode(authorization).decode()
(username, password) = authorization.split(':', 1)
return password
@property
def remote_addr(self):
return self.request.getpeername()[0]
2020-01-20 05:19:45 +00:00
def check_password(self, attempt):
if self.server.password is None:
2020-01-20 05:19:45 +00:00
return True
if attempt == self.server.password:
2020-01-20 05:19:45 +00:00
return True
return False
def check_has_password(self):
if self.server.password is None:
2020-01-20 05:19:45 +00:00
return True
if self.auth_header == self.server.password:
2020-01-20 05:19:45 +00:00
return True
cookie = self.auth_cookie
if isinstance(cookie, http.cookies.Morsel):
cookie = cookie.value
if self.server.accepted_tokens is not None and cookie in self.server.accepted_tokens:
return True
2020-01-20 05:19:45 +00:00
if self.server.accepted_ips is not None and self.remote_addr in self.server.accepted_ips:
return True
2020-01-20 05:19:45 +00:00
return False
2016-07-20 03:31:47 +00:00
def write(self, data):
if isinstance(data, str):
data = data.encode('utf-8')
if isinstance(data, bytes):
databytes = data
data = (databytes[i*CHUNK_SIZE:(i+1)*CHUNK_SIZE] for i in range(math.ceil(len(data)/CHUNK_SIZE)))
for chunk in data:
self.wfile.write(chunk)
chunksize = len(chunk)
self.server.overall_ratelimit.limit(chunksize)
self.individual_ratelimit.limit(chunksize)
2015-09-03 21:09:35 +00:00
def do_GET(self):
2020-01-20 05:19:45 +00:00
if not self.check_has_password():
self.send_response(401)
self.end_headers()
self.write(PASSWORD_PROMPT_HTML.format(goto=self.path))
return
2019-06-12 05:29:46 +00:00
path = url_to_path(self.path)
2016-07-10 04:38:49 +00:00
if self.send_path_validation_error(path):
2015-10-03 23:29:52 +00:00
return
2016-07-05 07:24:08 +00:00
2016-07-20 03:31:47 +00:00
range_min = None
range_max = None
status_code = 200
headers = {}
2016-07-05 07:24:08 +00:00
2016-07-20 03:31:47 +00:00
if path.is_file:
file_size = path.size
if 'range' in self.headers:
desired_range = self.headers['range']
desired_range = desired_range.lower()
desired_range = desired_range.split('bytes=')[-1]
helper = lambda x: int(x) if x and x.isdigit() else None
if '-' in desired_range:
(desired_min, desired_max) = desired_range.split('-')
range_min = helper(desired_min)
range_max = helper(desired_max)
else:
range_min = helper(desired_range)
if range_min is None:
range_min = 0
if range_max is None:
range_max = file_size
# because ranges are 0 indexed
range_max = min(range_max, file_size - 1)
range_min = max(range_min, 0)
status_code = 206
range_header = 'bytes {min}-{max}/{outof}'.format(
min=range_min,
max=range_max,
outof=file_size,
)
headers['Content-Range'] = range_header
headers['Accept-Ranges'] = 'bytes'
content_length = (range_max - range_min) + 1
else:
content_length = file_size
headers['Content-length'] = content_length
2019-06-12 05:29:46 +00:00
if path.is_file:
headers['Content-type'] = mimetypes.guess_type(path.absolute_path)[0]
response = read_filebytes(path, range_min=range_min, range_max=range_max)
elif path.is_dir:
headers['Content-type'] = 'text/html'
response = generate_opendir(path, enable_zip=self.server.enable_zip)
2019-06-12 05:29:46 +00:00
response = response.encode('utf-8')
elif self.path.endswith('.zip') and self.server.enable_zip:
2019-06-12 05:29:46 +00:00
path = url_to_path(self.path.rsplit('.zip', 1)[0])
headers['Content-type'] = 'application/octet-stream'
2020-01-20 05:19:45 +00:00
download_as = urllib.parse.quote(path.basename)
download_as += '.zip'
headers['Content-Disposition'] = f'attachment; filename*=UTF-8\'\'{download_as}'
2019-06-12 05:29:46 +00:00
response = zip_directory(path)
response = iter(response)
# response = (print(chunk) or chunk for chunk in response)
else:
status_code = 404
self.send_error(status_code)
response = bytes()
2016-07-10 04:38:49 +00:00
2016-07-20 03:31:47 +00:00
self.send_response(status_code)
for (key, value) in headers.items():
self.send_header(key, value)
2016-07-05 07:24:08 +00:00
2016-07-10 04:38:49 +00:00
self.end_headers()
2019-06-12 05:29:46 +00:00
self.write(response)
2015-09-03 21:09:35 +00:00
2016-07-10 04:38:49 +00:00
def do_HEAD(self):
2020-01-20 05:19:45 +00:00
if not self.check_has_password():
self.send_response(401)
self.end_headers()
return
2019-06-12 05:29:46 +00:00
path = url_to_path(self.path)
2016-07-10 04:38:49 +00:00
if self.send_path_validation_error(path):
return
2016-07-20 03:31:47 +00:00
status_code = 200
2016-08-03 02:06:59 +00:00
self.send_response(status_code)
2016-07-10 04:38:49 +00:00
if path.is_dir:
mime = 'text/html'
else:
2016-07-20 03:31:47 +00:00
mime = mimetypes.guess_type(path.absolute_path)[0]
self.send_header('Content-length', path.size)
2016-07-10 04:38:49 +00:00
if mime is not None:
self.send_header('Content-type', mime)
self.end_headers()
2020-01-20 05:19:45 +00:00
def do_POST(self):
2020-10-10 15:44:56 +00:00
(ctype, pdict) = cgi.parse_header(self.headers.get('content-type'))
2020-01-20 05:19:45 +00:00
if ctype == 'multipart/form-data':
form = cgi.parse_multipart(self.rfile, pdict)
elif ctype == 'application/x-www-form-urlencoded':
length = int(self.headers.get('content-length'))
form = urllib.parse.parse_qs(self.rfile.read(length), keep_blank_values=1)
else:
form = {}
if self.path == '/password':
attempt = form.get(b'password')[0].decode('utf-8')
goto = form.get(b'goto')[0].decode('utf-8')
if self.check_password(attempt):
self.send_response(302)
if self.server.accepted_tokens is not None:
cookie = http.cookies.SimpleCookie()
token = passwordy.random_hex(32)
cookie[TOKEN_COOKIE_NAME] = token
self.server.accepted_tokens.add(token)
self.send_header('Set-Cookie', cookie.output(header='', sep=''))
if self.server.accepted_ips is not None:
self.server.accepted_ips.add(self.remote_addr)
2020-01-20 05:19:45 +00:00
self.send_header('Location', goto)
else:
self.send_response(401)
2020-10-10 15:44:56 +00:00
elif not self.check_has_password():
self.send_response(401)
self.end_headers()
return
2020-01-20 05:19:45 +00:00
else:
self.send_response(400)
self.end_headers()
2016-07-10 04:38:49 +00:00
def send_path_validation_error(self, path):
2019-06-12 05:29:46 +00:00
if not allowed(path):
2016-07-20 03:31:47 +00:00
self.send_error(403, 'Stop that!')
2016-07-10 04:38:49 +00:00
return True
return False
2020-10-10 15:44:56 +00:00
class SimpleServer:
def __init__(
self,
port,
*,
password,
authorize_by_ip,
enable_zip,
overall_ratelimit,
individual_ratelimit,
):
2020-10-10 15:44:56 +00:00
self.port = port
self.password = password
self.authorize_by_ip = authorize_by_ip
self.enable_zip = enable_zip
self.overall_ratelimit = ratelimiter.Ratelimiter(overall_ratelimit)
self.individual_ratelimit = individual_ratelimit
if authorize_by_ip:
self.accepted_ips = set()
self.accepted_tokens = None
else:
self.accepted_tokens = set()
self.accepted_ips = None
2020-10-10 15:44:56 +00:00
def make_request_handler(self, request, client_info, _server):
# We ignore the given _server and use self instead because _server will
# be the ThreadingHTTPServer instance.
return RequestHandler(request, client_info, self, individual_ratelimit=self.individual_ratelimit)
2020-10-10 15:44:56 +00:00
def start(self):
server = http.server.ThreadingHTTPServer(('0.0.0.0', self.port), self.make_request_handler)
2021-09-30 21:27:25 +00:00
print(f'Server starting on {self.port}, pid={os.getpid()}.')
2020-10-10 15:44:56 +00:00
try:
server.serve_forever()
except KeyboardInterrupt:
print('Goodbye.')
server.shutdown()
# HELPERS ##########################################################################################
2016-07-20 03:31:47 +00:00
2019-06-12 05:29:46 +00:00
def allowed(path):
2019-12-10 20:46:45 +00:00
return path == ROOT_DIRECTORY or path in ROOT_DIRECTORY
2019-06-12 05:29:46 +00:00
2019-12-10 20:46:45 +00:00
def atag(path, display_name=None):
2019-06-12 05:29:46 +00:00
if display_name is None:
display_name = path.basename
if path.is_dir:
# Folder emoji
icon = '\U0001F4C1'
else:
2020-10-27 05:57:36 +00:00
# Gift emoji
2019-06-12 05:29:46 +00:00
icon = '\U0001F381'
if display_name.endswith('.placeholder'):
a = '<a>{icon} {display}</a>'
else:
a = '<a href="{full}">{icon} {display}</a>'
a = a.format(
full=path_to_url(path),
icon=icon,
display=display_name,
)
return a
2016-07-05 07:24:08 +00:00
def generate_opendir(path, enable_zip):
2019-06-12 05:29:46 +00:00
try:
path.correct_case()
2019-06-12 05:29:46 +00:00
items = path.listdir()
except FileNotFoundError:
items = []
2020-10-27 05:57:36 +00:00
# This places directories above files, each ordered alphabetically
2016-07-05 07:24:08 +00:00
directories = []
files = []
2019-06-12 05:29:46 +00:00
for item in sorted(items, key=lambda p: p.basename.lower()):
if item.basename.lower() in HIDDEN_FILENAMES:
2020-10-27 05:57:36 +00:00
continue
2019-06-12 05:29:46 +00:00
if item.is_dir:
2016-07-05 07:24:08 +00:00
directories.append(item)
else:
files.append(item)
2016-07-10 04:38:49 +00:00
items = directories + files
2019-06-12 05:29:46 +00:00
table_rows = []
shaded = False
2016-07-20 03:31:47 +00:00
2021-07-17 04:06:45 +00:00
is_root = path.absolute_path == ROOT_DIRECTORY.absolute_path
if is_root:
2016-07-20 03:31:47 +00:00
# This is different than a permission check, we're seeing if they're
# actually at the top, in which case they don't need an up button.
2019-12-10 20:46:45 +00:00
entry = table_row(path, display_name='.', shaded=shaded)
table_rows.append(entry)
shaded = not shaded
2016-07-20 03:31:47 +00:00
else:
2019-06-12 05:29:46 +00:00
entry = table_row(path.parent, display_name='up', shaded=shaded)
table_rows.append(entry)
shaded = not shaded
2016-07-10 04:38:49 +00:00
for item in items:
2019-06-12 05:29:46 +00:00
entry = table_row(item, shaded=shaded)
table_rows.append(entry)
2016-07-05 07:24:08 +00:00
shaded = not shaded
2016-07-10 04:38:49 +00:00
if len(items) > 0 and enable_zip:
2020-01-20 05:19:45 +00:00
entry = table_row(path.replace_extension('.zip'), display_name='zip', shaded=shaded)
shaded = not shaded
table_rows.append(entry)
2019-06-12 05:29:46 +00:00
table_rows = '\n'.join(table_rows)
2021-07-17 04:06:45 +00:00
title = '/' if is_root else path.basename
text = OPENDIR_TEMPLATE.format(title=title, table_rows=table_rows)
2016-07-05 07:24:08 +00:00
return text
2019-06-12 05:29:46 +00:00
def read_filebytes(path, range_min=None, range_max=None):
if range_min is None:
range_min = 0
if range_max is None:
range_max = path.size
range_span = range_max - range_min
2020-09-21 01:29:36 +00:00
f = path.open('rb')
2019-06-12 05:29:46 +00:00
f.seek(range_min)
sent_amount = 0
while sent_amount < range_span:
chunk = f.read(CHUNK_SIZE)
2019-06-12 05:29:46 +00:00
if len(chunk) == 0:
break
yield chunk
sent_amount += len(chunk)
f.close()
def table_row(path, display_name=None, shaded=False):
2021-06-22 22:50:38 +00:00
form = '''
<tr style="background-color:#{bg}">
<td class="column_name">{anchor}</td>
<td class="column_size">{size}</td></tr>
'''.replace('\n', ' ')
if path.is_file:
size = bytestring.bytestring(path.size)
2019-06-12 05:29:46 +00:00
else:
size = ''
2019-06-12 05:29:46 +00:00
2019-12-10 20:46:45 +00:00
bg = 'ddd' if shaded else 'fff'
anchor = atag(path, display_name=display_name)
2019-06-12 05:29:46 +00:00
row = form.format(
bg=bg,
2019-12-10 20:46:45 +00:00
anchor=anchor,
2019-06-12 05:29:46 +00:00
size=size,
)
2019-06-12 05:29:46 +00:00
return row
def path_to_url(path):
2020-10-27 05:57:03 +00:00
url = path.relative_to(ROOT_DIRECTORY, simple=True)
2019-06-12 05:29:46 +00:00
url = url.replace(os.sep, '/')
url = '/' + url
url = urllib.parse.quote(url)
return url
def url_to_path(path):
path = urllib.parse.unquote(path)
path = path.strip('/')
path = path.split('?')[0]
2019-06-12 05:29:46 +00:00
return pathclass.Path(path)
def zip_directory(path):
zipfile = zipstream.ZipFile(mode='w', compression=zipstream.ZIP_STORED)
for item in path.walk():
if item.is_dir:
continue
arcname = item.relative_to(path).lstrip('.' + os.sep)
zipfile.write(filename=item.absolute_path, arcname=arcname)
return zipfile
# COMMAND LINE #####################################################################################
2016-07-05 07:24:08 +00:00
2020-10-13 04:56:28 +00:00
DOCSTRING = '''
simpleserver
============
Run a simple file server from your computer.
> simpleserver port <flags>
port:
Port number, an integer.
flags:
--password X:
A password string. The user will be prompted to enter it before proceeding
to any URL. A token is stored in a cookie unless authorize_by_ip is used.
--authorize_by_ip:
After the user enters the password, their entire IP becomes authorized for
all future requests. This reduces security, because a single IP can be home
to many different people, but increases convenience, because the user can
use download managers / scripts where adding auth is not convenient.
--enable_zip:
Add a 'zip' link to every directory and allow the user to download the
entire directory as a zip file.
--overall_ratelimit X:
An integer number of bytes, the maximum bytes/sec of the server overall.
--individual_ratelimit X:
An integer number of bytes, the maximum bytes/sec for any single request.
2020-10-13 04:56:28 +00:00
'''
2020-01-20 05:19:45 +00:00
def simpleserver_argparse(args):
server = SimpleServer(
port=args.port,
password=args.password,
authorize_by_ip=args.authorize_by_ip,
enable_zip=args.enable_zip,
overall_ratelimit=args.overall_ratelimit,
individual_ratelimit=args.individual_ratelimit,
)
2020-10-10 15:44:56 +00:00
server.start()
2020-01-20 05:19:45 +00:00
def main(argv):
parser = argparse.ArgumentParser(description=DOCSTRING)
2020-01-20 05:19:45 +00:00
parser.add_argument('port', nargs='?', type=int, default=40000)
parser.add_argument('--password', dest='password', default=None)
parser.add_argument('--authorize_by_ip', '--authorize-by-ip', dest='authorize_by_ip', action='store_true')
parser.add_argument('--enable_zip', '--enable-zip', dest='enable_zip', action='store_true')
parser.add_argument('--overall_ratelimit', '--overall-ratelimit', type=bytestring.parsebytes, default=20*bytestring.MIBIBYTE)
parser.add_argument('--individual_ratelimit', '--individual-ratelimit', type=bytestring.parsebytes, default=10*bytestring.MIBIBYTE)
2020-01-20 05:19:45 +00:00
parser.set_defaults(func=simpleserver_argparse)
2020-10-13 04:56:28 +00:00
return betterhelp.single_main(argv, parser, DOCSTRING)
2020-01-20 05:19:45 +00:00
2016-07-20 03:31:47 +00:00
if __name__ == '__main__':
2020-01-20 05:19:45 +00:00
raise SystemExit(main(sys.argv[1:]))