From a0c996b19d1021ba7f9ff2e26c51cb18941c78ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AD=D0=BB=D1=8C=D0=BD=D0=B0=D1=80?= Date: Wed, 29 Jul 2026 18:08:36 +0400 Subject: [PATCH] refactor(server): extract helpers and simplify auth dispatch - Move `sha256_hex` and cookie constants to module level; drop `token_is_hash` param in favour of regex auto-detection - Make `_respond()` accept `status`/`headers`; add `_redirect()` helper to eliminate boilerplate in auth routes - Simplify `_dispatch()` with a `PUBLIC_ROUTES` set and positive-logic early-return - Restrict CORS header to file API; auth responses no longer send `Access-Control-Allow-Origin: *` - Extract `clearPager()` in frontend and deduplicate `copyText` fallback path --- server.py | 134 ++++++++++++++++++++-------------------------- share.conf | 8 --- share.py | 9 ++-- static/index.html | 14 ++--- 4 files changed, 67 insertions(+), 98 deletions(-) delete mode 100644 share.conf diff --git a/server.py b/server.py index 8d9e93b..eef1240 100644 --- a/server.py +++ b/server.py @@ -18,6 +18,15 @@ 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: @@ -44,17 +53,18 @@ def _disposition_filename(name: str) -> str: 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): + 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 token_is_hash: + elif _SHA256_RE.fullmatch(token): self.token_hash = token else: - self.token_hash = hashlib.sha256(token.encode()).hexdigest() + self.token_hash = sha256_hex(token) def get_request(self): client, addr = self.socket.accept() @@ -87,33 +97,42 @@ class Handler(http.server.BaseHTTPRequestHandler): msg = str(args[0]) if args else fmt print(f" [{datetime.now():%H:%M:%S}] {_TOKEN_RE.sub(r'\1', msg)}") - def _json(self, data): - self._respond(json.dumps(data).encode(), "application/json") + # The file API is deliberately CORS-open; auth responses are not. + _CORS = (("Access-Control-Allow-Origin", "*"),) - def _respond(self, data: bytes, ct: str): - self.send_response(200) + 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") - self.send_header("Access-Control-Allow-Origin", "*") + 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 ── - @staticmethod - def _hash(token: str) -> str: - return hashlib.sha256(token.encode()).hexdigest() - def _match(self, value: str) -> bool: - return hmac.compare_digest(self._hash(value), self.server.token_hash) + 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"share_token={self._hash(token)}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=31536000" + 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 @@ -121,13 +140,12 @@ class Handler(http.server.BaseHTTPRequestHandler): # cookie on the index route only (see _dispatch). if not self.server.token_hash: return True - auth = self.headers.get("Authorization", "") - if auth.startswith("Bearer ") and self._match(auth[7:]): + scheme, _, bearer = self.headers.get("Authorization", "").partition(" ") + if scheme == "Bearer" and self._match(bearer): return True - cookie = self.headers.get("Cookie", "") - for part in cookie.split(";"): - part = part.strip() - if part.startswith("share_token=") and hmac.compare_digest(part[12:], self.server.token_hash): + 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 @@ -166,21 +184,10 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO def _token_to_cookie(self, token: str): """Swap a valid `?token=` link for a cookie, then redirect to a clean URL.""" - self.send_response(302) - self.send_header("Set-Cookie", self._cookie(token)) - self.send_header("Location", "/") - self.send_header("Cache-Control", "no-store") - self.send_header("Content-Length", "0") - self.send_header("Connection", "close") - self.end_headers() + self._redirect("/", self._cookie(token)) def _send_auth_page(self): - self.send_response(401) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(self._AUTH_PAGE))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(self._AUTH_PAGE) + self._respond(self._AUTH_PAGE, "text/html; charset=utf-8", status=401) # ── Routes ── @@ -195,28 +202,22 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO "POST": [("/upload", "_route_upload"), ("/auth", "_route_auth")], "DELETE": [("/del/", "_route_delete")], } + # Routes reachable without a valid token. + PUBLIC_ROUTES = {"_route_auth"} def _dispatch(self): - parsed = urllib.parse.urlparse(self.path) - path_only = parsed.path + 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 not in ("_route_auth",) and not self._check_auth(): - 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() - self.send_response(401) - body = json.dumps({"error": "unauthorized"}).encode() - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) - return - return getattr(self, method)(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 @@ -225,10 +226,7 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO self._respond(self.server.html, "text/html; charset=utf-8") def _route_files(self, _prefix): - entries = [] - for p in self.server.upload_dir.iterdir(): - if p.is_file(): - entries.append((p, p.stat())) + 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]) @@ -278,30 +276,14 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO data = json.loads(body) except (json.JSONDecodeError, ValueError): data = {} - if data.get("token") and self._match(data["token"]): - resp = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(resp))) - self.send_header("Set-Cookie", self._cookie(data["token"])) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(resp) + token = data.get("token") + if token and self._match(token): + self._json({"ok": True}, headers=(("Set-Cookie", self._cookie(token)),)) else: - resp = json.dumps({"ok": False}).encode() - self.send_response(401) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(resp))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(resp) + self._json({"ok": False}, status=401, headers=()) def _route_logout(self, _prefix): - self.send_response(302) - self.send_header("Set-Cookie", "share_token=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0") - self.send_header("Location", "/") - self.send_header("Connection", "close") - self.end_headers() + self._redirect("/", f"{COOKIE_NAME}=; {_COOKIE_ATTRS}; Max-Age=0") def _route_delete(self, prefix): safe = self._safe_path(prefix) @@ -333,8 +315,6 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO def _unique_path(self, filename: str) -> Path: dest = self.server.upload_dir / filename - if not dest.exists(): - return dest stem, suffix = dest.stem, dest.suffix i = 1 while dest.exists(): diff --git a/share.conf b/share.conf deleted file mode 100644 index 48f9969..0000000 --- a/share.conf +++ /dev/null @@ -1,8 +0,0 @@ -[share] -port = 3001 -dir = ~/Downloads/shared -ttl = 7 -san = 192.168.0.1 -lang = ru -refresh = 5 -per_page = 10,25,50,100 diff --git a/share.py b/share.py index 5d50718..2e4ed2a 100755 --- a/share.py +++ b/share.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """Share — local file sharing server (HTTPS).""" -import hashlib import json import ssl import sys @@ -11,13 +10,13 @@ from pathlib import Path from cert import ensure_cert, get_local_ip from cleanup import cleanup_loop from config import BASE_DIR, SECS_PER_DAY, client_cfg, load_config -from server import Handler, ShareServer +from server import Handler, ShareServer, sha256_hex def main(): if len(sys.argv) > 1 and sys.argv[1] == "hash": token = sys.argv[2] if len(sys.argv) > 2 else input("Token: ") - print(hashlib.sha256(token.encode()).hexdigest()) + print(sha256_hex(token)) return conf = load_config() @@ -48,9 +47,7 @@ def main(): if not token: print("\n WARNING: no token set — anyone on the network has full access.") print(" Set `token` in share.config (see SETUP.md) or pass --token.") - # If token looks like a SHA-256 hash, pass it as pre-hashed - is_hash = len(token) == 64 and all(c in "0123456789abcdef" for c in token) - server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx, token=token, token_is_hash=is_hash) + server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx, token=token) print("\n Share running") print(f" Local: https://localhost:{port}") diff --git a/static/index.html b/static/index.html index f9d2d13..67f5ef1 100644 --- a/static/index.html +++ b/static/index.html @@ -311,12 +311,11 @@ function fallbackCopy(text) { function copyText(text, msg) { msg = msg || t().copied; + const fallback = () => toast(fallbackCopy(text) ? msg : t().copyFail); if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text) - .then(() => toast(msg)) - .catch(() => { fallbackCopy(text) ? toast(msg) : toast(t().copyFail) }); + navigator.clipboard.writeText(text).then(() => toast(msg)).catch(fallback); } else { - fallbackCopy(text) ? toast(msg) : toast(t().copyFail); + fallback(); } } @@ -416,12 +415,14 @@ function setPage(p) { renderFiles(); } +function clearPager() { $('pager').innerHTML = ''; $('per-page').innerHTML = '' } + function renderPager(total) { const pages = Math.ceil(total / perPage); const pager = $('pager'); const pp = $('per-page'); - if (pages <= 1 && total <= PER_PAGE_OPTS[0]) { pager.innerHTML = ''; pp.innerHTML = ''; return } + if (pages <= 1 && total <= PER_PAGE_OPTS[0]) { clearPager(); return } /* per-page selector */ pp.innerHTML = '' + t().perPage + '' + @@ -454,8 +455,7 @@ function renderPager(total) { function renderFiles() { if (!allFiles.length) { flist.innerHTML = '
' + t().empty + '
'; - $('pager').innerHTML = ''; - $('per-page').innerHTML = ''; + clearPager(); return; } const pages = Math.ceil(allFiles.length / perPage);