#!/usr/bin/env python3 """Static server for the post generator, plus a save and a state endpoint. The browser download route turned out to be unreliable here: with a data: URL it opened a blank tab, with a blob: URL it opened the image in a tab. Both times the download attribute was ignored. Since this only ever runs locally, the generator now POSTs the rendered bytes to /save and the file is written straight to the Downloads folder. No browser download machinery involved. Der Projektstand liegt nicht mehr nur im localStorage des Browsers, sondern unter state/.json im Repo. Nur so ueberlebt er einen Rechnerwechsel: gepusht und gezogen hat die andere Maschine denselben Stand. Der localStorage bleibt als Zwischenspeicher, die Datei ist die Wahrheit. """ import http.server import json import os import re import socketserver import urllib.parse from pathlib import Path PORT = 4321 ROOT = Path(__file__).resolve().parent TARGET = Path.home() / "Downloads" STATE_DIR = ROOT / "state" SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,120}$") SAFE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$") MAX_STATE = 4 * 1024 * 1024 class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=str(ROOT), **kw) def _project(self): """Projektname aus der Anfrage, oder None wenn er nicht taugt.""" q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) name = (q.get("project") or [""])[0] return name if SAFE_PROJECT.match(name) else None def _state_path(self, name): # Der Name ist geprueft, aber der Pfad wird trotzdem gegengerechnet: # ein Schreibziel ausserhalb von state/ darf es nicht geben. p = (STATE_DIR / f"{name}.json").resolve() return p if p.parent == STATE_DIR.resolve() else None def do_DELETE(self): if urllib.parse.urlparse(self.path).path != "/state": self.send_error(404) return name = self._project() if not name: self._json(400, '{"error":"ungueltiges Projekt"}') return p = self._state_path(name) if p and p.exists(): p.unlink() print(f"Stand verworfen: {p.name}") self._json(200, '{"ok":true}') def _put_state(self): name = self._project() if not name: self._json(400, '{"error":"ungueltiges Projekt"}') return length = int(self.headers.get("Content-Length", 0)) if length <= 0 or length > MAX_STATE: self._json(400, '{"error":"ungueltige Groesse"}') return raw = self.rfile.read(length) try: data = json.loads(raw) except ValueError: self._json(400, '{"error":"kein JSON"}') return p = self._state_path(name) if p is None: self._json(400, '{"error":"ungueltiger Pfad"}') return STATE_DIR.mkdir(parents=True, exist_ok=True) # Eingerueckt und mit sortierten Schluesseln, damit git einen lesbaren # Unterschied zeigt statt einer einzigen langen Zeile. Erst in eine # Nebendatei, dann umbenennen: ein Abbruch mittendrin darf keinen # halben Stand hinterlassen. tmp = p.with_suffix(".json.tmp") tmp.write_text(json.dumps(data, indent=1, sort_keys=True, ensure_ascii=False) + "\n", encoding="utf-8") tmp.replace(p) self._json(200, '{"ok":true}') def do_POST(self): path = urllib.parse.urlparse(self.path).path if path == "/state": self._put_state() return if path != "/save": self.send_error(404) return name = self.headers.get("X-Filename", "") # The filename comes from the page, so it is never trusted: no paths, no # traversal, plain basename only. name = os.path.basename(name) if not SAFE_NAME.match(name): self._json(400, '{"error":"ungueltiger Dateiname"}') return length = int(self.headers.get("Content-Length", 0)) if length <= 0 or length > 80 * 1024 * 1024: self._json(400, '{"error":"ungueltige Groesse"}') return data = self.rfile.read(length) TARGET.mkdir(parents=True, exist_ok=True) dest = TARGET / name stem, suffix = dest.stem, dest.suffix n = 1 while dest.exists(): dest = TARGET / f"{stem}-{n}{suffix}" n += 1 dest.write_bytes(data) print(f"gespeichert: {dest} ({len(data)/1024:.0f} KB)") self._json(200, '{"path":"%s"}' % dest) def _json(self, code, body): payload = body.encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def end_headers(self): # Werkzeug, kein Auslieferungsserver: Assets werden hier laufend # ausgetauscht, und ein gecachter Screenshot unter gleichem Dateinamen # kostet mehr Zeit als jeder erneute Ladevorgang. self.send_header("Cache-Control", "no-store, must-revalidate") self.send_header("Pragma", "no-cache") self.send_header("Expires", "0") super().end_headers() def log_message(self, fmt, *args): first = args[0] if args else "" if "POST /save" in first or "DELETE" in first: super().log_message(fmt, *args) class Server(socketserver.ThreadingTCPServer): allow_reuse_address = True if __name__ == "__main__": print(f"Generator: http://127.0.0.1:{PORT}/post1.html") print(f"Speichert nach: {TARGET}") with Server(("127.0.0.1", PORT), Handler) as httpd: httpd.serve_forever()