diff --git a/httpuploader.py b/httpuploader.py index df24d94..a2bec1e 100755 --- a/httpuploader.py +++ b/httpuploader.py @@ -1,5 +1,7 @@ import argparse import base64 +from email.parser import BytesHeaderParser +from email.policy import default as email_policy import gzip import hashlib import io @@ -16,7 +18,6 @@ from wsgiref.util import FileWrapper from urllib.parse import parse_qs from tempfile import TemporaryFile -from cgi import FieldStorage from pprint import pformat from datetime import datetime, timedelta, timezone from configparser import ConfigParser, ExtendedInterpolation @@ -31,6 +32,90 @@ allow = list, download, mkdir, upload """ + +def _get_multipart_boundary(content_type): + headers = BytesHeaderParser(policy=email_policy).parsebytes( + "Content-Type: {}\r\n\r\n".format(content_type).encode("latin-1") + ) + boundary = headers.get_param("boundary", header="content-type") + if boundary is None: + return None + return boundary.encode("latin-1") + + +class _LimitedReader: + def __init__(self, fp, content_length): + self.fp = fp + self.remaining = content_length + + def readline(self): + if self.remaining <= 0: + return b"" + line = self.fp.readline(min(CHUNKSIZE, self.remaining)) + self.remaining -= len(line) + return line + + +def _multipart_boundary_type(line, boundary): + marker = b"--" + boundary + stripped = line.rstrip(b"\r\n") + if stripped == marker: + return "part" + if stripped == marker + b"--": + return "final" + return None + + +def _strip_multipart_line_break(line): + if line.endswith(b"\r\n"): + return line[:-2] + if line.endswith(b"\n"): + return line[:-1] + return line + + +def _skip_to_first_multipart_boundary(reader, boundary): + while True: + line = reader.readline() + if not line: + return True + boundary_type = _multipart_boundary_type(line, boundary) + if boundary_type == "final": + return True + if boundary_type == "part": + return False + + +def _read_multipart_headers(reader): + lines = [] + while True: + line = reader.readline() + if not line or line in (b"\r\n", b"\n"): + break + lines.append(line) + return BytesHeaderParser(policy=email_policy).parsebytes(b"".join(lines)) + + +def _consume_multipart_part(reader, boundary, output=None): + pending = None + while True: + line = reader.readline() + if not line: + if pending and output is not None: + output.write(pending) + return True + + boundary_type = _multipart_boundary_type(line, boundary) + if boundary_type: + if pending and output is not None: + output.write(_strip_multipart_line_break(pending)) + return boundary_type == "final" + + if pending and output is not None: + output.write(pending) + pending = line + + LOCAL_TZ = datetime.now(timezone(timedelta(0))).astimezone().tzinfo @@ -447,18 +532,28 @@ def checksum(self, path, args): def upload(self, path, args): content_type = self.env.get("CONTENT_TYPE", "") - if content_type.startswith("multipart/form-data"): - fs = FieldStorage(fp=self.env["wsgi.input"], environ=self.env) - for key in fs: - if fs[key].file: - pn = path / fs[key].filename - with pn.open("wb") as saved: - while 1: - chunk = fs[key].file.read(CHUNKSIZE) - if len(chunk) > 0: - saved.write(chunk) - else: - break + if content_type.lower().startswith("multipart/form-data"): + boundary = _get_multipart_boundary(content_type) + if boundary is None: + raise HTUPLError(400, "Bad request", "No multipart boundary") + + content_length = int(self.env.get("CONTENT_LENGTH", "0")) + if not content_length: + raise HTUPLError(400, "Bad request", "No Content-length header") + + reader = _LimitedReader(self.env["wsgi.input"], content_length) + final_boundary = _skip_to_first_multipart_boundary(reader, boundary) + while not final_boundary: + headers = _read_multipart_headers(reader) + filename = headers.get_filename() + if filename: + pn = path / filename + with pn.open("wb") as saved: + final_boundary = _consume_multipart_part( + reader, boundary, saved + ) + else: + final_boundary = _consume_multipart_part(reader, boundary) else: fp = self.env["wsgi.input"] content_length = int(self.env.get("CONTENT_LENGTH", "0"))