"""HTTP server and request handler.""" import email.parser import email.policy import hashlib import hmac import http.server import json import mimetypes import re import ssl import stat import urllib.parse from datetime import datetime from pathlib import Path from config import CHUNK_SIZE, SNIFF_SIZE, SSL_HANDSHAKE_TIMEOUT # Keeps `?token=...` out of the log / journald. _TOKEN_RE = re.compile(r"([?&]token=)[^&\s\"]*") _SHA256_RE = re.compile(r"[0-9a-f]{64}") COOKIE_NAME = "share_token" _COOKIE_ATTRS = "Path=/; HttpOnly; Secure; SameSite=Strict" _COOKIE_MAX_AGE = 31536000 def sha256_hex(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() def file_type(path: Path) -> str: ct = mimetypes.guess_type(path.name)[0] or '' if ct.startswith('image/'): return 'image' if ct.startswith('text/'): return 'text' try: with path.open('rb') as f: return 'text' if b'\x00' not in f.read(SNIFF_SIZE) else 'other' except OSError: 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 = ""): super().__init__(addr, handler) self.upload_dir = upload_dir self.html = html self.ssl_ctx = ssl_ctx # `share.py hash` emits a digest, so a 64-char hex token is already hashed. if not token: self.token_hash = "" elif _SHA256_RE.fullmatch(token): self.token_hash = token else: self.token_hash = sha256_hex(token) def get_request(self): client, addr = self.socket.accept() if self.ssl_ctx: client.settimeout(SSL_HANDSHAKE_TIMEOUT) try: client = self.ssl_ctx.wrap_socket(client, server_side=True) except (ssl.SSLError, OSError): client.close() raise client.settimeout(None) return client, addr class Handler(http.server.BaseHTTPRequestHandler): server: ShareServer def finish(self): try: super().finish() except (ssl.SSLError, BrokenPipeError, ConnectionResetError, OSError): pass def end_headers(self): # Stops a `?token=` link from leaking the token via Referer on outbound clicks. self.send_header("Referrer-Policy", "no-referrer") super().end_headers() def log_message(self, fmt, *args): msg = str(args[0]) if args else fmt print(f" [{datetime.now():%H:%M:%S}] {_TOKEN_RE.sub(r'\1', msg)}") # The file API is deliberately CORS-open; auth responses are not. _CORS = (("Access-Control-Allow-Origin", "*"),) def _respond(self, data: bytes, ct: str, *, status: int = 200, headers: tuple = ()): self.send_response(status) self.send_header("Content-Type", ct) self.send_header("Content-Length", str(len(data))) self.send_header("Connection", "close") for name, value in headers: self.send_header(name, value) self.end_headers() self.wfile.write(data) def _json(self, data, *, status: int = 200, headers: tuple = _CORS): self._respond(json.dumps(data).encode(), "application/json", status=status, headers=headers) def _redirect(self, location: str, cookie: str): self.send_response(302) self.send_header("Location", location) self.send_header("Set-Cookie", cookie) self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", "0") self.send_header("Connection", "close") self.end_headers() # ── Auth ── def _match(self, value: str) -> bool: return hmac.compare_digest(sha256_hex(value), self.server.token_hash) def _query_token(self) -> str: qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) return qs.get("token", [""])[0] def _cookie(self, token: str) -> str: return f"{COOKIE_NAME}={sha256_hex(token)}; {_COOKIE_ATTRS}; Max-Age={_COOKIE_MAX_AGE}" def _check_auth(self) -> bool: # NB: `?token=` is deliberately not accepted here — a token in the URL # leaks into logs, browser history and Referer. It is exchanged for a # cookie on the index route only (see _dispatch). if not self.server.token_hash: return True scheme, _, bearer = self.headers.get("Authorization", "").partition(" ") if scheme == "Bearer" and self._match(bearer): return True for part in self.headers.get("Cookie", "").split(";"): name, _, value = part.strip().partition("=") if name == COOKIE_NAME and hmac.compare_digest(value, self.server.token_hash): return True return False _AUTH_PAGE = b''' Share

Share

''' def _token_to_cookie(self, token: str): """Swap a valid `?token=` link for a cookie, then redirect to a clean URL.""" self._redirect("/", self._cookie(token)) def _send_auth_page(self): self._respond(self._AUTH_PAGE, "text/html; charset=utf-8", status=401) # ── Routes ── ROUTES = { "GET": [ ("/", "_route_index"), ("/index.html","_route_index"), ("/files", "_route_files"), ("/dl/", "_route_download"), ("/logout", "_route_logout"), ], "POST": [("/upload", "_route_upload"), ("/auth", "_route_auth")], "DELETE": [("/del/", "_route_delete")], } # Routes reachable without a valid token. PUBLIC_ROUTES = {"_route_auth"} def _dispatch(self): path_only = urllib.parse.urlparse(self.path).path for prefix, method in self.ROUTES.get(self.command, []): if path_only == prefix or (len(prefix) > 1 and prefix.endswith("/") and path_only.startswith(prefix)): if method in self.PUBLIC_ROUTES or self._check_auth(): return getattr(self, method)(prefix) if self.command == "GET" and path_only in ("/", "/index.html"): tok = self._query_token() if tok and self._match(tok): return self._token_to_cookie(tok) return self._send_auth_page() return self._json({"error": "unauthorized"}, status=401, headers=()) self.send_error(404) do_GET = do_POST = do_DELETE = _dispatch def _route_index(self, _prefix): self._respond(self.server.html, "text/html; charset=utf-8") def _route_files(self, _prefix): entries = [(p, p.stat()) for p in self.server.upload_dir.iterdir() if p.is_file()] entries.sort(key=lambda e: e[1].st_mtime, reverse=True) self._json([{"name": p.name, "size": st.st_size, "type": file_type(p)} for p, st in entries]) def _safe_path(self, prefix: str) -> Path: parsed = urllib.parse.urlparse(self.path) name = urllib.parse.unquote(parsed.path[len(prefix):]) return self.server.upload_dir / Path(name).name def _route_download(self, prefix): safe = self._safe_path(prefix) try: st = safe.stat() except FileNotFoundError: self.send_error(404) return if not stat.S_ISREG(st.st_mode): self.send_error(404) return ct = mimetypes.guess_type(safe.name)[0] or "application/octet-stream" self.send_response(200) self.send_header("Content-Type", ct) 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() with safe.open("rb") as f: while chunk := f.read(CHUNK_SIZE): self.wfile.write(chunk) def _route_upload(self, _prefix): ct = self.headers.get("Content-Type", "") if "multipart/form-data" not in ct: self.send_error(400) return length = int(self.headers.get("Content-Length", 0)) if not length: self.send_error(400) return body = self.rfile.read(length) count = self._save_parts(ct, body) self._json({"ok": True, "count": count}) def _route_auth(self, _prefix): length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) if length else b"" try: data = json.loads(body) except (json.JSONDecodeError, ValueError): data = {} token = data.get("token") if token and self._match(token): self._json({"ok": True}, headers=(("Set-Cookie", self._cookie(token)),)) else: self._json({"ok": False}, status=401, headers=()) def _route_logout(self, _prefix): self._redirect("/", f"{COOKIE_NAME}=; {_COOKIE_ATTRS}; Max-Age=0") def _route_delete(self, prefix): safe = self._safe_path(prefix) if safe.is_file(): safe.unlink() self._json({"ok": True}) # ── Helpers ── def _save_parts(self, content_type: str, body: bytes) -> int: header = f"Content-Type: {content_type}\r\n\r\n".encode() # 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() if not fname: continue fname = Path(fname).name or f"paste_{datetime.now():%H%M%S}" data = part.get_payload(decode=True) if not data: continue dest = self._unique_path(fname) dest.write_bytes(data) count += 1 print(f" + {dest.name} ({len(data)} bytes)") return count def _unique_path(self, filename: str) -> Path: dest = self.server.upload_dir / filename stem, suffix = dest.stem, dest.suffix i = 1 while dest.exists(): dest = self.server.upload_dir / f"{stem}_{i}{suffix}" i += 1 return dest