From 35985fe66dee3e62c1619668eb944ff87d681056 Mon Sep 17 00:00:00 2001 From: Dominic Alexander Windolph Date: Sat, 17 Oct 2026 20:53:37 +0100 Subject: [PATCH] Admin: eigenen Einladungslink festlegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statt des zufälligen Links kann der Admin einen sprechenden Link wählen (z. B. /z/sommerfest2026, mind. 6 Zeichen: A–Z, a–z, 0–9, - und _). Alte Zugänge werden dabei ungültig; der QR-Code passt sich an. Co-Authored-By: Claude Opus 5.5 --- server/app/admin.py | 22 +++++++++++++++++++--- server/app/static/admin.html | 12 ++++++++++-- server/app/static/admin.js | 13 +++++++++++++ server/tests/test_admin.py | 17 +++++++++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/server/app/admin.py b/server/app/admin.py index c0c574a..5e4bae7 100644 --- a/server/app/admin.py +++ b/server/app/admin.py @@ -354,15 +354,19 @@ def neu_mischen(db: sqlite3.Connection = Depends(get_db)) -> dict: # --- Gastzugang (Einladungslink / QR-Code) --------------------------------------- +def _link_basis(request: Request) -> str: + return (konfig().public_url or str(request.base_url).rstrip("/")) + "/z/" + + def _einladungslink(request: Request, token: str) -> str: - basis = konfig().public_url or str(request.base_url).rstrip("/") - return f"{basis}/z/{token}" + return _link_basis(request) + token @api.get("/access") def zugang(request: Request, db: sqlite3.Connection = Depends(get_db)) -> dict: e = einstellungen(db) - return {"enabled": e["access_enabled"], "link": _einladungslink(request, e["access_token"])} + return {"enabled": e["access_enabled"], "link": _einladungslink(request, e["access_token"]), + "base": _link_basis(request), "token": e["access_token"]} class ZugangSchalter(BaseModel): @@ -382,6 +386,18 @@ def zugang_erneuern(db: sqlite3.Connection = Depends(get_db)) -> dict: return {"ok": True} +class EigenerLink(BaseModel): + # sprechend, aber nicht zu kurz (Raten wird zusätzlich durch die Einladungs-Drossel gebremst) + token: str = Field(pattern=r"^[A-Za-z0-9_-]{6,64}$") + + +@api.post("/access/token") +def zugang_eigener_link(z: EigenerLink, db: sqlite3.Connection = Depends(get_db)) -> dict: + """Eigenen Link festlegen (z. B. /z/sommerfest2026) – alte Zugänge werden ungültig.""" + einstellung_setzen(db, "access_token", z.token) + return {"ok": True} + + @api.get("/access/qr") def zugang_qr(request: Request, format: Literal["svg", "png"] = "svg", db: sqlite3.Connection = Depends(get_db)) -> Response: diff --git a/server/app/static/admin.html b/server/app/static/admin.html index 02ea6eb..e14bba7 100644 --- a/server/app/static/admin.html +++ b/server/app/static/admin.html @@ -108,8 +108,16 @@ QR als Bild - -

Ein neuer Link macht den alten und alle damit erteilten Zugänge ungültig.

+ + + +

Ein geänderter Link macht den alten und alle damit erteilten Zugänge ungültig. + Eigener Link: mindestens 6 Zeichen, nur Buchstaben, Ziffern, - und _; Groß-/Kleinschreibung zählt. + Je kürzer und gebräuchlicher, desto leichter zu erraten – für eine Party ist z. B. „sommerfest2026“ in Ordnung.

diff --git a/server/app/static/admin.js b/server/app/static/admin.js index b6098bf..b6054b4 100644 --- a/server/app/static/admin.js +++ b/server/app/static/admin.js @@ -293,6 +293,8 @@ async function ladeZugang() { const z = await api("/access"); setzeSchalter("s-zugang", z.enabled); $("einladungslink").value = z.link; + $("link-basis").textContent = z.base; + setzeWert("link-eigen", z.token); $("qr").src = "/admin/api/access/qr?t=" + Date.now(); } @@ -426,6 +428,17 @@ function start() { toast("Neuer Link erzeugt"); ladeZugang(); }); + $("link-festlegen").addEventListener("click", async () => { + const token = $("link-eigen").value.trim(); + if (!/^[A-Za-z0-9_-]{6,64}$/.test(token)) { + toast("Mindestens 6 Zeichen: nur Buchstaben, Ziffern, - und _", true); + return; + } + if (!confirm(`Link auf „${token}“ ändern? Der alte Link und alle bisherigen Gastzugänge werden ungültig.`)) return; + await api("/access/token", { token }); + toast("Eigener Link übernommen"); + ladeZugang(); + }); $("playlist-wechseln").addEventListener("click", async () => { await aktion("/playlist", { link: $("playlist-neu").value }, "Playlist gewechselt").catch(() => {}); diff --git a/server/tests/test_admin.py b/server/tests/test_admin.py index 23b3dad..eaaea25 100644 --- a/server/tests/test_admin.py +++ b/server/tests/test_admin.py @@ -211,6 +211,23 @@ def test_gastzugang_und_qr(client): assert png.content[:8] == b"\x89PNG\r\n\x1a\n" and "attachment" in png.headers["content-disposition"] +def test_eigener_einladungslink(client): + h = anmelden(client) + assert client.post("/admin/api/access/token", json={"token": "sommerfest2026"}, headers=h).status_code == 200 + z = client.get("/admin/api/access").json() + assert z["link"] == "https://musik.example.de/z/sommerfest2026" and z["token"] == "sommerfest2026" + assert z["base"] == "https://musik.example.de/z/" + for falsch in ("kurz", "mit leerzeichen", "../../etc", "ümlaut1", "x" * 65, ""): + assert client.post("/admin/api/access/token", json={"token": falsch}, headers=h).status_code == 422, falsch + assert client.post("/admin/api/access/token", json={"token": "sommerfest2026"}).status_code == 403 # ohne CSRF + # Gäste kommen mit dem eigenen Link hinein, mit dem alten zufälligen nicht mehr + client.post("/admin/api/access", json={"enabled": True}, headers=h) + gast = TestClient(client.app) + assert gast.get("/z/sommerfest2026", follow_redirects=False).status_code == 303 + assert gast.get("/api/status").status_code == 200 + assert gast.get("/z/Sommerfest2026", follow_redirects=False).status_code == 403 # Groß/klein zählt + + def test_verlauf(client): anmelden(client) w = wunsch(k="A")