From cac59e6e674f7e64f0068836d4431715f1ebcca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AD=D0=BB=D1=8C=D0=BD=D0=B0=D1=80?= Date: Mon, 27 Jul 2026 02:08:16 +0400 Subject: [PATCH] fix(encoding): handle non-ASCII filenames on upload and download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cyrillic names arrived as question marks and were stored that way. Upload: BytesParser defaults to the compat32 policy, which wraps a non-ASCII Content-Disposition in a Header with the unknown-8bit charset, so get_filename() returned one replacement character per UTF-8 byte and the mangled name was written to disk. Parse with policy=HTTP, which decodes headers as UTF-8. Download: send_header() encodes latin-1 strict, so a Cyrillic name raised UnicodeEncodeError. Emit both Content-Disposition forms per RFC 5987 — an ASCII fallback plus percent-encoded filename*. --- server.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index cd9fd07..6ca4de9 100644 --- a/server.py +++ b/server.py @@ -1,6 +1,7 @@ """HTTP server and request handler.""" import email.parser +import email.policy import hashlib import hmac import http.server @@ -28,6 +29,16 @@ def file_type(path: Path) -> str: return 'other' +def _disposition_filename(name: str) -> str: + """Content-Disposition filename params: ASCII fallback + RFC 5987 UTF-8 form. + + HTTP headers are latin-1 only, so a non-ASCII name must be percent-encoded. + """ + ascii_name = name.encode("ascii", "replace").decode("ascii") + ascii_name = ascii_name.replace("\\", "_").replace('"', "_").replace("?", "_") + return f'filename="{ascii_name}"; filename*=UTF-8\'\'{urllib.parse.quote(name, safe="")}' + + class ShareServer(http.server.ThreadingHTTPServer): def __init__(self, addr, handler, *, upload_dir: Path, html: bytes, ssl_ctx: ssl.SSLContext | None = None, token: str = "", token_is_hash: bool = False): super().__init__(addr, handler) @@ -208,10 +219,9 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO self.send_error(404) return ct = mimetypes.guess_type(safe.name)[0] or "application/octet-stream" - fname = safe.name.replace("\\", "\\\\").replace('"', '\\"') self.send_response(200) self.send_header("Content-Type", ct) - self.send_header("Content-Disposition", f'attachment; filename="{fname}"') + self.send_header("Content-Disposition", f"attachment; {_disposition_filename(safe.name)}") self.send_header("Content-Length", str(st.st_size)) self.send_header("Connection", "close") self.end_headers() @@ -274,7 +284,9 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO def _save_parts(self, content_type: str, body: bytes) -> int: header = f"Content-Type: {content_type}\r\n\r\n".encode() - msg = email.parser.BytesParser().parsebytes(header + body) + # policy=HTTP decodes headers as UTF-8; the default compat32 policy mangles + # non-ASCII filenames into replacement characters. + msg = email.parser.BytesParser(policy=email.policy.HTTP).parsebytes(header + body) count = 0 for part in msg.walk(): fname = part.get_filename()