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
This commit is contained in:
@@ -18,6 +18,15 @@ from config import CHUNK_SIZE, SNIFF_SIZE, SSL_HANDSHAKE_TIMEOUT
|
|||||||
|
|
||||||
# Keeps `?token=...` out of the log / journald.
|
# Keeps `?token=...` out of the log / journald.
|
||||||
_TOKEN_RE = re.compile(r"([?&]token=)[^&\s\"]*")
|
_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:
|
def file_type(path: Path) -> str:
|
||||||
@@ -44,17 +53,18 @@ def _disposition_filename(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class ShareServer(http.server.ThreadingHTTPServer):
|
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)
|
super().__init__(addr, handler)
|
||||||
self.upload_dir = upload_dir
|
self.upload_dir = upload_dir
|
||||||
self.html = html
|
self.html = html
|
||||||
self.ssl_ctx = ssl_ctx
|
self.ssl_ctx = ssl_ctx
|
||||||
|
# `share.py hash` emits a digest, so a 64-char hex token is already hashed.
|
||||||
if not token:
|
if not token:
|
||||||
self.token_hash = ""
|
self.token_hash = ""
|
||||||
elif token_is_hash:
|
elif _SHA256_RE.fullmatch(token):
|
||||||
self.token_hash = token
|
self.token_hash = token
|
||||||
else:
|
else:
|
||||||
self.token_hash = hashlib.sha256(token.encode()).hexdigest()
|
self.token_hash = sha256_hex(token)
|
||||||
|
|
||||||
def get_request(self):
|
def get_request(self):
|
||||||
client, addr = self.socket.accept()
|
client, addr = self.socket.accept()
|
||||||
@@ -87,33 +97,42 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
msg = str(args[0]) if args else fmt
|
msg = str(args[0]) if args else fmt
|
||||||
print(f" [{datetime.now():%H:%M:%S}] {_TOKEN_RE.sub(r'\1<redacted>', msg)}")
|
print(f" [{datetime.now():%H:%M:%S}] {_TOKEN_RE.sub(r'\1<redacted>', msg)}")
|
||||||
|
|
||||||
def _json(self, data):
|
# The file API is deliberately CORS-open; auth responses are not.
|
||||||
self._respond(json.dumps(data).encode(), "application/json")
|
_CORS = (("Access-Control-Allow-Origin", "*"),)
|
||||||
|
|
||||||
def _respond(self, data: bytes, ct: str):
|
def _respond(self, data: bytes, ct: str, *, status: int = 200, headers: tuple = ()):
|
||||||
self.send_response(200)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", ct)
|
self.send_header("Content-Type", ct)
|
||||||
self.send_header("Content-Length", str(len(data)))
|
self.send_header("Content-Length", str(len(data)))
|
||||||
self.send_header("Connection", "close")
|
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.end_headers()
|
||||||
self.wfile.write(data)
|
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 ──
|
# ── Auth ──
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _hash(token: str) -> str:
|
|
||||||
return hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
|
|
||||||
def _match(self, value: str) -> bool:
|
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:
|
def _query_token(self) -> str:
|
||||||
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||||
return qs.get("token", [""])[0]
|
return qs.get("token", [""])[0]
|
||||||
|
|
||||||
def _cookie(self, token: str) -> str:
|
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:
|
def _check_auth(self) -> bool:
|
||||||
# NB: `?token=` is deliberately not accepted here — a token in the URL
|
# 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).
|
# cookie on the index route only (see _dispatch).
|
||||||
if not self.server.token_hash:
|
if not self.server.token_hash:
|
||||||
return True
|
return True
|
||||||
auth = self.headers.get("Authorization", "")
|
scheme, _, bearer = self.headers.get("Authorization", "").partition(" ")
|
||||||
if auth.startswith("Bearer ") and self._match(auth[7:]):
|
if scheme == "Bearer" and self._match(bearer):
|
||||||
return True
|
return True
|
||||||
cookie = self.headers.get("Cookie", "")
|
for part in self.headers.get("Cookie", "").split(";"):
|
||||||
for part in cookie.split(";"):
|
name, _, value = part.strip().partition("=")
|
||||||
part = part.strip()
|
if name == COOKIE_NAME and hmac.compare_digest(value, self.server.token_hash):
|
||||||
if part.startswith("share_token=") and hmac.compare_digest(part[12:], self.server.token_hash):
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -166,21 +184,10 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO
|
|||||||
|
|
||||||
def _token_to_cookie(self, token: str):
|
def _token_to_cookie(self, token: str):
|
||||||
"""Swap a valid `?token=` link for a cookie, then redirect to a clean URL."""
|
"""Swap a valid `?token=` link for a cookie, then redirect to a clean URL."""
|
||||||
self.send_response(302)
|
self._redirect("/", self._cookie(token))
|
||||||
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()
|
|
||||||
|
|
||||||
def _send_auth_page(self):
|
def _send_auth_page(self):
|
||||||
self.send_response(401)
|
self._respond(self._AUTH_PAGE, "text/html; charset=utf-8", status=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)
|
|
||||||
|
|
||||||
# ── Routes ──
|
# ── Routes ──
|
||||||
|
|
||||||
@@ -195,28 +202,22 @@ fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSO
|
|||||||
"POST": [("/upload", "_route_upload"), ("/auth", "_route_auth")],
|
"POST": [("/upload", "_route_upload"), ("/auth", "_route_auth")],
|
||||||
"DELETE": [("/del/", "_route_delete")],
|
"DELETE": [("/del/", "_route_delete")],
|
||||||
}
|
}
|
||||||
|
# Routes reachable without a valid token.
|
||||||
|
PUBLIC_ROUTES = {"_route_auth"}
|
||||||
|
|
||||||
def _dispatch(self):
|
def _dispatch(self):
|
||||||
parsed = urllib.parse.urlparse(self.path)
|
path_only = urllib.parse.urlparse(self.path).path
|
||||||
path_only = parsed.path
|
|
||||||
|
|
||||||
for prefix, method in self.ROUTES.get(self.command, []):
|
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 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 method in self.PUBLIC_ROUTES or self._check_auth():
|
||||||
if self.command == "GET" and path_only in ("/", "/index.html"):
|
return getattr(self, method)(prefix)
|
||||||
tok = self._query_token()
|
if self.command == "GET" and path_only in ("/", "/index.html"):
|
||||||
if tok and self._match(tok):
|
tok = self._query_token()
|
||||||
return self._token_to_cookie(tok)
|
if tok and self._match(tok):
|
||||||
return self._send_auth_page()
|
return self._token_to_cookie(tok)
|
||||||
self.send_response(401)
|
return self._send_auth_page()
|
||||||
body = json.dumps({"error": "unauthorized"}).encode()
|
return self._json({"error": "unauthorized"}, status=401, headers=())
|
||||||
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)
|
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
do_GET = do_POST = do_DELETE = _dispatch
|
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")
|
self._respond(self.server.html, "text/html; charset=utf-8")
|
||||||
|
|
||||||
def _route_files(self, _prefix):
|
def _route_files(self, _prefix):
|
||||||
entries = []
|
entries = [(p, p.stat()) for p in self.server.upload_dir.iterdir() if p.is_file()]
|
||||||
for p in self.server.upload_dir.iterdir():
|
|
||||||
if p.is_file():
|
|
||||||
entries.append((p, p.stat()))
|
|
||||||
entries.sort(key=lambda e: e[1].st_mtime, reverse=True)
|
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])
|
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)
|
data = json.loads(body)
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
data = {}
|
data = {}
|
||||||
if data.get("token") and self._match(data["token"]):
|
token = data.get("token")
|
||||||
resp = json.dumps({"ok": True}).encode()
|
if token and self._match(token):
|
||||||
self.send_response(200)
|
self._json({"ok": True}, headers=(("Set-Cookie", self._cookie(token)),))
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
resp = json.dumps({"ok": False}).encode()
|
self._json({"ok": False}, status=401, headers=())
|
||||||
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)
|
|
||||||
|
|
||||||
def _route_logout(self, _prefix):
|
def _route_logout(self, _prefix):
|
||||||
self.send_response(302)
|
self._redirect("/", f"{COOKIE_NAME}=; {_COOKIE_ATTRS}; Max-Age=0")
|
||||||
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()
|
|
||||||
|
|
||||||
def _route_delete(self, prefix):
|
def _route_delete(self, prefix):
|
||||||
safe = self._safe_path(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:
|
def _unique_path(self, filename: str) -> Path:
|
||||||
dest = self.server.upload_dir / filename
|
dest = self.server.upload_dir / filename
|
||||||
if not dest.exists():
|
|
||||||
return dest
|
|
||||||
stem, suffix = dest.stem, dest.suffix
|
stem, suffix = dest.stem, dest.suffix
|
||||||
i = 1
|
i = 1
|
||||||
while dest.exists():
|
while dest.exists():
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Share — local file sharing server (HTTPS)."""
|
"""Share — local file sharing server (HTTPS)."""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
@@ -11,13 +10,13 @@ from pathlib import Path
|
|||||||
from cert import ensure_cert, get_local_ip
|
from cert import ensure_cert, get_local_ip
|
||||||
from cleanup import cleanup_loop
|
from cleanup import cleanup_loop
|
||||||
from config import BASE_DIR, SECS_PER_DAY, client_cfg, load_config
|
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():
|
def main():
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "hash":
|
if len(sys.argv) > 1 and sys.argv[1] == "hash":
|
||||||
token = sys.argv[2] if len(sys.argv) > 2 else input("Token: ")
|
token = sys.argv[2] if len(sys.argv) > 2 else input("Token: ")
|
||||||
print(hashlib.sha256(token.encode()).hexdigest())
|
print(sha256_hex(token))
|
||||||
return
|
return
|
||||||
|
|
||||||
conf = load_config()
|
conf = load_config()
|
||||||
@@ -48,9 +47,7 @@ def main():
|
|||||||
if not token:
|
if not token:
|
||||||
print("\n WARNING: no token set — anyone on the network has full access.")
|
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.")
|
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
|
server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx, token=token)
|
||||||
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)
|
|
||||||
|
|
||||||
print("\n Share running")
|
print("\n Share running")
|
||||||
print(f" Local: https://localhost:{port}")
|
print(f" Local: https://localhost:{port}")
|
||||||
|
|||||||
+7
-7
@@ -311,12 +311,11 @@ function fallbackCopy(text) {
|
|||||||
|
|
||||||
function copyText(text, msg) {
|
function copyText(text, msg) {
|
||||||
msg = msg || t().copied;
|
msg = msg || t().copied;
|
||||||
|
const fallback = () => toast(fallbackCopy(text) ? msg : t().copyFail);
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text).then(() => toast(msg)).catch(fallback);
|
||||||
.then(() => toast(msg))
|
|
||||||
.catch(() => { fallbackCopy(text) ? toast(msg) : toast(t().copyFail) });
|
|
||||||
} else {
|
} else {
|
||||||
fallbackCopy(text) ? toast(msg) : toast(t().copyFail);
|
fallback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,12 +415,14 @@ function setPage(p) {
|
|||||||
renderFiles();
|
renderFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPager() { $('pager').innerHTML = ''; $('per-page').innerHTML = '' }
|
||||||
|
|
||||||
function renderPager(total) {
|
function renderPager(total) {
|
||||||
const pages = Math.ceil(total / perPage);
|
const pages = Math.ceil(total / perPage);
|
||||||
const pager = $('pager');
|
const pager = $('pager');
|
||||||
const pp = $('per-page');
|
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 */
|
/* per-page selector */
|
||||||
pp.innerHTML = '<span class="lbl">' + t().perPage + '</span>' +
|
pp.innerHTML = '<span class="lbl">' + t().perPage + '</span>' +
|
||||||
@@ -454,8 +455,7 @@ function renderPager(total) {
|
|||||||
function renderFiles() {
|
function renderFiles() {
|
||||||
if (!allFiles.length) {
|
if (!allFiles.length) {
|
||||||
flist.innerHTML = '<div class="empty">' + t().empty + '</div>';
|
flist.innerHTML = '<div class="empty">' + t().empty + '</div>';
|
||||||
$('pager').innerHTML = '';
|
clearPager();
|
||||||
$('per-page').innerHTML = '';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pages = Math.ceil(allFiles.length / perPage);
|
const pages = Math.ceil(allFiles.length / perPage);
|
||||||
|
|||||||
Reference in New Issue
Block a user