Der Filter in log_message pruefte args[0] mit "in", bekommt bei send_error aber einen HTTPStatus statt der Anfragezeile. Der TypeError riss den Handler-Thread mit, der Browser wartete auf eine Antwort, die nie kam, und die Seite blieb ohne Meldung bei "Lade Screens ..." stehen. args[0] wird jetzt in str() gewickelt, und alles, was keine gewoehnliche Anfragezeile ist, wird geloggt statt verschluckt. Genau das haette die Suche vorher abgekuerzt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
242 lines
9.7 KiB
Python
Executable File
242 lines
9.7 KiB
Python
Executable File
#!/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/<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.
|
|
|
|
Eigene Bilder kommen ueber /upload herein und landen unverfaelscht als Datei
|
|
unter assets/eigene/. Der Weg ueber den Projektstand waere der falsche: ein
|
|
2-MB-PNG als Base64 blaeht state/post1.json auf und macht jeden Diff
|
|
unlesbar. So steht im Stand nur der Pfad, und das Bild liegt mit im Repo --
|
|
dasselbe Prinzip wie bei allen anderen Assets.
|
|
"""
|
|
|
|
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"
|
|
EIGENE_DIR = ROOT / "assets" / "eigene"
|
|
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
|
|
MAX_BILD = 40 * 1024 * 1024
|
|
# Nur verlustfreie oder unveraenderte Rasterbilder. Die Bytes werden nicht
|
|
# angefasst, deshalb bleibt ein PNG genau das PNG, das der Finder geliefert hat.
|
|
BILD_ENDUNGEN = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"}
|
|
BILD_ORDNER = ("assets/screens", "assets/eigene")
|
|
|
|
|
|
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_GET(self):
|
|
"""Vor der Dateiauslieferung nur die Bilderliste abfangen."""
|
|
if urllib.parse.urlparse(self.path).path == "/bilder":
|
|
self._liste_bilder()
|
|
return
|
|
super().do_GET()
|
|
|
|
def _liste_bilder(self):
|
|
"""Alle verwendbaren Bilder, damit die Auswahl im Generator nicht im
|
|
Code gepflegt werden muss und importierte sofort auftauchen."""
|
|
raus = []
|
|
for ordner in BILD_ORDNER:
|
|
verz = ROOT / ordner
|
|
if not verz.is_dir():
|
|
continue
|
|
for f in sorted(verz.iterdir()):
|
|
if f.is_file() and f.suffix.lower() in BILD_ENDUNGEN:
|
|
raus.append({"pfad": "./" + ordner + "/" + f.name,
|
|
"name": f.stem, "ordner": ordner.split("/")[-1]})
|
|
self._json(200, json.dumps(raus, ensure_ascii=False))
|
|
|
|
def _upload(self):
|
|
"""Ein eigenes Bild aufnehmen. Die Bytes gehen unveraendert auf die
|
|
Platte: nicht neu kodiert, nicht skaliert, nicht umbenannt ausser um
|
|
gefaehrliche Zeichen. Verlustfrei heisst hier woertlich unangetastet."""
|
|
name = os.path.basename(urllib.parse.unquote(self.headers.get("X-Filename", "")))
|
|
stamm, endung = os.path.splitext(name)
|
|
endung = endung.lower()
|
|
if endung not in BILD_ENDUNGEN:
|
|
self._json(400, '{"error":"kein Bildformat"}')
|
|
return
|
|
# Aus dem Stamm wird ein unverfaenglicher Dateiname. Umlaute werden
|
|
# umschrieben statt weggeworfen, sonst wird aus "pruefbild" ein
|
|
# "pr-fbild" und der Name sagt nichts mehr.
|
|
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("Ä", "Ae"),
|
|
("Ö", "Oe"), ("Ü", "Ue"), ("ß", "ss")):
|
|
stamm = stamm.replace(a, b)
|
|
stamm = re.sub(r"[^A-Za-z0-9._-]+", "-", stamm).strip("-.") or "bild"
|
|
stamm = stamm[:80]
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
if length <= 0 or length > MAX_BILD:
|
|
self._json(400, '{"error":"ungueltige Groesse"}')
|
|
return
|
|
daten = self.rfile.read(length)
|
|
|
|
EIGENE_DIR.mkdir(parents=True, exist_ok=True)
|
|
ziel = (EIGENE_DIR / (stamm + endung)).resolve()
|
|
if ziel.parent != EIGENE_DIR.resolve():
|
|
self._json(400, '{"error":"ungueltiger Pfad"}')
|
|
return
|
|
# Gleicher Inhalt, gleicher Name: nicht doppelt ablegen.
|
|
n = 1
|
|
while ziel.exists() and ziel.read_bytes() != daten:
|
|
ziel = EIGENE_DIR / (stamm + "-" + str(n) + endung)
|
|
n += 1
|
|
ziel.write_bytes(daten)
|
|
pfad = "./assets/eigene/" + ziel.name
|
|
print(f"importiert: {ziel.name} ({len(daten)/1024:.0f} KB)")
|
|
self._json(200, json.dumps({"pfad": pfad, "name": ziel.stem}))
|
|
|
|
def do_POST(self):
|
|
path = urllib.parse.urlparse(self.path).path
|
|
if path == "/state":
|
|
self._put_state()
|
|
return
|
|
if path == "/upload":
|
|
self._upload()
|
|
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):
|
|
# args[0] ist normalerweise die Anfragezeile, bei send_error aber ein
|
|
# HTTPStatus. Ohne str() flog hier ein TypeError, der den Handler-Thread
|
|
# abbrach: der Browser wartete dann ewig auf eine Antwort, die nie kam,
|
|
# und die Seite blieb bei "Lade Screens ..." stehen.
|
|
first = str(args[0]) if args else ""
|
|
if "POST /save" in first or "DELETE" in first:
|
|
super().log_message(fmt, *args)
|
|
elif not first.startswith(("GET", "POST", "HEAD")):
|
|
# Fehler und Warnungen gehoeren gesehen, sonst sucht man lange.
|
|
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()
|