new
parent
ec7290cacd
commit
2b8923f898
93
serve.py
93
serve.py
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -35,7 +36,75 @@ MAX_UPLOAD = 200 * 1024 * 1024
|
||||||
|
|
||||||
MIME = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
|
MIME = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
|
||||||
".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml",
|
".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:
|
class Session:
|
||||||
|
|
@ -123,6 +192,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
if path == "/api/state":
|
if path == "/api/state":
|
||||||
self._json({
|
self._json({
|
||||||
|
"mode": "local",
|
||||||
"history": [p.name for p in SESSION.history],
|
"history": [p.name for p in SESSION.history],
|
||||||
"perf": SESSION.perf.name if SESSION.perf else None,
|
"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])
|
self._export(parse_qs(route.query).get("format", ["xlsx"])[0])
|
||||||
return
|
return
|
||||||
|
|
||||||
rel = "index.html" if path in ("/", "") else path.lstrip("/")
|
target = _web_file(path)
|
||||||
target = (WEB / rel).resolve()
|
if target is None:
|
||||||
if not str(target).startswith(str(WEB.resolve())) or not target.is_file():
|
|
||||||
self._send(HTTPStatus.NOT_FOUND, b"Not found", "text/plain; charset=utf-8")
|
self._send(HTTPStatus.NOT_FOUND, b"Not found", "text/plain; charset=utf-8")
|
||||||
return
|
return
|
||||||
self._send(HTTPStatus.OK, target.read_bytes(),
|
self._send(HTTPStatus.OK, target.read_bytes(),
|
||||||
|
|
@ -144,12 +213,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
try:
|
try:
|
||||||
if path == "/api/upload":
|
if path == "/api/upload":
|
||||||
name = self.headers.get("X-Filename", "upload.xlsx")
|
ctype = self.headers.get("Content-Type", "")
|
||||||
kind = self.headers.get("X-Kind", "history")
|
if "multipart/form-data" in ctype:
|
||||||
data = self._body()
|
name, data, kind = _parse_multipart(self._body(), ctype)
|
||||||
if not data:
|
else:
|
||||||
self._error("That file was empty.")
|
name = self.headers.get("X-Filename", "upload.xlsx")
|
||||||
return
|
kind = self.headers.get("X-Kind", "history")
|
||||||
|
data = self._body()
|
||||||
|
if not data:
|
||||||
|
self._error("That file was empty.")
|
||||||
|
return
|
||||||
with SESSION.lock:
|
with SESSION.lock:
|
||||||
SESSION.add(name, data, kind)
|
SESSION.add(name, data, kind)
|
||||||
self._json({"ok": True, "name": Path(name).name})
|
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
|
// expired session redirects to the sign-in page rather than silently rendering
|
||||||
// an empty dashboard.
|
// an empty dashboard.
|
||||||
A.api('/api/state').then((r) => r.json()).then((s) => {
|
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').textContent = s.user.name || s.user.email;
|
||||||
$('whoami').title = s.user.email;
|
$('whoami').title = s.user.email;
|
||||||
$('link-admin').hidden = !s.user.is_admin;
|
$('link-admin').hidden = !s.user.is_admin;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<!-- Outside the set that stage() toggles, so these stay visible throughout. -->
|
<!-- Outside the set that stage() toggles, so these stay visible throughout. -->
|
||||||
<span id="whoami" class="muted" style="align-self:center;font-size:12px"></span>
|
<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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
<h2>Drop your change-history exports here</h2>
|
<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>
|
<p>One file or a whole week of them. <button type="button" class="linklike" id="pick">Choose files</button>
|
||||||
— or drop a folder.</p>
|
— 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>
|
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>
|
<input type="file" id="file-input" multiple accept=".xlsx,.xlsm" hidden>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue