Projektstand liegt im Repo statt nur im Browser

Der Stand hing am localStorage und damit an einem Rechner. Wer das Projekt
woanders zog, sah einen anderen Stand.

Jede Aenderung geht jetzt zusaetzlich in state/<projekt>.json, gebuendelt nach
600 ms. Beim Laden gewinnt die Datei, der localStorage ist nur noch der
Rueckfall fuer den Fall, dass der Server nicht laeuft. Gibt es die Datei noch
nicht, wird der vorhandene Browser-Stand einmalig hineingeschrieben. Ein
Reload verschluckt nichts: was im Timer haengt, geht per sendBeacon raus.

Der Server nimmt den Stand unter POST /state entgegen und loescht ihn unter
DELETE /state. Der Projektname wird gegen ein enges Muster geprueft und der
Zielpfad zusaetzlich gegen state/ gegengerechnet. Geschrieben wird erst in
eine Nebendatei und dann umbenannt, eingerueckt und mit sortierten Schluesseln,
damit git einen lesbaren Unterschied zeigt.

state/*.json gehoeren ab jetzt in jeden Commit, sonst sieht die andere
Maschine die Aenderung nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-06 17:36:49 +02:00
1 parent f046f6d6f0
commit 1359c6eba8
6 files changed
+420 -47

No files matched your search

+74 -3
View File
@@ -1,31 +1,101 @@
#!/usr/bin/env python3
"""Static server for the post generator, plus a save endpoint.
"""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/<projekt>.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):
if self.path.split("?")[0] != "/save":
path = urllib.parse.urlparse(self.path).path
if path == "/state":
self._put_state()
return
if path != "/save":
self.send_error(404)
return
@@ -72,7 +142,8 @@ class Handler(http.server.SimpleHTTPRequestHandler):
super().end_headers()
def log_message(self, fmt, *args):
if "POST" in (args[0] if args else ""):
first = args[0] if args else ""
if "POST /save" in first or "DELETE" in first:
super().log_message(fmt, *args)