new
parent
ec7290cacd
commit
2b8923f898
93
serve.py
93
serve.py
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
|
|
@ -35,7 +36,75 @@ MAX_UPLOAD = 200 * 1024 * 1024
|
|||
|
||||
MIME = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon"}
|
||||
".ico": "image/x-icon", ".woff2": "font/woff2"}
|
||||
|
||||
|
||||
def _web_file(url_path: str) -> Path | None:
|
||||
"""Map a URL path to a file under web/, mirroring the hosted /static mount."""
|
||||
rel = url_path.removeprefix("/static/").lstrip("/")
|
||||
if url_path in ("/", ""):
|
||||
rel = "index.html"
|
||||
target = (WEB / rel).resolve()
|
||||
if not str(target).startswith(str(WEB.resolve())) or not target.is_file():
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
_DISPOSITION = re.compile(
|
||||
r'content-disposition:\s*form-data;\s*(.*)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FIELD = re.compile(r'name="([^"]+)"')
|
||||
_FILENAME = re.compile(r'filename="([^"]*)"')
|
||||
|
||||
|
||||
def _parse_multipart(body: bytes, content_type: str) -> tuple[str, bytes, str]:
|
||||
"""Extract (filename, file_bytes, kind) from a multipart upload."""
|
||||
if "multipart/form-data" not in content_type:
|
||||
raise ValueError("Expected a multipart upload.")
|
||||
boundary = None
|
||||
for part in content_type.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("boundary="):
|
||||
boundary = part[9:].strip().strip('"')
|
||||
break
|
||||
if not boundary:
|
||||
raise ValueError("Upload is missing a boundary.")
|
||||
|
||||
filename = "upload.xlsx"
|
||||
kind = "history"
|
||||
file_data = b""
|
||||
for section in body.split(f"--{boundary}".encode()):
|
||||
if not section or section in (b"--", b"--\r\n"):
|
||||
continue
|
||||
chunk = section.lstrip(b"\r\n")
|
||||
if not chunk:
|
||||
continue
|
||||
header_end = chunk.find(b"\r\n\r\n")
|
||||
if header_end < 0:
|
||||
continue
|
||||
headers = chunk[:header_end].decode("latin-1", errors="replace")
|
||||
payload = chunk[header_end + 4:]
|
||||
if payload.endswith(b"\r\n"):
|
||||
payload = payload[:-2]
|
||||
|
||||
disp = _DISPOSITION.search(headers)
|
||||
if not disp:
|
||||
continue
|
||||
name_match = _FIELD.search(disp.group(1))
|
||||
if not name_match:
|
||||
continue
|
||||
name = name_match.group(1)
|
||||
if name == "kind":
|
||||
kind = payload.decode("utf-8", errors="replace").strip() or "history"
|
||||
elif name == "file":
|
||||
file_data = payload
|
||||
fn = _FILENAME.search(disp.group(1))
|
||||
if fn and fn.group(1):
|
||||
filename = fn.group(1)
|
||||
if not file_data:
|
||||
raise ValueError("That file was empty.")
|
||||
return filename, file_data, kind
|
||||
|
||||
|
||||
class Session:
|
||||
|
|
@ -123,6 +192,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
|
||||
if path == "/api/state":
|
||||
self._json({
|
||||
"mode": "local",
|
||||
"history": [p.name for p in SESSION.history],
|
||||
"perf": SESSION.perf.name if SESSION.perf else None,
|
||||
})
|
||||
|
|
@ -132,9 +202,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self._export(parse_qs(route.query).get("format", ["xlsx"])[0])
|
||||
return
|
||||
|
||||
rel = "index.html" if path in ("/", "") else path.lstrip("/")
|
||||
target = (WEB / rel).resolve()
|
||||
if not str(target).startswith(str(WEB.resolve())) or not target.is_file():
|
||||
target = _web_file(path)
|
||||
if target is None:
|
||||
self._send(HTTPStatus.NOT_FOUND, b"Not found", "text/plain; charset=utf-8")
|
||||
return
|
||||
self._send(HTTPStatus.OK, target.read_bytes(),
|
||||
|
|
@ -144,12 +213,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||
path = urlparse(self.path).path
|
||||
try:
|
||||
if path == "/api/upload":
|
||||
name = self.headers.get("X-Filename", "upload.xlsx")
|
||||
kind = self.headers.get("X-Kind", "history")
|
||||
data = self._body()
|
||||
if not data:
|
||||
self._error("That file was empty.")
|
||||
return
|
||||
ctype = self.headers.get("Content-Type", "")
|
||||
if "multipart/form-data" in ctype:
|
||||
name, data, kind = _parse_multipart(self._body(), ctype)
|
||||
else:
|
||||
name = self.headers.get("X-Filename", "upload.xlsx")
|
||||
kind = self.headers.get("X-Kind", "history")
|
||||
data = self._body()
|
||||
if not data:
|
||||
self._error("That file was empty.")
|
||||
return
|
||||
with SESSION.lock:
|
||||
SESSION.add(name, data, kind)
|
||||
self._json({"ok": True, "name": Path(name).name})
|
||||
|
|
|
|||
|
|
@ -823,7 +823,13 @@ $('settings').addEventListener('close', (ev) => {
|
|||
// expired session redirects to the sign-in page rather than silently rendering
|
||||
// an empty dashboard.
|
||||
A.api('/api/state').then((r) => r.json()).then((s) => {
|
||||
if (s.user) {
|
||||
if (s.mode === 'local') {
|
||||
const note = $('upload-note');
|
||||
if (note) {
|
||||
note.textContent = 'Everything stays on this machine. Nothing is uploaded anywhere.';
|
||||
}
|
||||
} else if (s.user) {
|
||||
$('btn-signout').hidden = false;
|
||||
$('whoami').textContent = s.user.name || s.user.email;
|
||||
$('whoami').title = s.user.email;
|
||||
$('link-admin').hidden = !s.user.is_admin;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<!-- Outside the set that stage() toggles, so these stay visible throughout. -->
|
||||
<span id="whoami" class="muted" style="align-self:center;font-size:12px"></span>
|
||||
<a id="link-admin" href="/admin" hidden><button type="button" class="ghost">Accounts</button></a>
|
||||
<button id="btn-signout" class="ghost">Sign out</button>
|
||||
<button id="btn-signout" class="ghost" hidden>Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
<h2>Drop your change-history exports here</h2>
|
||||
<p>One file or a whole week of them. <button type="button" class="linklike" id="pick">Choose files</button>
|
||||
— or drop a folder.</p>
|
||||
<p class="fineprint">Your files are uploaded to this server, analysed, and deleted
|
||||
<p class="fineprint" id="upload-note">Your files are uploaded to this server, analysed, and deleted
|
||||
when you sign out or go idle. Nobody else using this dashboard can see them.</p>
|
||||
<input type="file" id="file-input" multiple accept=".xlsx,.xlsm" hidden>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue