fix(encoding): handle non-ASCII filenames on upload and download

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*.
This commit is contained in:
2026-07-27 02:08:16 +04:00
parent 7af9aec0b3
commit cac59e6e67
+15 -3
View File
@@ -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()