Files
posts/serve.py
T
dejanandClaude Opus 5 11eaf3f3e7 Umlaute im Dateinamen, klarere Meldung bei altem Server
Beim Import wurde aus "Pruefbild" ein "pr-fbild": der Namensfilter warf alles
weg, was nicht ASCII ist. Umlaute werden jetzt umschrieben statt entfernt.

Und /upload antwortet mit 404, solange der Python-Server noch in einer
Fassung ohne den Endpunkt laeuft. Das ist die wahrscheinlichste Ursache,
wenn der Import scheinbar nichts tut -- die Seite neu zu laden hilft
dagegen nicht, weil serve.py nur beim Start eingelesen wird. Die Meldung
sagt das jetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:23:15 +02:00

235 lines
9.3 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):
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()