sheheryarsoomro12 2026-08-20 11:46:42 +05:00
parent ec7290cacd
commit 2b8923f898
3 changed files with 92 additions and 13 deletions

View File

@ -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,6 +213,10 @@ class Handler(BaseHTTPRequestHandler):
path = urlparse(self.path).path path = urlparse(self.path).path
try: try:
if path == "/api/upload": if path == "/api/upload":
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") name = self.headers.get("X-Filename", "upload.xlsx")
kind = self.headers.get("X-Kind", "history") kind = self.headers.get("X-Kind", "history")
data = self._body() data = self._body()

View File

@ -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;

View File

@ -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>
&mdash; or drop a folder.</p> &mdash; 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>