3 Commits
Author SHA1 Message Date
hnzio 7590be66a5 Release 0.3.0 2026-10-21 21:05:57 +02:00
hnzio 08e8875cb5 Release 0.2.0 2026-10-21 17:25:28 +02:00
hnzio 7a49e66c37 Release 0.1.1 2026-10-21 16:38:56 +02:00
56 changed files with 6450 additions and 537 deletions

No files matched your search

+17
View File
@@ -1,3 +1,20 @@
# Relative paths are resolved from the project directory.
SENDEWERK_DB_PATH=.data/sendewerk.sqlite
SENDEWERK_UPLOAD_DIR=.data/uploads
SENDEWERK_MAIL_OUTBOX_DIR=.data/mail-outbox
BODY_SIZE_LIMIT=6M
# Generate this locally and insert it here as described in README.md. Never commit .env.
SENDEWERK_SETUP_TOKEN=
SENDEWERK_APP_ORIGIN=http://localhost:5173
# "local" writes messages to the outbox and never contacts a mail server.
SENDEWERK_MAIL_TRANSPORT=local
SENDEWERK_MAIL_FROM=Sendewerk <sendewerk@localhost>
# Required only when SENDEWERK_MAIL_TRANSPORT=smtp is explicitly selected.
SENDEWERK_SMTP_HOST=
SENDEWERK_SMTP_PORT=587
SENDEWERK_SMTP_SECURE=false
SENDEWERK_SMTP_USER=
SENDEWERK_SMTP_PASSWORD=
+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.1.0",
"version": "0.3.0",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+71 -13
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
ausschließlich Etappe 1: technisches Grundgerüst, persistente Beispieldaten
und gekennzeichnete Gestaltungsvorschauen. Anmeldung, Bearbeitung und echte
Live-Steuerung folgen in späteren Etappen.
Etappe 1 bis 3: technisches Grundgerüst, Anmeldung und Rollen sowie einen
persistenten Fundus mit Eingang, Editor, Taxonomie, privaten Bildern und
Konfliktschutz. Sendungsbau und Live-Steuerung folgen in späteren Etappen.
## Voraussetzungen
@@ -20,6 +20,9 @@ nicht erforderlich.
```sh
npm ci
cp .env.example .env
SETUP_TOKEN="$(openssl rand -hex 32)"
sed -i "s/^SENDEWERK_SETUP_TOKEN=.*/SENDEWERK_SETUP_TOKEN=$SETUP_TOKEN/" .env
printf 'Betreiber-Setup-Token: %s\n' "$SETUP_TOKEN"
npm run setup
npm run dev
```
@@ -29,18 +32,57 @@ erstellt die konfigurierten Verzeichnisse, führt ausstehende Migrationen aus
und lädt den reproduzierbaren Beispieldatensatz. Der Seed darf beliebig oft
ausgeführt werden und ersetzt nur die von ihm verwalteten Beispieldaten.
Beim ersten Aufruf verlangt `/setup` den ausgegebenen Wert aus
`SENDEWERK_SETUP_TOKEN` und legt genau einen Barkeeper an. Der Befehl schreibt
den Wert in die nicht versionierte `.env`, damit auch spätere lokale Starts
dieselbe Konfiguration verwenden. Nach erfolgreicher Einrichtung ist `/setup`
dauerhaft geschlossen und der Token kann aus `.env` entfernt werden. Der Seed
enthält bewusst kein Konto und kein Standardpasswort.
## Konfiguration
| Variable | Vorgabe | Bedeutung |
| ---------------------- | ------------------------ | ----------------------------------- |
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers |
| `PORT` | `3000` | Port des Produktionsservers |
| Variable | Vorgabe | Bedeutung |
| --------------------------- | ------------------------ | ---------------------------------------------------- |
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail |
| `BODY_SIZE_LIMIT` | `6M` | Requestlimit für private Bild-Uploads bis 5 MB |
| `SENDEWERK_SETUP_TOKEN` | keine | einmaliges Betreibergeheimnis, mindestens 32 Zeichen |
| `SENDEWERK_APP_ORIGIN` | `http://localhost:5173` | feste Origin für Links und Cookie-Sicherheit |
| `SENDEWERK_MAIL_TRANSPORT` | `local` | `local` oder ausdrücklich `smtp` |
| `SENDEWERK_MAIL_FROM` | lokaler Absender | Absender; bei SMTP verpflichtend |
| `SENDEWERK_SMTP_HOST` | keine | SMTP-Server, nur im SMTP-Modus |
| `SENDEWERK_SMTP_PORT` | `587` | SMTP-Port |
| `SENDEWERK_SMTP_SECURE` | `false` | `true` für implizites TLS, üblicherweise Port 465 |
| `SENDEWERK_SMTP_USER` | keine | optionaler SMTP-Benutzer |
| `SENDEWERK_SMTP_PASSWORD` | keine | optionales SMTP-Passwort |
| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers |
| `PORT` | `3000` | Port des Produktionsservers |
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
Cloudron liegen Datenbank und Uploads unter `/app/data`.
Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand
findet nur statt, wenn `SENDEWERK_MAIL_TRANSPORT=smtp` und die SMTP-Werte
explizit gesetzt sind.
## Inhalte und private Bilder
Neue Gedanken oder Links lassen sich im Eingang schnell erfassen. Im Fundus
stehen leere Pieces sowie Vorlagen für Gespräch, Reaction/Fundstück und
Interaktion bereit. Der Editor speichert Änderungen nach kurzer Pause
automatisch; ein sichtbarer Status meldet Erfolg, Fehler oder eine parallele
Änderung. In diesem Fall muss die Seite neu geladen werden, bevor erneut
gespeichert wird.
Markdown ist in Einleitung, Recherche, Moderation, Überleitung und Präsentation
möglich. Die Ausgabe wird serverseitig auf eine begrenzte Auswahl sicherer
Elemente und Links reduziert. Bilder dürfen höchstens 5 MB groß sein und müssen
als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten
Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar.
## Befehle
| Befehl | Zweck |
@@ -48,7 +90,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
| `npm run setup` | Verzeichnisse, Migrationen und Seed für lokale Entwicklung |
| `npm run db:migrate` | Ausstehende Migrationen atomar ausführen |
| `npm run db:seed` | Reproduzierbare Beispieldaten laden |
| `npm run dev` | Entwicklungsserver starten |
| `npm run dev` | Migrationen ausführen und Entwicklungsserver starten |
| `npm run check` | Svelte- und TypeScript-Prüfung |
| `npm run lint` | Formatierung und ESLint prüfen |
| `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank |
@@ -61,6 +103,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
```sh
npm ci
npm run setup
export SENDEWERK_APP_ORIGIN=http://localhost:3000
npm run build
HOST=127.0.0.1 PORT=3000 npm start
```
@@ -68,6 +111,12 @@ HOST=127.0.0.1 PORT=3000 npm start
Der Healthcheck liegt unter <http://localhost:3000/healthz> und prüft auch den
Datenbankzugriff.
Für lokales HTTP wird das Sitzungscookie gezielt ohne `Secure` gesetzt, bleibt
aber `HttpOnly` und `SameSite=Lax`. Bei einer `https:`-Origin ist `Secure`
automatisch aktiv. Die Build-Origin muss beim lokalen Produktionsbuild mit der
später verwendeten Browser-Origin übereinstimmen; SvelteKits Origin-Prüfung
bleibt aktiv.
Vor der ersten Browserprüfung wird der Testbrowser einmalig installiert:
```sh
@@ -82,9 +131,17 @@ Das frühe Cloudron-Paket besteht aus `Dockerfile`, `CloudronManifest.json` und
```sh
docker build -t sendewerk:local .
docker run --rm -p 3000:3000 -v sendewerk-data:/app/data sendewerk:local
docker run --rm -p 3000:3000 \
-e SENDEWERK_APP_ORIGIN=http://localhost:3000 \
-e SENDEWERK_SETUP_TOKEN="$(openssl rand -hex 32)" \
-v sendewerk-data:/app/data sendewerk:local
```
Auf Cloudron muss das Betreiber-Setup-Token vor dem ersten Browseraufruf als
Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
abschließend geprüft werden.
@@ -101,5 +158,6 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3` sind MIT-lizenziert; TypeScript und Playwright stehen unter
Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html und file-type sind
MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und Playwright stehen
unter Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
+68 -13
View File
@@ -2,18 +2,19 @@
## Etappe 1
| Kriterium | Nachweis | Stand |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Frischer Checkout startet ohne Cloudron | `npm ci` erfolgreich; README-Setup und lokaler Produktionsstart ausgeführt | erfüllt am 21.10.2026 |
| Typprüfung und Build erfolgreich | `npm run check`, `npm run build` | erfüllt am 21.10.2026 |
| Theme bleibt nach Neuladen erhalten | Playwright: System auf Hell wechseln, Reload, DOM und Schalterzustand prüfen | erfüllt am 21.10.2026 |
| Testdaten überleben Neustart | Gebauten Server zweimal gegen `.data/sendewerk.sqlite` gestartet; Fundusdaten und `/healthz` jeweils vorhanden | erfüllt am 21.10.2026 |
| Migration wiederholt ausführbar | lokal mehrfach ausgeführt; Verhaltenstest führt Migration zweimal in temporärer DB aus | erfüllt am 21.10.2026 |
| Seed reproduzierbar | lokal und im Verhaltenstest zweimal ausgeführt; danach 4 Pieces, 1 Sendung und 3 Verwendungen | erfüllt am 21.10.2026 |
| Tests verwenden getrennte Daten | Vitest und Playwright erzeugen je eigene Datenbank im Betriebssystem-Tempdir | erfüllt am 21.10.2026 |
| Desktop und Smartphone ohne horizontales Scrollen | 4 Playwright-Abläufe; 1440x1000, 1280x900 und 390x844; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 |
| Cloudron-Pfade und Healthcheck vorbereitet | Manifestpfad `/app/data/sendewerk.sqlite`, `/healthz`, `start.sh`; JSON-Kernfelder und Bash-Syntax geprüft | vorbereitet; Container offen |
| Echte Cloudron-Prüfung | Installation, Backup/Restore, Proxy und read-only Laufzeit | offen, kein Serverzugang |
| Kriterium | Nachweis | Stand |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Frischer Checkout startet ohne Cloudron | `npm ci` erfolgreich; README-Setup und lokaler Produktionsstart ausgeführt | erfüllt am 21.10.2026 |
| Typprüfung und Build erfolgreich | `npm run check`, `npm run build` | erfüllt am 21.10.2026 |
| Theme bleibt nach Neuladen erhalten | Playwright: System auf Hell wechseln, Reload, DOM und Schalterzustand prüfen | erfüllt am 21.10.2026 |
| Testdaten überleben Neustart | Gebauten Server zweimal gegen `.data/sendewerk.sqlite` gestartet; Fundusdaten und `/healthz` jeweils vorhanden | erfüllt am 21.10.2026 |
| Migration wiederholt ausführbar | lokal mehrfach ausgeführt; Verhaltenstest führt Migration zweimal in temporärer DB aus | erfüllt am 21.10.2026 |
| Seed reproduzierbar | lokal und im Verhaltenstest zweimal ausgeführt; danach 4 Pieces, 1 Sendung und 3 Verwendungen | erfüllt am 21.10.2026 |
| Tests verwenden getrennte Daten | Vitest und Playwright erzeugen je eigene Datenbank im Betriebssystem-Tempdir | erfüllt am 21.10.2026 |
| Desktop und Smartphone ohne horizontales Scrollen | 8 Playwright-Abläufe bei 390, 768, 960, 1280 und 1440 Pixeln; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 |
| Kompakte Arbeitsoberfläche in beiden Farbmodi | 390, 768 und 1440 Pixel jeweils in Light/Dark; einspaltige Mobilkarten, lange Titel, Metadatenumbrüche und Leerzustände | erfüllt am 21.10.2026 |
| Cloudron-Pfade und Healthcheck vorbereitet | Manifestpfad `/app/data/sendewerk.sqlite`, `/healthz`, `start.sh`; JSON-Kernfelder und Bash-Syntax geprüft | vorbereitet; Container offen |
| Echte Cloudron-Prüfung | Installation, Backup/Restore, Proxy und read-only Laufzeit | offen, kein Serverzugang |
## Ausgeführte Prüfungen
@@ -23,7 +24,9 @@
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 4 von 4 Browsertests erfolgreich
- `npm run test:e2e`: 8 von 8 Browsertests erfolgreich; 390, 768 und 1440 Pixel
jeweils in Light- und Darkmode sowie Tastaturbedienung, feste Navigation,
einspaltige Funduskarten, lange Inhalte und Überläufe geprüft
- Zwei getrennte `npm start`-Läufe: Healthcheck und persistente Daten erfolgreich
- `bash -n start.sh` und programmatische Manifest-Kernfeldprüfung: erfolgreich
@@ -36,3 +39,55 @@
- Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale
Python-Modul `playwright` nicht installiert war. Die projektgebundene
TypeScript-Playwright-Suite lief stattdessen vollständig erfolgreich.
## Etappe 2
| Kriterium | Nachweis | Stand |
| ------------------------------- | ----------------------------------------------------------------------------------- | --------------------- |
| Erstadmin danach geschlossen | Browser-Setup sowie wiederholter Service-Aufruf geprüft; Seed enthält kein Konto | erfüllt am 21.10.2026 |
| Login, Logout und lokales HTTP | Browserablauf; `HttpOnly`, `SameSite=Lax`, ohne `Secure` bei expliziter HTTP-Origin | erfüllt am 21.10.2026 |
| Einladung und Reset | lokale Outbox bis Kontoaktivierung und neuem Login im Browser durchlaufen | erfüllt am 21.10.2026 |
| Einmalige und abgelaufene Links | Verhaltenstests für Wiederverwendung und Ablauf von Einladung und Reset | erfüllt am 21.10.2026 |
| Direkte Admin-Aktion verweigert | Thekencrew-POST auf Einladungsaction liefert 403 | erfüllt am 21.10.2026 |
| Sperrung entzieht Zugriff | aktive Thekencrew-Sitzung wird gelöscht und beim nächsten Request abgewiesen | erfüllt am 21.10.2026 |
| Keine echte Mail ungefragt | Browser- und Verhaltenstests nutzen ausschließlich temporäre lokale Outbox | erfüllt am 21.10.2026 |
| Echter SMTP-Versand | nicht ausgeführt, keine Zugangsdaten bereitgestellt | offen |
## Ausgeführte Prüfungen Etappe 2
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 9 Auth-, Migrations-, Mail- und Dauer-Verhaltenstests erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 9 von 9 Browsertests erfolgreich; Erstadmin,
Login/Logout, fremder Origin, Einladung, direkte Rollenprüfung, Sperrung,
Reset sowie bestehende responsive Arbeitsansichten geprüft
- Lokale Outbox in isolierten temporären Verzeichnissen geprüft
- Lokaler Produktionsserver der Browsertests über HTTP gestartet; Healthcheck
und Migration vor Start erfolgreich
- `bash -n start.sh`: erfolgreich
## Etappe 3
| Kriterium | Nachweis | Stand |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------- |
| Anlegen, Bearbeiten, Suchen, Filtern und Reload | Browserrundlauf mit Vorlage, Autosave, Quellen, Status, Suche und Neuladen | erfüllt am 21.10.2026 |
| Taxonomien unabhängig | Format einzeln sowie Themen und Abschnittskategorien mehrfach gespeichert und gefiltert | erfüllt am 21.10.2026 |
| Sicheres Markdown | Verhaltenstest und Browservorschau entfernen Skript/JavaScript, Formatierung bleibt erhalten | erfüllt am 21.10.2026 |
| Private Bilder | Signatur-/Größenprüfung; anonymer Direktrequest erhält keinen Dateiinhalt | erfüllt am 21.10.2026 |
| Sichtbare Speicherfehler | Actionfehler und Autosave-Fehlerstatus bleiben am Formular sichtbar | erfüllt am 21.10.2026 |
| Konflikterkennung | zwei Browserseiten; veraltete Revision erhält Konflikt und überschreibt die neue Fassung nicht | erfüllt am 21.10.2026 |
| Archiv und Wiederherstellung | Browserrundlauf blendet Piece aus und stellt den vorherigen Status wieder her | erfüllt am 21.10.2026 |
| Duplikat | Verhaltenstest und Browser prüfen unabhängige Kopie samt Zuordnungen und Bild | erfüllt am 21.10.2026 |
## Ausgeführte Prüfungen Etappe 3
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 13 Tests in 3 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 10 von 10 Browsertests erfolgreich
- Browserprüfung bei 390, 768, 960, 1280 und 1440 Pixeln in beiden Themes ohne
ungewollten horizontalen Überlauf
- Mobile Darkmode-Aufnahme des Piece-Editors visuell kontrolliert
- `bash -n start.sh` und `git diff --check`: erfolgreich
+71 -19
View File
@@ -36,18 +36,18 @@ spätere API dieselben Regeln verwenden.
Etappe 1 materialisiert nur den für echte Vorschau- und Seed-Daten benötigten
Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt.
| Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens, Sperren und technische Zugänge; Tokens nur gehasht |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status und Dauer; Quellen, Medien, private Recherche, Moderation, Präsentation und Vorlagen als getrennte Daten |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
| Bereich | Geplante Entitäten und Beziehungen |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
### Historien- und Kopierregel
@@ -63,11 +63,63 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
getrennt.
## Anmeldung und Rechte
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
`thekencrew`. Passwörter werden mit Argon2id gespeichert. Sitzungs-, Einladungs-
und Reset-Tokens entstehen aus kryptografischem Zufall; in SQLite liegt jeweils
nur ihr SHA-256-Hash. Der Browser erhält ausschließlich das rohe Sitzungstoken
in einem `HttpOnly`-, `SameSite=Lax`-Cookie.
`hooks.server.ts` löst die Sitzung für jeden Request auf. Die Datenbankabfrage
verbindet die Session immer mit dem aktuellen Nutzerzustand, sodass Sperren und
Rollenwechsel ohne verzögerte Token-Erneuerung wirken. Geschützte Routen
erfordern eine Anmeldung; Verwaltungs-Loads und jede Verwaltungs-Action prüfen
zusätzlich die Barkeeper-Rolle. Sperrung und Passwortwechsel löschen bestehende
Sessions transaktional.
Der Erstadmin benötigt sowohl eine leere Nutzertabelle als auch ein extern
konfiguriertes Setup-Token. Einladungs- und Reset-Links sind 24 Stunden gültig,
werden erst beim bestätigenden POST verbraucht und sind genau einmal nutzbar.
Fehlgeschlagene Loginversuche werden pro E-Mail-Schlüssel und Clientadresse in
einem Zeitfenster begrenzt, ohne das Konto dauerhaft zu sperren.
Mail verwendet einen Adapter: `local` schreibt geschützte Dateien in den
konfigurierten Outbox-Pfad, `smtp` nutzt Nodemailer nur nach expliziter Auswahl.
Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der
Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen.
## Inhalte, Markdown und Medien
Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen
erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständige
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
Zuordnungen. Archivieren merkt sich den vorherigen Arbeitsstatus für die
Wiederherstellung. Duplikate erhalten neue IDs und unabhängige Bilddateien.
Markdown wird roh gespeichert, mit Marked serverseitig gerendert und danach mit
sanitize-html auf eine kleine Allowlist reduziert. Bilder werden nicht über
Markdown eingebettet. Uploads akzeptieren höchstens 5 MB und nur anhand ihrer
Dateisignatur erkannte JPEG-, PNG-, WebP- oder GIF-Dateien. Interne Dateinamen
sind zufällige IDs; Originalnamen werden nie als Pfad verwendet. `/media/[id]`
prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`,
Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus.
## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf breiten Ansichten stehen Fundus und Ablauf nebeneinander. Auf schmalen
Ansichten werden sie über eine gut erreichbare Umschaltung einzeln gezeigt.
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere
Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht.
@@ -77,8 +129,8 @@ Fachabläufe werden jedoch nicht vorgetäuscht.
## Sicherheitsgrenzen ab den Folgeetappen
Authentifizierung und Autorisierung werden serverseitig an jeder Route und
Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt.
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen.
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Spätere
Netzwerkabrufe erhalten Schutz gegen interne Ziele, Größen-/Zeitlimits und
kontrollierte Redirects.
+91
View File
@@ -81,3 +81,94 @@ Vorschauseiten lesen echte persistente SQLite-Seed-Daten, bieten aber keine
funktionslosen Speichern- oder Live-Schaltflächen. Ein sichtbarer Hinweis
grenzt Gestaltungsvorschauen von den späteren Fachfunktionen ab. Damit wird
keine noch nicht implementierte Funktion als fertig dargestellt.
## D-007: Arbeitsansicht nach verfügbarem Platz
Die feste Desktop-Seitenleiste wird bis `52rem` verwendet und dann vollständig
durch mobile Kopf- und Fußnavigation ersetzt. Der Sendungsbau schaltet bereits
bis `72rem` von zwei Spalten auf eine bedienbare Pane-Umschaltung um. Dadurch
bleibt die Desktopnavigation in schmalen Fenstern erhalten, ohne Fundus und
Ablauf zusammenzudrücken.
Der Fundus erscheint auf dem Desktop als kompakte Liste und auf kleinen
Bildschirmen als Kartenfolge. Vorschau- und Schreibschutzinformationen sind in
einem nativen, tastaturbedienbaren `details`-Element gebündelt. Diese
Darstellungsentscheidung ändert weder Datenmodell noch Funktionsumfang.
## D-008: Widerrufbare Datenbanksitzungen und Argon2id
Sendewerk verwendet Datenbanksitzungen statt JWT. SvelteKit empfiehlt diesen
Ansatz, wenn sofortiger Widerruf wichtiger als eine zusätzliche Datenbankabfrage
ist. Ein zufälliges Sitzungstoken liegt nur als SHA-256-Hash in SQLite; der
Browser erhält es als `HttpOnly`-, `SameSite=Lax`-Cookie. Nutzerstatus und Rolle
werden bei jedem Request erneut verbunden. Lokal erlaubt eine explizite
`http:`-Origin Cookies ohne `Secure`, bei `https:` wird `Secure` gesetzt.
Passwörter hasht `@node-rs/argon2` 2.2.2 mit Argon2id und den dokumentierten
Paketparametern. Zufallstokens und SHA-256 stammen aus `node:crypto`; eigene
Kryptoprimitive werden nicht implementiert.
Quellen:
- <https://svelte.dev/docs/kit/auth>
- <https://svelte.dev/docs/kit/hooks>
- <https://github.com/napi-rs/node-rs/tree/main/packages/argon2>
## D-009: Form-Actions, geschlossenes Setup und Rollenprüfung
Alle Auth-Schreibvorgänge verwenden POST-Form-Actions beziehungsweise einen
POST-Endpunkt. SvelteKits gleichursprüngliche Formularprüfung bleibt aktiv. Der
Erstadmin erfordert ein mindestens 32 Zeichen langes Betreiber-Setup-Token und
eine transaktional geprüfte leere Nutzertabelle. Danach bleibt `/setup`
geschlossen. Barkeeper-Rechte werden in jeder Verwaltungsaktion erneut geprüft;
UI-Sichtbarkeit ist keine Sicherheitsgrenze.
Quelle: <https://svelte.dev/docs/kit/form-actions>
## D-010: Lokale Mail-Outbox als sichere Vorgabe
Der Mailmodus ist standardmäßig `local` und schreibt Einladungs-/Resetnachrichten
in einen konfigurierten persistenten Pfad. Dadurch wird lokal keine echte Mail
ungefragt versandt. Nur die explizite Auswahl `smtp` initialisiert Nodemailer;
bei SMTP sind Host und Absender verpflichtend. Outbox-Dateien sind private
Geheimnisträger und werden weder versioniert noch über eine Route ausgeliefert.
Nodemailer 10.1.7 unterstützt Node 20 oder neuer und ist MIT-0-lizenziert.
Quelle: <https://github.com/nodemailer/nodemailer>
## D-011: Form-Actions und optimistische Piece-Revisionen
Piece-Schreibvorgänge bleiben normale SvelteKit-Form-Actions und funktionieren
damit ohne clientseitiges JavaScript. `use:enhance` ergänzt den Editor um
verzögertes Autospeichern und sichtbare Zustände. Vor interner Navigation wird
eine ausstehende Änderung gespeichert; beim Verlassen der Seite greift die
Browserwarnung. Jede Mutation vergleicht eine erwartete ganzzahlige Revision in
derselben SQLite-Transaktion. Veraltete Änderungen liefern 409 und überschreiben
keine aktuelleren Inhalte oder Zuordnungen.
Quelle: <https://svelte.dev/docs/kit/form-actions>
## D-012: Markdown immer nach dem Parsen bereinigen
Marked 18.1.1 rendert die gespeicherten Markdown-Texte serverseitig. Marked
weist ausdrücklich darauf hin, dass seine HTML-Ausgabe nicht bereinigt ist.
Darum reduziert sanitize-html 2.18.1 das Ergebnis anschließend auf ausgewählte
Text-, Listen-, Code- und Linkelemente. Skripte, Bilder, Ereignisattribute,
relative Protokoll-URLs und andere Schemata werden nicht zugelassen.
Quellen:
- <https://github.com/markedjs/marked>
- <https://github.com/apostrophecms/apostrophe/tree/main/packages/sanitize-html>
## D-013: Private Rasterbilder außerhalb statischer Assets
Uploads liegen ausschließlich im konfigurierten privaten Uploadverzeichnis und
werden über zufällige interne Namen adressiert. file-type 22.2.0 erkennt den Typ
aus dem Dateiinhalt; Client-MIME und Originalname entscheiden nicht über Typ oder
Pfad. Zugelassen sind JPEG, PNG, WebP und GIF bis 5 MB. SVG und allgemeine
Dokumente bleiben ausgeschlossen. Eine authentifizierte ID-Route liefert Bilder
mit sicherheitsrelevanten Headern aus. Das Adapter-Requestlimit liegt mit 6 MB
knapp oberhalb des fachlichen Dateilimits.
Quelle: <https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload>
+48 -7
View File
@@ -2,8 +2,9 @@
## Aktueller Umfang
Etappe 1 ist abgeschlossen. Architektur, Gesamtdatenmodell, Entscheidungen und
lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen.
Etappe 1 bis Etappe 3 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung und lokaler Betriebsweg sind
dokumentiert. Etappe 4 wurde nicht begonnen.
## Umgesetzter Stand Etappe 1
@@ -13,6 +14,8 @@ lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen.
- persistentes Light-/Dark-/System-Theme
- responsive Navigation und gekennzeichnete Vorschauen für Fundus,
Sendungsbau und Live-Modus
- kompakte Desktop-Arbeitsansicht mit fester Seitenleiste, Fundusliste und
zweigeteiltem Sendungsbau; auf schmalen Ansichten Umschaltung statt Engpass
- Healthcheck, Produktionsstart und frühes Cloudron-Paket
- getrennte Unit-/Integrations- und Browser-Testdaten
@@ -20,12 +23,50 @@ Die Vorschauen lesen echte Daten aus SQLite. Suche und mobile Pane-Umschaltung
funktionieren lokal; redaktionelle Schreiboperationen bleiben bewusst den
späteren Etappen vorbehalten.
## Nicht Bestandteil dieser Etappe
Die abschließende Oberflächenüberarbeitung von Etappe 1 reduziert große
Seitenköpfe und verschachtelte Karten, bündelt den Schreibschutzhinweis
aufklappbar und hält Entwicklungsinformationen aus der Arbeitsoberfläche heraus.
Eingang und Archiv zeigen kurze Leerzustände, die Verwaltung trennt
Abschnittskategorien, Formate und Themen kompakt.
Anmeldung, Rollen, echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung,
Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und API.
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden
werden.
Die abschließende responsive Prüfung verdichtet Seitenleiste, Navigation und
Funduszeilen weiter. Mobile Funduskarten sind intern einspaltig; lange Titel,
Beschreibungen und Metadaten bleiben ohne feste Kartenhöhe sichtbar. Suche und
Statusfilter sind gleich hoch, und die Filtergruppe wird nur als Ganzes
umgebrochen.
## Noch nicht umgesetzt
Sendungsplanung, echte Live-Steuerung, Präsentationsfenster, Archivabschluss,
Freigaben, RSS und API. Die bestehenden Vorschauen dürfen nicht als
Implementierung dieser späteren Funktionen verstanden werden.
## Umgesetzter Stand Etappe 2
- einmalige Erstadmin-Einrichtung mit externem Betreiber-Token
- Login und POST-Logout mit widerrufbaren Datenbanksitzungen
- Rollen Barkeeper und Thekencrew mit serverseitiger Prüfung
- Einladungen und Passwortreset über befristete, einmalige Links
- Kontensperrung mit sofortigem Entzug bestehender Sitzungen
- zeitfensterbasiertes Login-Rate-Limit ohne dauerhafte Kontensperre
- lokale Mail-Outbox als Vorgabe und explizit konfigurierbarer SMTP-Versand
- responsive Authentifizierungs- und Kontenverwaltungsoberflächen
## Umgesetzter Stand Etappe 3
- persistente Piece-Neuanlage aus leerem Entwurf oder drei redaktionellen Vorlagen
- Schnellaufnahme von Gedanken und HTTP-/HTTPS-Links im Eingang
- vollständiger Editor für Quellen, Dauer, Status und getrennte Einleitung,
Recherche, Moderation, Überleitung und Präsentation
- serverseitig bereinigte Markdown-Vorschau
- unabhängige Themen, Formate und Abschnittskategorien mit Barkeeper-Verwaltung
- serverseitige Suche und Filter nach Status, Format, Thema und Abschnitt
- Autospeichern mit sichtbarem Zustand und Warten vor interner Navigation
- optimistische Revisionen verhindern unbemerktes Überschreiben
- Duplizieren einschließlich Quellen, Taxonomie und Bildern
- Archivieren und Wiederherstellen in den vorherigen Arbeitsstatus
- private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung
- erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen
## Bekannte offene Betriebsprüfung
+58
View File
@@ -0,0 +1,58 @@
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
email_normalized TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('barkeeper', 'thekencrew')),
locked_at_utc TEXT,
password_changed_at_utc TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX sessions_user_idx ON sessions(user_id);
CREATE INDEX sessions_expiry_idx ON sessions(expires_at_utc);
CREATE TABLE invitations (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
email_normalized TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('barkeeper', 'thekencrew')),
token_hash TEXT NOT NULL UNIQUE,
created_by_user_id TEXT NOT NULL REFERENCES users(id),
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
accepted_at_utc TEXT,
revoked_at_utc TEXT
);
CREATE INDEX invitations_email_idx ON invitations(email_normalized, created_at_utc);
CREATE TABLE password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
consumed_at_utc TEXT
);
CREATE INDEX password_resets_user_idx ON password_reset_tokens(user_id, created_at_utc);
CREATE TABLE login_attempts (
id TEXT PRIMARY KEY,
email_key TEXT NOT NULL,
address_key TEXT NOT NULL,
attempted_at_utc TEXT NOT NULL
);
CREATE INDEX login_attempts_email_idx ON login_attempts(email_key, attempted_at_utc);
CREATE INDEX login_attempts_address_idx ON login_attempts(address_key, attempted_at_utc);
+23
View File
@@ -0,0 +1,23 @@
ALTER TABLE pieces ADD COLUMN research_text TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1);
ALTER TABLE pieces ADD COLUMN archived_from_status TEXT CHECK (
archived_from_status IS NULL OR archived_from_status IN ('inbox', 'preparing', 'ready')
);
CREATE TABLE piece_media (
id TEXT PRIMARY KEY,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
storage_name TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/webp', 'image/gif')),
byte_size INTEGER NOT NULL CHECK (byte_size > 0 AND byte_size <= 5242880),
alt_text TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
created_at_utc TEXT NOT NULL,
UNIQUE (piece_id, position)
);
CREATE INDEX idx_piece_media_piece ON piece_media(piece_id, position);
CREATE INDEX idx_piece_topics_topic ON piece_topics(topic_id, piece_id);
CREATE INDEX idx_piece_categories_category ON piece_category_suggestions(category_id, piece_id);
CREATE INDEX idx_piece_sources_piece ON piece_sources(piece_id, position);
+537 -10
View File
@@ -1,14 +1,19 @@
{
"name": "sendewerk",
"version": "0.1.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.1.0",
"version": "0.3.0",
"dependencies": {
"better-sqlite3": "^13.0.3"
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"file-type": "^22.2.0",
"marked": "^18.1.1",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -18,6 +23,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
@@ -35,6 +41,16 @@
"node": ">=22.17 <25"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cacheable/memory": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
@@ -327,6 +343,238 @@
"dev": true,
"license": "MIT"
},
"node_modules/@node-rs/argon2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
"integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@node-rs/argon2-android-arm-eabi": "2.2.2",
"@node-rs/argon2-android-arm64": "2.2.2",
"@node-rs/argon2-darwin-arm64": "2.2.2",
"@node-rs/argon2-darwin-x64": "2.2.2",
"@node-rs/argon2-freebsd-x64": "2.2.2",
"@node-rs/argon2-linux-arm-gnueabihf": "2.2.2",
"@node-rs/argon2-linux-arm64-gnu": "2.2.2",
"@node-rs/argon2-linux-arm64-musl": "2.2.2",
"@node-rs/argon2-linux-x64-gnu": "2.2.2",
"@node-rs/argon2-linux-x64-musl": "2.2.2",
"@node-rs/argon2-win32-arm64-msvc": "2.2.2",
"@node-rs/argon2-win32-ia32-msvc": "2.2.2",
"@node-rs/argon2-win32-x64-msvc": "2.2.2"
}
},
"node_modules/@node-rs/argon2-android-arm-eabi": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz",
"integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-android-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz",
"integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz",
"integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz",
"integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-freebsd-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz",
"integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz",
"integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz",
"integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-musl": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz",
"integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-gnu": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz",
"integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-musl": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz",
"integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz",
"integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz",
"integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-x64-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz",
"integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@oxc-project/types": {
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
@@ -717,6 +965,29 @@
"vite": "^8.0.0-beta.7 || ^8.0.0"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz",
"integrity": "sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.2"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -776,6 +1047,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.2.tgz",
"integrity": "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^12.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
@@ -1241,11 +1522,16 @@
"node": ">=4"
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1293,6 +1579,85 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -1304,7 +1669,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1625,6 +1989,24 @@
"flat-cache": "^6.1.23"
}
},
"node_modules/file-type": {
"version": "22.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.2.0.tgz",
"integrity": "sha512-XmEVJsRpQMVYC+sWFW+FKAVo2P7/lPctDdWH4UQ1r6oxhdrTobnowdM3z+ZJ93a4J8D3yDpxX0bEvRuFev0vUQ==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.5.1",
"strtok3": "^10.5.0",
"token-types": "^6.1.2",
"uint8array-extras": "^1.6.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -1722,6 +2104,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1765,6 +2189,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1813,6 +2246,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/launder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",
"integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.23"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2131,6 +2573,18 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/marked": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",
"integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -2171,14 +2625,12 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2209,6 +2661,15 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
@@ -2273,6 +2734,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2297,7 +2764,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -2346,7 +2812,6 @@
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2610,6 +3075,23 @@
"node": ">=6"
}
},
"node_modules/sanitize-html": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.1.0",
"launder": "^1.7.2",
"parse-srcset": "^1.0.2",
"postcss": "^8.5.27"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -2665,7 +3147,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2678,6 +3159,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/strtok3": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz",
"integrity": "sha512-BkdixFFUt7r7LsX6aGNPVyYMLjW+M1USLlKPNbZsrZEmLFh5tGqhdfckKo6xgsZIJqdDD0CZGkeCJJwNWQIKQA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/svelte": {
"version": "5.57.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
@@ -2856,6 +3353,24 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -2930,6 +3445,18 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uint8array-extras": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz",
"integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+11 -5
View File
@@ -1,15 +1,15 @@
{
"name": "sendewerk",
"private": true,
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"engines": {
"node": ">=22.17 <25"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"dev": "npm run db:migrate && node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js dev",
"build": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js build",
"preview": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js preview",
"start": "npm run db:migrate && node --env-file-if-exists=.env build",
"setup": "npm run db:migrate && npm run db:seed",
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs",
@@ -24,7 +24,12 @@
"test:e2e": "playwright test"
},
"dependencies": {
"better-sqlite3": "^13.0.3"
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"file-type": "^22.2.0",
"marked": "^18.1.1",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -34,6 +39,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
+10 -1
View File
@@ -3,7 +3,11 @@ import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const dataDirectory = mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-'));
const dataDirectory =
process.env.SENDEWERK_E2E_DATA_DIR || mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-'));
const mailOutboxDirectory = path.join(dataDirectory, 'mail-outbox');
process.env.SENDEWERK_E2E_DATA_DIR = dataDirectory;
process.env.SENDEWERK_E2E_OUTBOX_DIR = mailOutboxDirectory;
export default defineConfig({
testDir: 'tests',
@@ -18,6 +22,11 @@ export default defineConfig({
env: {
SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'),
SENDEWERK_UPLOAD_DIR: path.join(dataDirectory, 'uploads'),
SENDEWERK_MAIL_OUTBOX_DIR: mailOutboxDirectory,
SENDEWERK_MAIL_TRANSPORT: 'local',
SENDEWERK_APP_ORIGIN: 'http://127.0.0.1:4173',
SENDEWERK_SETUP_TOKEN: 'e2e-only-setup-token-32-characters',
ORIGIN: 'http://127.0.0.1:4173',
HOST: '127.0.0.1',
PORT: '4173'
}
+55 -3
View File
@@ -124,9 +124,49 @@ export function seed(databasePath = getPaths().database) {
'Pixel als bewusste Entscheidung.',
timestamp,
timestamp
],
[
'piece-inbox-link',
'Indie-Showcase nachhören',
'Ein Fundstück aus dem letzten Showcase, das noch eingeordnet werden muss.',
'format-find',
'inbox',
0,
'',
'',
'',
'',
timestamp,
timestamp
],
[
'piece-archive-demo',
'Controller mit eingebautem Bildschirm',
'Ein früheres Fundstück, das bewusst im Archiv liegt.',
'format-find',
'archived',
300,
'',
'',
'',
'',
timestamp,
timestamp
]
]
);
database
.prepare('UPDATE pieces SET research_text = ? WHERE id = ?')
.run(
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten',
'piece-console'
);
database
.prepare('UPDATE pieces SET presentation_text = ? WHERE id = ?')
.run(
'## Weniger Leistung, mehr Idee\n\nDie Kurbel ist Teil des Spiels, nicht nur Dekoration.',
'piece-console'
);
database.prepare('DELETE FROM piece_topics WHERE piece_id LIKE ?').run('piece-%');
const addTopic = database.prepare(
@@ -137,7 +177,9 @@ export function seed(databasePath = getPaths().database) {
['piece-effects', 'topic-film'],
['piece-quiz', 'topic-gaming'],
['piece-pixels', 'topic-gaming'],
['piece-pixels', 'topic-internet']
['piece-pixels', 'topic-internet'],
['piece-inbox-link', 'topic-gaming'],
['piece-archive-demo', 'topic-gaming']
])
addTopic.run(...values);
@@ -149,7 +191,9 @@ export function seed(databasePath = getPaths().database) {
['piece-console', 'cat-jc'],
['piece-effects', 'cat-reaction'],
['piece-quiz', 'cat-jc'],
['piece-pixels', 'cat-reaction']
['piece-pixels', 'cat-reaction'],
['piece-inbox-link', 'cat-reaction'],
['piece-archive-demo', 'cat-reaction']
])
addCategory.run(...values);
@@ -173,7 +217,15 @@ export function seed(databasePath = getPaths().database) {
95,
0
],
['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0]
['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0],
[
'source-inbox-showcase',
'piece-inbox-link',
'Showcase-Aufzeichnung',
'https://example.com/showcase',
120,
0
]
]
);
+1227 -237
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -1,5 +1,14 @@
import type { AuthUser } from '#lib/server/auth.js';
declare global {
namespace App {}
namespace App {
interface Locals {
user: AuthUser | null;
}
interface PageData {
user?: AuthUser | null;
}
}
}
export {};
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
const publicPaths = new Set([
'/',
'/healthz',
'/login',
'/setup',
'/einladung',
'/passwort-vergessen',
'/passwort-zuruecksetzen'
]);
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies);
if (!publicPaths.has(event.url.pathname) && !event.locals.user) {
redirect(303, hasUsers() ? '/login' : '/setup');
}
const response = await resolve(event);
response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store');
}
return response;
};
@@ -0,0 +1,7 @@
<script lang="ts">
let { html }: { html: string } = $props();
</script>
<!-- html is produced exclusively by the allowlist sanitizer in $lib/server/markdown.ts. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="markdown-preview">{@html html}</div>
+10 -9
View File
@@ -2,12 +2,13 @@
let { area }: { area: string } = $props();
</script>
<aside class="preview-notice" aria-label="Hinweis zur Gestaltungsvorschau">
<span class="preview-dot" aria-hidden="true"></span>
<div>
<strong>Gestaltungsvorschau: {area}</strong>
<p>
In Etappe 1 schreibgeschützt. Die gezeigten Inhalte stammen aus der lokalen SQLite-Datenbank.
</p>
</div>
</aside>
<details class="preview-notice">
<summary>
<span class="preview-dot" aria-hidden="true"></span>
<strong>Vorschau · {area}</strong>
<span class="preview-chevron" aria-hidden="true">⌄</span>
</summary>
<p>
Diese Ansicht ist schreibgeschützt. Bearbeiten und Speichern folgen in einer späteren Etappe.
</p>
</details>
+255
View File
@@ -0,0 +1,255 @@
import Database from 'better-sqlite3';
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Cookies } from '@sveltejs/kit';
const temporaryDirectories: string[] = [];
let directory: string;
let databasePath: string;
function testCookies(): Cookies {
const values = new Map<string, string>();
return {
get: (name: string) => values.get(name),
getAll: () => Array.from(values, ([name, value]) => ({ name, value })),
set: (name: string, value: string) => {
values.set(name, value);
},
delete: (name: string) => {
values.delete(name);
},
serialize: () => ''
} as unknown as Cookies;
}
function tokenFromLatestMail(): string {
const outbox = path.join(directory, 'outbox');
const files = readdirSync(outbox).sort();
const message = JSON.parse(readFileSync(path.join(outbox, files.at(-1)!), 'utf8')) as {
text: string;
};
const match = message.text.match(/[?&]token=([^\s]+)/);
if (!match) throw new Error('Testmail enthält keinen Token.');
return decodeURIComponent(match[1]);
}
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-auth-test-'));
temporaryDirectories.push(directory);
databasePath = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_DB_PATH = databasePath;
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
process.env.SENDEWERK_MAIL_OUTBOX_DIR = path.join(directory, 'outbox');
process.env.SENDEWERK_MAIL_TRANSPORT = 'local';
process.env.SENDEWERK_APP_ORIGIN = 'http://localhost:5173';
process.env.SENDEWERK_SETUP_TOKEN = 'a'.repeat(32);
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(async () => {
const { closeDatabase } = await import('./database.js');
closeDatabase();
for (const name of [
'SENDEWERK_DB_PATH',
'SENDEWERK_UPLOAD_DIR',
'SENDEWERK_MAIL_OUTBOX_DIR',
'SENDEWERK_MAIL_TRANSPORT',
'SENDEWERK_MAIL_FROM',
'SENDEWERK_SMTP_HOST',
'SENDEWERK_APP_ORIGIN',
'SENDEWERK_SETUP_TOKEN'
]) {
delete process.env[name];
}
for (const temporaryDirectory of temporaryDirectories.splice(0)) {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
});
describe('authentication', () => {
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
const { createFirstAdmin } = await import('./auth.js');
const password = 'Ein-sehr-langes-Testpasswort';
const user = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'Bar@example.org',
displayName: 'Erster Barkeeper',
password
});
const database = new Database(databasePath, { readonly: true });
const row = database.prepare('SELECT * FROM users').get() as {
role: string;
email_normalized: string;
password_hash: string;
};
database.close();
expect(user.role).toBe('barkeeper');
expect(row.email_normalized).toBe('bar@example.org');
expect(row.password_hash).not.toContain(password);
await expect(
createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'second@example.org',
displayName: 'Zweiter Admin',
password
})
).rejects.toThrow('SETUP_CLOSED');
});
it('accepts an invitation only once and rejects an expired invitation', async () => {
const { acceptInvitation, createFirstAdmin, inviteUser } = await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
await inviteUser(admin, 'crew@example.org', 'thekencrew');
const firstToken = tokenFromLatestMail();
const crew = await acceptInvitation({
token: firstToken,
displayName: 'Crew',
password: 'Crew-Passwort-1234'
});
expect(crew.role).toBe('thekencrew');
await expect(
acceptInvitation({
token: firstToken,
displayName: 'Crew',
password: 'Crew-Passwort-1234'
})
).rejects.toThrow('TOKEN_INVALID');
await inviteUser(admin, 'late@example.org', 'thekencrew');
const expiredToken = tokenFromLatestMail();
const database = new Database(databasePath);
database.prepare("UPDATE invitations SET expires_at_utc = '2000-01-01T00:00:00.000Z'").run();
database.close();
await expect(
acceptInvitation({
token: expiredToken,
displayName: 'Zu spät',
password: 'Crew-Passwort-1234'
})
).rejects.toThrow('TOKEN_INVALID');
});
it('revokes active sessions immediately when a user is locked', async () => {
const { createFirstAdmin, createSession, getSessionUser, setUserLocked, SESSION_COOKIE } =
await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
const database = new Database(databasePath);
const crewId = crypto.randomUUID();
database
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
SELECT ?, 'crew@example.org', 'crew@example.org', 'Crew', password_hash, 'thekencrew',
password_changed_at_utc, created_at_utc, updated_at_utc FROM users WHERE id = ?`
)
.run(crewId, admin.id);
database.close();
const cookies = testCookies();
createSession(crewId, cookies);
const token = cookies.get(SESSION_COOKIE);
expect(getSessionUser(token)?.id).toBe(crewId);
setUserLocked(admin, crewId, true);
expect(getSessionUser(token)).toBeNull();
});
it('uses password reset links once and invalidates prior sessions', async () => {
const {
authenticate,
createFirstAdmin,
createSession,
getSessionUser,
requestPasswordReset,
resetPassword,
SESSION_COOKIE
} = await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Altes-Passwort-1234'
});
const cookies = testCookies();
createSession(admin.id, cookies);
const sessionToken = cookies.get(SESSION_COOKIE);
await requestPasswordReset(admin.email);
const expiredToken = tokenFromLatestMail();
const database = new Database(databasePath);
database
.prepare("UPDATE password_reset_tokens SET expires_at_utc = '2000-01-01T00:00:00.000Z'")
.run();
database.close();
await expect(resetPassword(expiredToken, 'Neues-Passwort-1234')).rejects.toThrow(
'TOKEN_INVALID'
);
await requestPasswordReset(admin.email);
const resetToken = tokenFromLatestMail();
await resetPassword(resetToken, 'Neues-Passwort-1234');
expect(getSessionUser(sessionToken)).toBeNull();
expect(await authenticate(admin.email, 'Altes-Passwort-1234')).toBeNull();
expect(await authenticate(admin.email, 'Neues-Passwort-1234')).not.toBeNull();
await expect(resetPassword(resetToken, 'Noch-ein-Passwort-1234')).rejects.toThrow(
'TOKEN_INVALID'
);
});
it('rate limits repeated failed login attempts without locking the account', async () => {
const { authenticate, createFirstAdmin, loginIsRateLimited, reserveLoginAttempt } =
await import('./auth.js');
await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
for (let attempt = 0; attempt < 5; attempt += 1) {
expect(reserveLoginAttempt('admin@example.org', '127.0.0.1')).toBe(true);
expect(await authenticate('admin@example.org', 'falsch')).toBeNull();
}
expect(loginIsRateLimited('admin@example.org', '127.0.0.1')).toBe(true);
const database = new Database(databasePath, { readonly: true });
const user = database.prepare('SELECT locked_at_utc FROM users').get() as {
locked_at_utc: string | null;
};
database.close();
expect(user.locked_at_utc).toBeNull();
});
it('denies administrative operations to Thekencrew', async () => {
const { requireBarkeeper } = await import('./auth.js');
expect(() =>
requireBarkeeper({
id: 'crew',
email: 'crew@example.org',
displayName: 'Crew',
role: 'thekencrew',
lockedAtUtc: null
})
).toThrow();
});
it('never falls back to local delivery when SMTP was selected', async () => {
process.env.SENDEWERK_MAIL_TRANSPORT = 'smtp';
process.env.SENDEWERK_MAIL_FROM = '';
process.env.SENDEWERK_SMTP_HOST = '';
const { sendMail } = await import('./mail.js');
await expect(
sendMail({ to: 'test@example.org', subject: 'Test', text: 'Keine echte Nachricht' })
).rejects.toThrow('SMTP-Konfiguration');
});
});
+610
View File
@@ -0,0 +1,610 @@
import { hash, verify } from '@node-rs/argon2';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { error } from '@sveltejs/kit';
import type { Cookies } from '@sveltejs/kit';
import { getAuthConfig } from './config.js';
import { getDatabase } from './database.js';
import { sendMail } from './mail.js';
export const SESSION_COOKIE = 'sendewerk_session';
export const INVITATION_COOKIE = 'sendewerk_invitation';
export const RESET_COOKIE = 'sendewerk_password_reset';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
const LINK_DURATION_MS = 24 * 60 * 60 * 1000;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const EMAIL_ATTEMPT_LIMIT = 5;
const ADDRESS_ATTEMPT_LIMIT = 20;
const RESET_WINDOW_MS = 60 * 60 * 1000;
const RESET_EMAIL_LIMIT = 3;
const RESET_ADDRESS_LIMIT = 10;
const PASSWORD_OPTIONS = {
algorithm: 2,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
outputLen: 32
} as const;
export type Role = 'barkeeper' | 'thekencrew';
export type AuthUser = {
id: string;
email: string;
displayName: string;
role: Role;
lockedAtUtc: string | null;
};
type UserRow = {
id: string;
email: string;
display_name: string;
password_hash: string;
role: Role;
locked_at_utc: string | null;
};
type InvitationRow = {
id: string;
email: string;
role: Role;
expires_at_utc: string;
accepted_at_utc: string | null;
revoked_at_utc: string | null;
};
function toUser(row: UserRow): AuthUser {
return {
id: row.id,
email: row.email,
displayName: row.display_name,
role: row.role,
lockedAtUtc: row.locked_at_utc
};
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function validEmail(email: string): boolean {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function validDisplayName(name: string): boolean {
return name.trim().length >= 2 && name.trim().length <= 80;
}
export function validPassword(password: string): boolean {
return password.length >= 12 && Buffer.byteLength(password, 'utf8') <= 256;
}
export function isRole(value: string): value is Role {
return value === 'barkeeper' || value === 'thekencrew';
}
export async function hashPassword(password: string): Promise<string> {
return hash(password, PASSWORD_OPTIONS);
}
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return verify(passwordHash, password, PASSWORD_OPTIONS);
}
const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex'));
function createToken(): string {
return randomBytes(32).toString('base64url');
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function nowUtc(): string {
return new Date().toISOString();
}
function futureUtc(durationMs: number): string {
return new Date(Date.now() + durationMs).toISOString();
}
export function hasUsers(): boolean {
const row = getDatabase().prepare('SELECT count(*) AS count FROM users').get() as {
count: number;
};
return row.count > 0;
}
export function setupIsConfigured(): boolean {
return getAuthConfig().setupToken.length >= 32;
}
function setupTokenMatches(candidate: string): boolean {
const configured = getAuthConfig().setupToken;
if (configured.length < 32 || candidate.length !== configured.length) return false;
return timingSafeEqual(Buffer.from(candidate), Buffer.from(configured));
}
export async function createFirstAdmin(input: {
setupToken: string;
email: string;
displayName: string;
password: string;
}): Promise<AuthUser> {
if (!setupTokenMatches(input.setupToken)) throw new Error('SETUP_TOKEN_INVALID');
const email = input.email.trim();
const emailNormalized = normalizeEmail(email);
const displayName = input.displayName.trim();
if (!validEmail(email) || !validDisplayName(displayName) || !validPassword(input.password)) {
throw new Error('VALIDATION');
}
const passwordHash = await hashPassword(input.password);
const database = getDatabase();
const create = database.transaction(() => {
const count = database.prepare('SELECT count(*) AS count FROM users').get() as {
count: number;
};
if (count.count !== 0) throw new Error('SETUP_CLOSED');
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO users (
id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, 'barkeeper', ?, ?, ?)`
)
.run(id, email, emailNormalized, displayName, passwordHash, timestamp, timestamp, timestamp);
return { id, email, displayName, role: 'barkeeper', lockedAtUtc: null } satisfies AuthUser;
});
return create();
}
function sessionCookieOptions(expires?: Date) {
return {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
secure: getAuthConfig().secureCookies,
expires
};
}
function linkCookieOptions() {
return {
path: '/',
httpOnly: true,
sameSite: 'strict' as const,
secure: getAuthConfig().secureCookies,
maxAge: 10 * 60
};
}
export function storeLinkToken(cookies: Cookies, name: string, token: string): void {
cookies.set(name, token, linkCookieOptions());
}
export function readLinkToken(cookies: Cookies, name: string): string {
return cookies.get(name) || '';
}
export function deleteLinkToken(cookies: Cookies, name: string): void {
cookies.delete(name, { ...linkCookieOptions(), maxAge: 0 });
}
export function createSession(userId: string, cookies: Cookies): void {
const token = createToken();
const expires = new Date(Date.now() + SESSION_DURATION_MS);
getDatabase()
.prepare(
'INSERT INTO sessions (token_hash, user_id, created_at_utc, expires_at_utc) VALUES (?, ?, ?, ?)'
)
.run(hashToken(token), userId, nowUtc(), expires.toISOString());
cookies.set(SESSION_COOKIE, token, sessionCookieOptions(expires));
}
export function deleteSession(cookies: Cookies): void {
const token = cookies.get(SESSION_COOKIE);
if (token)
getDatabase().prepare('DELETE FROM sessions WHERE token_hash = ?').run(hashToken(token));
cookies.delete(SESSION_COOKIE, sessionCookieOptions());
}
export function getSessionUser(token: string | undefined): AuthUser | null {
if (!token) return null;
const database = getDatabase();
const row = database
.prepare(
`SELECT u.id, u.email, u.display_name, u.password_hash, u.role, u.locked_at_utc
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at_utc > ? AND u.locked_at_utc IS NULL`
)
.get(hashToken(token), nowUtc()) as UserRow | undefined;
if (!row) return null;
return toUser(row);
}
function rateKey(value: string): string {
return hashToken(value || 'unknown');
}
export function loginIsRateLimited(email: string, address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - LOGIN_WINDOW_MS).toISOString();
database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since);
const emailCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?'
)
.get(rateKey(normalizeEmail(email)), since) as { count: number };
const addressCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?'
)
.get(rateKey(address), since) as { count: number };
return emailCount.count >= EMAIL_ATTEMPT_LIMIT || addressCount.count >= ADDRESS_ATTEMPT_LIMIT;
}
export function reserveLoginAttempt(email: string, address: string): boolean {
const database = getDatabase();
const reserve = database.transaction(() => {
if (loginIsRateLimited(email, address)) return false;
database
.prepare(
'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)'
)
.run(randomUUID(), rateKey(normalizeEmail(email)), rateKey(address), nowUtc());
return true;
});
return reserve();
}
export async function authenticate(email: string, password: string): Promise<AuthUser | null> {
const database = getDatabase();
const normalized = normalizeEmail(email);
const row = database
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users WHERE email_normalized = ?`
)
.get(normalized) as UserRow | undefined;
const matches = row
? await verifyPassword(row.password_hash, password)
: await verifyPassword(await dummyPasswordHash, password);
if (!row || !matches || row.locked_at_utc) {
return null;
}
database.prepare('DELETE FROM login_attempts WHERE email_key = ?').run(rateKey(normalized));
return toUser(row);
}
export function requireUser(user: AuthUser | null): AuthUser {
if (!user) error(401, 'Anmeldung erforderlich');
return user;
}
export function requireBarkeeper(user: AuthUser | null): AuthUser {
const authenticated = requireUser(user);
if (authenticated.role !== 'barkeeper') error(403, 'Diese Aktion erfordert Barkeeper-Rechte.');
return authenticated;
}
function requireCurrentBarkeeper(actor: AuthUser): AuthUser {
const row = getDatabase()
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users WHERE id = ? AND role = 'barkeeper' AND locked_at_utc IS NULL`
)
.get(actor.id) as UserRow | undefined;
if (!row) error(403, 'Diese Aktion erfordert aktive Barkeeper-Rechte.');
return toUser(row);
}
export function listUsers(): AuthUser[] {
const rows = getDatabase()
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users ORDER BY display_name COLLATE NOCASE`
)
.all() as UserRow[];
return rows.map(toUser);
}
export function listOpenInvitations() {
return getDatabase()
.prepare(
`SELECT id, email, role, expires_at_utc
FROM invitations
WHERE accepted_at_utc IS NULL AND revoked_at_utc IS NULL AND expires_at_utc > ?
ORDER BY created_at_utc DESC`
)
.all(nowUtc()) as Array<{ id: string; email: string; role: Role; expires_at_utc: string }>;
}
export async function inviteUser(
actor: AuthUser,
emailValue: string,
role: Role
): Promise<'local' | 'smtp'> {
actor = requireCurrentBarkeeper(actor);
const email = emailValue.trim();
const normalized = normalizeEmail(email);
if (!validEmail(email) || !isRole(role)) throw new Error('VALIDATION');
const database = getDatabase();
if (database.prepare('SELECT 1 FROM users WHERE email_normalized = ?').get(normalized)) {
throw new Error('USER_EXISTS');
}
const token = createToken();
const id = randomUUID();
const timestamp = nowUtc();
const expiresAt = futureUtc(LINK_DURATION_MS);
const store = database.transaction(() => {
database
.prepare(
`UPDATE invitations SET revoked_at_utc = ?
WHERE email_normalized = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL`
)
.run(timestamp, normalized);
database
.prepare(
`INSERT INTO invitations (
id, email, email_normalized, role, token_hash, created_by_user_id,
created_at_utc, expires_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(id, email, normalized, role, hashToken(token), actor.id, timestamp, expiresAt);
});
store();
const link = new URL(
`/einladung?token=${encodeURIComponent(token)}`,
getAuthConfig().origin
).toString();
try {
return await sendMail({
to: email,
subject: 'Einladung zu Sendewerk',
text: `Du wurdest zu Sendewerk eingeladen. Der Link ist 24 Stunden gültig und einmalig nutzbar:\n\n${link}`
});
} catch (mailError) {
database.prepare('UPDATE invitations SET revoked_at_utc = ? WHERE id = ?').run(nowUtc(), id);
throw mailError;
}
}
export function getInvitation(token: string): InvitationRow | null {
if (!token || token.length > 100) return null;
return (
(getDatabase()
.prepare(
`SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc
FROM invitations WHERE token_hash = ?`
)
.get(hashToken(token)) as InvitationRow | undefined) ?? null
);
}
export async function acceptInvitation(input: {
token: string;
displayName: string;
password: string;
}): Promise<AuthUser> {
const invitation = getInvitation(input.token);
const displayName = input.displayName.trim();
if (
!invitation ||
invitation.accepted_at_utc ||
invitation.revoked_at_utc ||
invitation.expires_at_utc <= nowUtc()
) {
throw new Error('TOKEN_INVALID');
}
if (!validDisplayName(displayName) || !validPassword(input.password))
throw new Error('VALIDATION');
const passwordHash = await hashPassword(input.password);
const database = getDatabase();
const accept = database.transaction(() => {
const current = database
.prepare(
`SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc
FROM invitations WHERE id = ?`
)
.get(invitation.id) as InvitationRow;
if (current.accepted_at_utc || current.revoked_at_utc || current.expires_at_utc <= nowUtc()) {
throw new Error('TOKEN_INVALID');
}
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO users (
id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
current.email,
normalizeEmail(current.email),
displayName,
passwordHash,
current.role,
timestamp,
timestamp,
timestamp
);
database
.prepare('UPDATE invitations SET accepted_at_utc = ? WHERE id = ?')
.run(timestamp, current.id);
return {
id,
email: current.email,
displayName,
role: current.role,
lockedAtUtc: null
} satisfies AuthUser;
});
return accept();
}
export function reservePasswordResetRequest(email: string, address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - RESET_WINDOW_MS).toISOString();
const emailKey = rateKey(`reset-email:${normalizeEmail(email)}`);
const addressKey = rateKey(`reset-address:${address}`);
const reserve = database.transaction(() => {
database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since);
const emailCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?'
)
.get(emailKey, since) as { count: number };
const addressCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?'
)
.get(addressKey, since) as { count: number };
if (emailCount.count >= RESET_EMAIL_LIMIT || addressCount.count >= RESET_ADDRESS_LIMIT)
return false;
database
.prepare(
'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)'
)
.run(randomUUID(), emailKey, addressKey, nowUtc());
return true;
});
return reserve();
}
export async function requestPasswordReset(emailValue: string): Promise<void> {
const normalized = normalizeEmail(emailValue);
const database = getDatabase();
const row = database
.prepare('SELECT id, email FROM users WHERE email_normalized = ?')
.get(normalized) as { id: string; email: string } | undefined;
if (!row) return;
const token = createToken();
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO password_reset_tokens
(id, user_id, token_hash, created_at_utc, expires_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(id, row.id, hashToken(token), timestamp, futureUtc(LINK_DURATION_MS));
const link = new URL(
`/passwort-zuruecksetzen?token=${encodeURIComponent(token)}`,
getAuthConfig().origin
).toString();
try {
await sendMail({
to: row.email,
subject: 'Sendewerk-Passwort zurücksetzen',
text: `Mit diesem 24 Stunden gültigen, einmaligen Link kannst du dein Passwort zurücksetzen:\n\n${link}`
});
} catch (mailError) {
database.prepare('DELETE FROM password_reset_tokens WHERE id = ?').run(id);
throw mailError;
}
}
export function passwordResetIsValid(token: string): boolean {
if (!token || token.length > 100) return false;
return Boolean(
getDatabase()
.prepare(
`SELECT 1 FROM password_reset_tokens
WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?`
)
.get(hashToken(token), nowUtc())
);
}
export async function resetPassword(token: string, password: string): Promise<void> {
if (!passwordResetIsValid(token)) throw new Error('TOKEN_INVALID');
if (!validPassword(password)) throw new Error('VALIDATION');
const passwordHash = await hashPassword(password);
const database = getDatabase();
const reset = database.transaction(() => {
const row = database
.prepare(
`SELECT id, user_id FROM password_reset_tokens
WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?`
)
.get(hashToken(token), nowUtc()) as { id: string; user_id: string } | undefined;
if (!row) throw new Error('TOKEN_INVALID');
const timestamp = nowUtc();
database
.prepare(
`UPDATE users SET password_hash = ?, password_changed_at_utc = ?, updated_at_utc = ?
WHERE id = ?`
)
.run(passwordHash, timestamp, timestamp, row.user_id);
database
.prepare(
'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
)
.run(timestamp, row.user_id);
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id);
});
reset();
}
function activeBarkeeperCount(): number {
const row = getDatabase()
.prepare(
"SELECT count(*) AS count FROM users WHERE role = 'barkeeper' AND locked_at_utc IS NULL"
)
.get() as { count: number };
return row.count;
}
export function setUserLocked(actor: AuthUser, userId: string, locked: boolean): void {
actor = requireCurrentBarkeeper(actor);
const database = getDatabase();
const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as
{ role: Role; locked_at_utc: string | null } | undefined;
if (!row) throw new Error('NOT_FOUND');
if (locked && actor.id === userId) throw new Error('SELF_LOCK');
if (locked && row.role === 'barkeeper' && !row.locked_at_utc && activeBarkeeperCount() <= 1) {
throw new Error('LAST_BARKEEPER');
}
const update = database.transaction(() => {
database
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
.run(locked ? nowUtc() : null, nowUtc(), userId);
if (locked) database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
});
update();
}
export function setUserRole(actor: AuthUser, userId: string, role: Role): void {
requireCurrentBarkeeper(actor);
if (!isRole(role)) throw new Error('VALIDATION');
const database = getDatabase();
const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as
{ role: Role; locked_at_utc: string | null } | undefined;
if (!row) throw new Error('NOT_FOUND');
if (
row.role === 'barkeeper' &&
role === 'thekencrew' &&
!row.locked_at_utc &&
activeBarkeeperCount() <= 1
) {
throw new Error('LAST_BARKEEPER');
}
database
.prepare('UPDATE users SET role = ?, updated_at_utc = ? WHERE id = ?')
.run(role, nowUtc(), userId);
}
export function revokeInvitation(actor: AuthUser, invitationId: string): void {
requireCurrentBarkeeper(actor);
getDatabase()
.prepare(
`UPDATE invitations SET revoked_at_utc = ?
WHERE id = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL`
)
.run(nowUtc(), invitationId);
}
+33 -1
View File
@@ -7,6 +7,38 @@ function configuredPath(value: string | undefined, fallback: string): string {
export function getServerPaths() {
return {
database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'),
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads')
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads'),
mailOutbox: configuredPath(process.env.SENDEWERK_MAIL_OUTBOX_DIR, '.data/mail-outbox')
};
}
export function getAuthConfig() {
const configuredOrigin = process.env.SENDEWERK_APP_ORIGIN || process.env.CLOUDRON_APP_ORIGIN;
if (process.env.NODE_ENV === 'production' && !configuredOrigin) {
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
}
const origin = new URL(configuredOrigin || 'http://localhost:5173');
return {
origin,
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
secureCookies: origin.protocol === 'https:'
};
}
export function getMailConfig() {
const transport = process.env.SENDEWERK_MAIL_TRANSPORT || 'local';
if (transport !== 'local' && transport !== 'smtp') {
throw new Error('SENDEWERK_MAIL_TRANSPORT muss local oder smtp sein.');
}
return {
transport,
from:
process.env.SENDEWERK_MAIL_FROM ||
(transport === 'local' ? 'Sendewerk <sendewerk@localhost>' : ''),
host: process.env.SENDEWERK_SMTP_HOST || '',
port: Number(process.env.SENDEWERK_SMTP_PORT || '587'),
secure: process.env.SENDEWERK_SMTP_SECURE === 'true',
user: process.env.SENDEWERK_SMTP_USER || '',
password: process.env.SENDEWERK_SMTP_PASSWORD || ''
};
}
+169
View File
@@ -0,0 +1,169 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createPiece,
createTaxonomy,
deletePieceImage,
deleteTaxonomy,
duplicatePiece,
getMediaFile,
getPiece,
getTaxonomy,
listPieces,
savePiece,
setPieceArchived,
uploadPieceImage,
type PieceInput
} from './content.js';
import { closeDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-content-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
return {
title: 'Getestetes Piece',
summary: 'Eine persistente Kurzbeschreibung.',
formatId: 'format-talk',
status: 'ready',
durationSeconds: 420,
intro: '**Starker Einstieg**',
researchText: 'Private Recherche',
moderationNotes: '- Punkt eins',
transitionText: 'Weiter geht es.',
presentationText: '## Sichtbare Aussage',
topicIds: ['topic-gaming', 'topic-internet'],
categoryIds: ['cat-jc', 'cat-reaction'],
sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
],
...overrides
};
}
describe('content management', () => {
it('persists complete pieces and rejects stale revisions atomically', () => {
const id = createPiece('talk');
const initial = getPiece(id)!;
const saved = savePiece(id, initial.revision, completeInput());
const piece = getPiece(id)!;
expect(saved.revision).toBe(2);
expect(piece).toMatchObject({
title: 'Getestetes Piece',
status: 'ready',
researchText: 'Private Recherche',
topicIds: ['topic-gaming', 'topic-internet'],
categoryIds: ['cat-jc', 'cat-reaction']
});
expect(piece.sources).toEqual([
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
]);
expect(() =>
savePiece(id, initial.revision, completeInput({ title: 'Veralteter Titel', topicIds: [] }))
).toThrow('CONFLICT');
expect(getPiece(id)).toMatchObject({
title: 'Getestetes Piece',
topicIds: ['topic-gaming', 'topic-internet']
});
});
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
const sourceId = createPiece('find');
const source = getPiece(sourceId)!;
savePiece(
sourceId,
source.revision,
completeInput({
title: 'Seltene Hardware',
formatId: 'format-find',
topicIds: [topic.id],
categoryIds: ['cat-reaction']
})
);
expect(
listPieces({ statuses: ['ready'], query: 'dokumentation', topicId: topic.id })
).toHaveLength(1);
expect(listPieces({ statuses: ['ready'], query: '%' })).toHaveLength(0);
expect(() => deleteTaxonomy('topic', topic.id)).toThrow('TAXONOMY_IN_USE');
createTaxonomy('topic', 'Unbenutzt');
const unused = getTaxonomy().topics.find((entry) => entry.name === 'Unbenutzt')!;
deleteTaxonomy('topic', unused.id);
expect(getTaxonomy().topics.some((entry) => entry.id === unused.id)).toBe(false);
expect(() => deleteTaxonomy('format', 'format-find')).toThrow('TAXONOMY_IN_USE');
const duplicateId = await duplicatePiece(sourceId);
expect(getPiece(duplicateId)).toMatchObject({
title: 'Kopie von Seltene Hardware',
status: 'preparing',
topicIds: [topic.id]
});
const current = getPiece(sourceId)!;
setPieceArchived(sourceId, current.revision, true);
expect(listPieces({ statuses: ['ready'] }).some((piece) => piece.id === sourceId)).toBe(false);
const archived = listPieces({ statuses: ['archived'] }).find((piece) => piece.id === sourceId)!;
setPieceArchived(sourceId, archived.revision, false);
expect(getPiece(sourceId)?.status).toBe('ready');
});
it('sanitizes dangerous markdown while preserving useful formatting', () => {
const html = renderMarkdown(
'# Verbotene Ebene\n\n**sicher** <script>globalThis.compromised = true</script> [Link](javascript:alert(1))'
);
expect(html).toContain('<strong>sicher</strong>');
expect(html).not.toContain('<script');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('<h1');
});
it('accepts signed raster images, rejects disguised files and keeps storage private', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await uploadPieceImage(
id,
1,
new File([png], '../bild.png', { type: 'text/html' }),
'Ein heller Pixel'
);
const piece = getPiece(id)!;
expect(piece.media).toHaveLength(1);
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
const media = await getMediaFile(piece.media[0].id);
expect(media?.data.equals(png)).toBe(true);
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
await expect(
uploadPieceImage(
id,
piece.revision,
new File(['<script>alert(1)</script>'], 'angriff.png', { type: 'image/png' }),
''
)
).rejects.toThrow('INVALID_IMAGE_TYPE');
await deletePieceImage(id, piece.media[0].id, piece.revision);
expect(getPiece(id)?.media).toHaveLength(0);
});
});
+803
View File
@@ -0,0 +1,803 @@
import { randomUUID } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type';
import { getDatabase } from './database.js';
import { getServerPaths } from './config.js';
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number];
export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>;
export type TaxonomyKind = 'category' | 'format' | 'topic';
export type PieceSourceInput = {
label: string;
url: string;
timecodeSeconds: number | null;
};
export type PieceInput = {
title: string;
summary: string;
formatId: string | null;
status: EditablePieceStatus;
durationSeconds: number;
intro: string;
researchText: string;
moderationNotes: string;
transitionText: string;
presentationText: string;
topicIds: string[];
categoryIds: string[];
sources: PieceSourceInput[];
};
export type PieceListItem = {
id: string;
title: string;
summary: string;
status: PieceStatus;
revision: number;
durationSeconds: number;
formatId: string | null;
format: string;
categories: { id: string; name: string; color: string }[];
topics: { id: string; name: string }[];
sourceCount: number;
mediaId: string | null;
};
export type PieceEditor = Omit<PieceInput, 'status'> & {
id: string;
status: PieceStatus;
revision: number;
createdAtUtc: string;
updatedAtUtc: string;
media: PieceMedia[];
};
export type PieceMedia = {
id: string;
originalName: string;
mimeType: string;
byteSize: number;
altText: string;
position: number;
};
export type Taxonomy = {
categories: { id: string; name: string; color: string }[];
formats: { id: string; name: string }[];
topics: { id: string; name: string }[];
};
export const pieceTemplates = [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.'
},
{
id: 'talk',
name: 'Gespräch',
description: 'Leitfragen, Recherche und eine klare Überleitung.'
},
{
id: 'find',
name: 'Reaction / Fundstück',
description: 'Quelle, Einordnung und präsentierbare Kernaussage.'
},
{
id: 'interaction',
name: 'Interaktion',
description: 'Quiz, Publikumsfrage oder kleines Spiel.'
}
] as const;
export type PieceTemplateId = (typeof pieceTemplates)[number]['id'];
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGES = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['image/webp', 'webp'],
['image/gif', 'gif']
]);
function cleanText(value: unknown, max: number, field: string): string {
const text = String(value ?? '').trim();
if (text.length > max) throw new Error(`INVALID_${field.toUpperCase()}`);
return text;
}
function uniqueIds(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function validUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) {
if (!ids.length) return;
const placeholders = ids.map(() => '?').join(', ');
const count = getDatabase()
.prepare(`SELECT count(*) AS count FROM ${table} WHERE id IN (${placeholders})`)
.get(...ids) as { count: number };
if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY');
}
function validatePieceInput(input: PieceInput): PieceInput {
const title = cleanText(input.title, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
if (
!Number.isInteger(input.durationSeconds) ||
input.durationSeconds < 0 ||
input.durationSeconds > 86_400
) {
throw new Error('INVALID_DURATION');
}
const topicIds = uniqueIds(input.topicIds);
const categoryIds = uniqueIds(input.categoryIds);
if (topicIds.length > 20 || categoryIds.length > 20) throw new Error('INVALID_TAXONOMY');
if (input.formatId) requireTaxonomyIds('formats', [input.formatId]);
requireTaxonomyIds('topics', topicIds);
requireTaxonomyIds('section_categories', categoryIds);
if (input.sources.length > 20) throw new Error('INVALID_SOURCES');
const sources = input.sources.map((source) => {
const label = cleanText(source.label, 200, 'source');
const url = cleanText(source.url, 2_000, 'source');
if (!label || !validUrl(url)) throw new Error('INVALID_SOURCES');
if (
source.timecodeSeconds !== null &&
(!Number.isInteger(source.timecodeSeconds) ||
source.timecodeSeconds < 0 ||
source.timecodeSeconds > 86_400)
) {
throw new Error('INVALID_SOURCES');
}
return { label, url, timecodeSeconds: source.timecodeSeconds };
});
return {
title,
summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null,
status: input.status,
durationSeconds: input.durationSeconds,
intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'),
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
transitionText: cleanText(input.transitionText, 50_000, 'transition'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
topicIds,
categoryIds,
sources
};
}
export function getTaxonomy(): Taxonomy {
const database = getDatabase();
return {
categories: database
.prepare('SELECT id, name, color FROM section_categories ORDER BY sort_order, name')
.all() as Taxonomy['categories'],
formats: database
.prepare('SELECT id, name FROM formats ORDER BY name')
.all() as Taxonomy['formats'],
topics: database
.prepare('SELECT id, name FROM topics ORDER BY name')
.all() as Taxonomy['topics']
};
}
type PieceListRow = {
id: string;
title: string;
summary: string;
status: PieceStatus;
revision: number;
duration_seconds: number;
format_id: string | null;
format: string;
source_count: number;
media_id: string | null;
};
export function listPieces(filters: {
statuses: PieceStatus[];
query?: string;
formatId?: string;
topicId?: string;
categoryId?: string;
}): PieceListItem[] {
if (!filters.statuses.length) return [];
const conditions = [`p.status IN (${filters.statuses.map(() => '?').join(', ')})`];
const parameters: unknown[] = [...filters.statuses];
const query = filters.query?.trim().toLowerCase();
if (query) {
const escapedQuery = query
.replaceAll('\\', '\\\\')
.replaceAll('%', '\\%')
.replaceAll('_', '\\_');
conditions.push(`(
lower(p.title) LIKE ? ESCAPE '\\' OR lower(p.summary) LIKE ? ESCAPE '\\' OR
EXISTS (SELECT 1 FROM piece_sources ps WHERE ps.piece_id = p.id AND (lower(ps.label) LIKE ? ESCAPE '\\' OR lower(ps.url) LIKE ? ESCAPE '\\')) OR
EXISTS (SELECT 1 FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id WHERE pt.piece_id = p.id AND lower(t.name) LIKE ? ESCAPE '\\')
)`);
parameters.push(...Array(5).fill(`%${escapedQuery}%`));
}
if (filters.formatId) {
conditions.push('p.format_id = ?');
parameters.push(filters.formatId);
}
if (filters.topicId) {
conditions.push(
'EXISTS (SELECT 1 FROM piece_topics pt WHERE pt.piece_id = p.id AND pt.topic_id = ?)'
);
parameters.push(filters.topicId);
}
if (filters.categoryId) {
conditions.push(
'EXISTS (SELECT 1 FROM piece_category_suggestions pc WHERE pc.piece_id = p.id AND pc.category_id = ?)'
);
parameters.push(filters.categoryId);
}
const database = getDatabase();
const rows = database
.prepare(
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds,
p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
WHERE ${conditions.join(' AND ')}
ORDER BY CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END,
p.updated_at_utc DESC, p.title`
)
.all(...parameters) as PieceListRow[];
const categoryStatement = database.prepare(
`SELECT c.id, c.name, c.color FROM piece_category_suggestions pc
JOIN section_categories c ON c.id = pc.category_id
WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name`
);
const topicStatement = database.prepare(
`SELECT t.id, t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name`
);
return rows.map((row) => ({
id: row.id,
title: row.title,
summary: row.summary,
status: row.status,
revision: row.revision,
durationSeconds: row.duration_seconds,
formatId: row.format_id,
format: row.format,
categories: categoryStatement.all(row.id) as PieceListItem['categories'],
topics: topicStatement.all(row.id) as PieceListItem['topics'],
sourceCount: row.source_count,
mediaId: row.media_id
}));
}
type PieceRow = {
id: string;
title: string;
summary: string;
format_id: string | null;
status: PieceStatus;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
revision: number;
created_at_utc: string;
updated_at_utc: string;
};
export function getPiece(id: string): PieceEditor | null {
const database = getDatabase();
const row = database
.prepare(
`SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, revision, created_at_utc, updated_at_utc
FROM pieces WHERE id = ?`
)
.get(id) as PieceRow | undefined;
if (!row) return null;
return {
id: row.id,
title: row.title,
summary: row.summary,
formatId: row.format_id,
status: row.status,
durationSeconds: row.duration_seconds,
intro: row.intro,
researchText: row.research_text,
moderationNotes: row.moderation_notes,
transitionText: row.transition_text,
presentationText: row.presentation_text,
revision: row.revision,
createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc,
topicIds: (
database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as {
topic_id: string;
}[]
).map((entry) => entry.topic_id),
categoryIds: (
database
.prepare('SELECT category_id FROM piece_category_suggestions WHERE piece_id = ?')
.all(id) as { category_id: string }[]
).map((entry) => entry.category_id),
sources: (
database
.prepare(
'SELECT label, url, timecode_seconds FROM piece_sources WHERE piece_id = ? ORDER BY position'
)
.all(id) as { label: string; url: string; timecode_seconds: number | null }[]
).map((source) => ({
label: source.label,
url: source.url,
timecodeSeconds: source.timecode_seconds
})),
media: (
database
.prepare(
`SELECT id, original_name, mime_type, byte_size, alt_text, position
FROM piece_media WHERE piece_id = ? ORDER BY position`
)
.all(id) as {
id: string;
original_name: string;
mime_type: string;
byte_size: number;
alt_text: string;
position: number;
}[]
).map((media) => ({
id: media.id,
originalName: media.original_name,
mimeType: media.mime_type,
byteSize: media.byte_size,
altText: media.alt_text,
position: media.position
}))
};
}
function replaceRelations(pieceId: string, input: PieceInput) {
const database = getDatabase();
database.prepare('DELETE FROM piece_topics WHERE piece_id = ?').run(pieceId);
const addTopic = database.prepare('INSERT INTO piece_topics (piece_id, topic_id) VALUES (?, ?)');
for (const id of input.topicIds) addTopic.run(pieceId, id);
database.prepare('DELETE FROM piece_category_suggestions WHERE piece_id = ?').run(pieceId);
const addCategory = database.prepare(
'INSERT INTO piece_category_suggestions (piece_id, category_id) VALUES (?, ?)'
);
for (const id of input.categoryIds) addCategory.run(pieceId, id);
database.prepare('DELETE FROM piece_sources WHERE piece_id = ?').run(pieceId);
const addSource = database.prepare(
`INSERT INTO piece_sources (id, piece_id, label, url, timecode_seconds, position)
VALUES (?, ?, ?, ?, ?, ?)`
);
input.sources.forEach((source, position) =>
addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position)
);
}
export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1)
throw new Error('INVALID_REVISION');
const input = validatePieceInput(rawInput);
const database = getDatabase();
const updatedAtUtc = new Date().toISOString();
const save = database.transaction(() => {
const result = database
.prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(
input.title,
input.summary,
input.formatId,
input.status,
input.durationSeconds,
input.intro,
input.researchText,
input.moderationNotes,
input.transitionText,
input.presentationText,
updatedAtUtc,
id,
expectedRevision
);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
replaceRelations(id, input);
});
save();
return { revision: expectedRevision + 1, updatedAtUtc };
}
function templateDefaults(template: PieceTemplateId): Partial<PieceInput> & { title: string } {
if (template === 'talk') {
return {
title: 'Unbenanntes Gespräch',
formatId: 'format-talk',
categoryIds: ['cat-jc'],
moderationNotes: '## Leitfragen\n\n- '
};
}
if (template === 'find') {
return {
title: 'Neues Fundstück',
formatId: 'format-find',
categoryIds: ['cat-reaction'],
presentationText: '## Kernaussage\n\n'
};
}
if (template === 'interaction') {
return {
title: 'Neue Interaktion',
formatId: 'format-quiz',
categoryIds: ['cat-game'],
moderationNotes: '## Ablauf\n\n1. '
};
}
return { title: 'Neues Piece' };
}
export function createPiece(template: PieceTemplateId = 'blank'): string {
if (!pieceTemplates.some((entry) => entry.id === template)) throw new Error('INVALID_TEMPLATE');
const defaults = templateDefaults(template);
const id = randomUUID();
const now = new Date().toISOString();
const input = validatePieceInput({
title: defaults.title,
summary: '',
formatId: defaults.formatId ?? null,
status: 'preparing',
durationSeconds: 0,
intro: '',
researchText: '',
moderationNotes: defaults.moderationNotes ?? '',
transitionText: '',
presentationText: defaults.presentationText ?? '',
topicIds: [],
categoryIds: defaults.categoryIds ?? [],
sources: []
});
const database = getDatabase();
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
input.title,
input.summary,
input.formatId,
input.status,
input.durationSeconds,
input.intro,
input.researchText,
input.moderationNotes,
input.transitionText,
input.presentationText,
now,
now
);
replaceRelations(id, input);
})();
return id;
}
export function captureInbox(titleValue: string, urlValue: string): string {
const title = cleanText(titleValue, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
const url = cleanText(urlValue, 2_000, 'source');
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
const id = createPiece('blank');
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
title,
status: 'inbox',
sources: url ? [{ label: 'Schnellaufnahme', url, timecodeSeconds: null }] : []
});
return id;
}
export function setPieceArchived(id: string, expectedRevision: number, archived: boolean) {
const database = getDatabase();
const now = new Date().toISOString();
const result = archived
? database
.prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(now, id, expectedRevision)
: database
.prepare(
`UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL,
revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status = 'archived'`
)
.run(now, id, expectedRevision);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
}
export async function duplicatePiece(sourceId: string): Promise<string> {
const source = getPiece(sourceId);
if (!source) throw new Error('NOT_FOUND');
const id = randomUUID();
const now = new Date().toISOString();
const uploadDirectory = getServerPaths().uploads;
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const database = getDatabase();
const storedMedia = database
.prepare('SELECT * FROM piece_media WHERE piece_id = ? ORDER BY position')
.all(sourceId) as {
id: string;
storage_name: string;
original_name: string;
mime_type: string;
byte_size: number;
alt_text: string;
position: number;
}[];
const copied: { source: (typeof storedMedia)[number]; storageName: string }[] = [];
try {
for (const media of storedMedia) {
const extension = ALLOWED_IMAGES.get(media.mime_type)!;
const storageName = `${randomUUID()}.${extension}`;
await copyFile(
path.join(uploadDirectory, media.storage_name),
path.join(uploadDirectory, storageName)
);
copied.push({ source: media, storageName });
}
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
`Kopie von ${source.title}`.slice(0, 200),
source.summary,
source.formatId,
source.durationSeconds,
source.intro,
source.researchText,
source.moderationNotes,
source.transitionText,
source.presentationText,
now,
now
);
replaceRelations(id, { ...source, status: 'preparing' });
const addMedia = database.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const media of copied) {
addMedia.run(
randomUUID(),
id,
media.storageName,
media.source.original_name,
media.source.mime_type,
media.source.byte_size,
media.source.alt_text,
media.source.position,
now
);
}
})();
return id;
} catch (error) {
await Promise.all(
copied.map((media) => unlink(path.join(uploadDirectory, media.storageName)).catch(() => {}))
);
throw error;
}
}
export async function uploadPieceImage(
pieceId: string,
expectedRevision: number,
file: File,
altTextValue: string
) {
if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE');
const buffer = Buffer.from(await file.arrayBuffer());
const detected = await fileTypeFromBuffer(buffer);
const extension = detected ? ALLOWED_IMAGES.get(detected.mime) : undefined;
if (!detected || !extension) throw new Error('INVALID_IMAGE_TYPE');
const altText = cleanText(altTextValue, 300, 'alt');
const storageName = `${randomUUID()}.${extension}`;
const uploadDirectory = getServerPaths().uploads;
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const filePath = path.join(uploadDirectory, storageName);
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
const database = getDatabase();
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const count = database
.prepare('SELECT count(*) AS count FROM piece_media WHERE piece_id = ?')
.get(pieceId) as { count: number };
if (count.count >= 8) throw new Error('TOO_MANY_IMAGES');
database
.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
pieceId,
storageName,
path.basename(file.name).slice(0, 200) || `bild.${extension}`,
detected.mime,
buffer.byteLength,
altText,
count.count,
new Date().toISOString()
);
})();
} catch (error) {
await unlink(filePath).catch(() => {});
throw error;
}
}
export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) {
const database = getDatabase();
const media = database
.prepare('SELECT storage_name FROM piece_media WHERE id = ? AND piece_id = ?')
.get(mediaId, pieceId) as { storage_name: string } | undefined;
if (!media) throw new Error('NOT_FOUND');
const originalPath = path.join(getServerPaths().uploads, media.storage_name);
const quarantinePath = `${originalPath}.deleting-${randomUUID()}`;
await rename(originalPath, quarantinePath);
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
database.prepare('DELETE FROM piece_media WHERE id = ?').run(mediaId);
})();
} catch (error) {
await rename(quarantinePath, originalPath).catch((restoreError) => {
console.error(
'Bilddatei konnte nach fehlgeschlagener Löschung nicht wiederhergestellt werden.',
restoreError
);
});
throw error;
}
try {
await unlink(quarantinePath);
} catch (error) {
console.error(
'Gelöschte Bilddatei konnte nicht aus dem Quarantänepfad entfernt werden.',
error
);
throw new Error('IMAGE_CLEANUP_FAILED', { cause: error });
}
}
export async function getMediaFile(id: string) {
const row = getDatabase()
.prepare('SELECT storage_name, mime_type, byte_size, alt_text FROM piece_media WHERE id = ?')
.get(id) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!row) return null;
return {
data: await readFile(path.join(getServerPaths().uploads, row.storage_name)),
mimeType: row.mime_type,
byteSize: row.byte_size,
altText: row.alt_text
};
}
export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue?: string) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const database = getDatabase();
const duplicate = database
.prepare(
`SELECT 1 FROM ${kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics'}
WHERE lower(name) = lower(?)`
)
.get(name);
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const id = randomUUID();
if (kind === 'category') {
const color = String(colorValue ?? '');
if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR');
const order = database
.prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories')
.get() as {
next: number;
};
database
.prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(id, name, color, order.next);
} else {
const table = kind === 'format' ? 'formats' : 'topics';
database.prepare(`INSERT INTO ${table} (id, name) VALUES (?, ?)`).run(id, name);
}
}
export function deleteTaxonomy(kind: TaxonomyKind, id: string) {
const table =
kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics';
if (
['format-talk', 'format-find', 'format-quiz', 'cat-jc', 'cat-reaction', 'cat-game'].includes(id)
) {
throw new Error('TAXONOMY_IN_USE');
}
const database = getDatabase();
const usage =
kind === 'format'
? (
database.prepare('SELECT count(*) AS count FROM pieces WHERE format_id = ?').get(id) as {
count: number;
}
).count
: kind === 'topic'
? (
database
.prepare('SELECT count(*) AS count FROM piece_topics WHERE topic_id = ?')
.get(id) as {
count: number;
}
).count
: (
database
.prepare(
`SELECT
(SELECT count(*) FROM piece_category_suggestions WHERE category_id = ?) +
(SELECT count(*) FROM show_sections WHERE category_id = ?) AS count`
)
.get(id, id) as { count: number }
).count;
if (usage > 0) throw new Error('TAXONOMY_IN_USE');
const result = database.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
+1 -1
View File
@@ -52,7 +52,7 @@ describe('database setup', () => {
.get() as { migrations: number; pieces: number; shows: number; usages: number };
database.close();
expect(counts).toEqual({ migrations: 1, pieces: 4, shows: 1, usages: 3 });
expect(counts).toEqual({ migrations: 3, pieces: 6, shows: 1, usages: 3 });
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
});
+49
View File
@@ -0,0 +1,49 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import nodemailer from 'nodemailer';
import { getMailConfig, getServerPaths } from './config.js';
export type MailMessage = {
to: string;
subject: string;
text: string;
};
export async function sendMail(message: MailMessage): Promise<'local' | 'smtp'> {
const config = getMailConfig();
if (config.transport === 'local') {
const outbox = getServerPaths().mailOutbox;
await mkdir(outbox, { recursive: true, mode: 0o700 });
const filename = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.json`;
await writeFile(
path.join(outbox, filename),
JSON.stringify({ from: config.from, ...message }, null, 2),
{ encoding: 'utf8', mode: 0o600 }
);
return 'local';
}
if (
!config.host ||
!config.from ||
!Number.isInteger(config.port) ||
config.port < 1 ||
config.port > 65_535 ||
Boolean(config.user) !== Boolean(config.password)
) {
throw new Error('Die SMTP-Konfiguration ist unvollständig.');
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
requireTLS: !config.secure,
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
auth: config.user ? { user: config.user, pass: config.password } : undefined
});
await transporter.sendMail({ from: config.from, ...message });
return 'smtp';
}
+42
View File
@@ -0,0 +1,42 @@
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
export function renderMarkdown(markdown: string): string {
const rendered = marked.parse(markdown, {
async: false,
breaks: true,
gfm: true
}) as string;
return sanitizeHtml(rendered, {
allowedTags: [
'p',
'br',
'strong',
'em',
'del',
'blockquote',
'ul',
'ol',
'li',
'h2',
'h3',
'h4',
'code',
'pre',
'a',
'hr'
],
allowedAttributes: {
a: ['href', 'title', 'target', 'rel']
},
allowedSchemes: ['http', 'https', 'mailto'],
allowProtocolRelative: false,
enforceHtmlBoundary: true,
transformTags: {
a: (_tagName, attributes) => ({
tagName: 'a',
attribs: { ...attributes, target: '_blank', rel: 'noopener noreferrer' }
})
}
});
}
+1 -1
View File
@@ -85,7 +85,7 @@ export function getPieces(): PreviewPiece[] {
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status != 'archived'
WHERE p.status IN ('preparing', 'ready')
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
)
.all() as PieceRow[];
+3
View File
@@ -0,0 +1,3 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user });
+78 -44
View File
@@ -5,14 +5,17 @@
import NavIcon from '#lib/components/NavIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
let { children } = $props();
const navigation = [
let { children, data } = $props();
const primaryNavigation = [
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' },
{ href: '/verwaltung', label: 'Verwaltung', icon: 'settings' }
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = $derived(
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
);
function isActive(href: string) {
return (
@@ -28,15 +31,66 @@
/>
</svelte:head>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
{#if data.user}
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<nav class="main-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
<nav class="main-navigation" aria-label="Hauptnavigation">
{#each primaryNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<NavIcon name={item.icon} />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-footer">
{#if data.user.role === 'barkeeper'}
<nav class="utility-navigation" aria-label="Verwaltung">
<a
href={resolve(administration.href)}
class:active={isActive(administration.href)}
aria-current={isActive(administration.href) ? 'page' : undefined}
>
<NavIcon name={administration.icon} />
<span>{administration.label}</span>
</a>
</nav>
{/if}
<div class="account-summary">
<span>{data.user.displayName}</span>
<small>{data.user.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</div>
<form method="POST" action="/logout">
<button class="logout-button" type="submit">Abmelden</button>
</form>
<ThemeToggle />
</div>
</aside>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<form method="POST" action="/logout">
<button class="mobile-logout" type="submit" aria-label="Abmelden">↗</button>
</form>
<ThemeToggle />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each mobileNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
@@ -47,36 +101,16 @@
</a>
{/each}
</nav>
<div class="sidebar-footer">
<ThemeToggle />
<div class="stage-indicator">
<span></span>
<div><strong>Etappe 1</strong><small>Lokaler Entwurf</small></div>
</div>
</div>
</aside>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<ThemeToggle />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<NavIcon name={item.icon} />
<span>{item.label}</span>
</div>
{:else}
<div class="auth-shell">
<header class="auth-header">
<a class="brand" href={resolve('/login')} aria-label="Sendewerk Anmeldung">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
{/each}
</nav>
</div>
<ThemeToggle />
</header>
<main class="auth-main">{@render children()}</main>
</div>
{/if}
+4 -2
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { hasUsers } from '#lib/server/auth.js';
export function load() {
redirect(307, '/fundus');
export function load({ locals }) {
if (!hasUsers()) redirect(307, '/setup');
redirect(307, locals.user ? '/fundus' : '/login');
}
+22
View File
@@ -0,0 +1,22 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['archived'] }) };
};
export const actions = {
restore: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(String(data.get('pieceId') || ''), Number(data.get('revision')), false);
return { success: 'Piece wurde in seinen vorherigen Bereich zurückgelegt.' };
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
}
}
} satisfies Actions;
+28 -13
View File
@@ -1,20 +1,35 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">Bewahrte Sendungen</p>
<h1>Archiv</h1>
<p class="page-intro">
Abgeschlossene Abläufe bleiben hier in ihrem damaligen Stand nachvollziehbar.
</p>
<p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div>
<p class="eyebrow">Geplant für Etappe 6</p>
<h2>Historie braucht klare Regeln.</h2>
<p>
Stabile Snapshots, Verwendungshistorie und bewusste Archivierung werden gemeinsam
implementiert, statt jetzt nur eine leere Liste vorzutäuschen.
</p>
</section>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
{#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article>
<div>
<h2>{piece.title}</h2>
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
</div>
<form method="POST" action="?/restore">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="secondary-button" type="submit">Wiederherstellen</button>
</form>
</article>{/each}
</div>
{:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div>
<h2>Keine Pieces archiviert</h2>
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
</section>{/if}
</div>
+30
View File
@@ -0,0 +1,30 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['inbox'] }) };
};
export const actions = {
capture: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
redirect(303, `/fundus/${id}`);
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
return fail(400, {
title: String(data.get('title') || ''),
url: String(data.get('url') || ''),
error:
code === 'INVALID_SOURCES'
? 'Bitte eine vollständige HTTP- oder HTTPS-Adresse eingeben.'
: 'Ein Titel ist erforderlich.'
});
}
}
} satisfies Actions;
+52 -13
View File
@@ -1,21 +1,60 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Eingang | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">Sammelstelle</p>
<h1>Eingang</h1>
<p class="page-intro">
Neue Gedanken und spätere RSS-Fundstücke landen hier, bevor sie in den Fundus übernommen
werden.
</p>
<p class="page-intro">Gedanken und Fundstücke schnell festhalten und später ausarbeiten.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<p class="eyebrow">Geplant für Etappe 3 und 7</p>
<h2>Noch ist der Tresen leer.</h2>
<p>
Schnellaufnahme, Feed-Vorschau und bewusste Übernahme werden erst mit den dazugehörigen
Speicher- und Sicherheitsregeln umgesetzt.
</p>
<section class="quiet-panel capture-panel">
<div>
<p class="eyebrow">Schnellaufnahme</p>
<h2>Was soll nicht verloren gehen?</h2>
</div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form method="POST" action="?/capture" class="capture-form">
<label
>Titel oder Gedanke<input
name="title"
required
maxlength="200"
value={form?.title ?? ''}
placeholder="Kurze Idee festhalten"
/></label
>
<label
>Link <span>optional</span><input
name="url"
type="url"
maxlength="2000"
value={form?.url ?? ''}
placeholder="https://…"
/></label
>
<button class="primary-button" type="submit">Aufnehmen und öffnen</button>
</form>
</section>
{#if data.pieces.length}
<section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
{#each data.pieces as piece (piece.id)}<a
class="inbox-item"
href={resolve('/fundus/[id]', { id: piece.id })}
><span
><strong>{piece.title}</strong><small
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
></span
><span>Ausarbeiten →</span></a
>{/each}
</section>
{:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<h2>Eingang ist leer</h2>
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if}
</div>
+57
View File
@@ -0,0 +1,57 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
acceptInvitation,
createSession,
deleteLinkToken,
getInvitation,
INVITATION_COOKIE,
readLinkToken,
storeLinkToken,
validDisplayName,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ url, cookies }) => {
const queryToken = url.searchParams.get('token');
if (queryToken) {
if (queryToken.length <= 100) storeLinkToken(cookies, INVITATION_COOKIE, queryToken);
else deleteLinkToken(cookies, INVITATION_COOKIE);
redirect(303, '/einladung');
}
const token = readLinkToken(cookies, INVITATION_COOKIE);
const invitation = getInvitation(token);
const valid = Boolean(
invitation &&
!invitation.accepted_at_utc &&
!invitation.revoked_at_utc &&
invitation.expires_at_utc > new Date().toISOString()
);
return { valid, email: valid ? invitation?.email : null };
};
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const token = readLinkToken(cookies, INVITATION_COOKIE);
const displayName = String(data.get('displayName') || '').trim();
const password = String(data.get('password') || '');
if (!validDisplayName(displayName) || !validPassword(password)) {
return fail(400, {
displayName,
error: 'Bitte Namen und ein Passwort mit mindestens 12 Zeichen prüfen.'
});
}
try {
const user = await acceptInvitation({ token, displayName, password });
deleteLinkToken(cookies, INVITATION_COOKIE);
createSession(user.id, cookies);
} catch {
return fail(400, {
displayName,
error: 'Die Einladung ist ungültig, abgelaufen oder bereits verwendet.'
});
}
redirect(303, '/fundus');
}
} satisfies Actions;
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Einladung annehmen | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Einladung</p>
<h1>Sendewerk-Konto aktivieren</h1>
{#if !data.valid && !form?.error}
<div class="form-message error" role="alert">
Die Einladung ist ungültig, abgelaufen oder bereits verwendet.
</div>
{:else}
<p>Die Einladung gilt für <strong>{data.email}</strong>.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Name<input
name="displayName"
required
maxlength="80"
value={form?.displayName ?? ''}
autocomplete="name"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<button class="primary-button" type="submit">Konto aktivieren</button>
</form>
{/if}
</section>
+80 -3
View File
@@ -1,5 +1,82 @@
import { getPieces } from '#lib/server/previews.js';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
createPiece,
duplicatePiece,
getTaxonomy,
listPieces,
pieceTemplates,
setPieceArchived,
type PieceTemplateId
} from '#lib/server/content.js';
export function load() {
return { pieces: getPieces() };
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const filters = {
query: url.searchParams.get('q') ?? '',
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? ''
};
return {
pieces: listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
}),
taxonomy: getTaxonomy(),
templates: pieceTemplates,
filters
};
};
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT') return 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.';
if (code === 'NOT_FOUND') return 'Das Piece wurde nicht gefunden.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
export const actions = {
create: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
id = createPiece(String(data.get('template') || 'blank') as PieceTemplateId);
} catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
},
duplicate: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
id = await duplicatePiece(String(data.get('pieceId') || ''));
} catch (cause) {
return fail(400, { intent: 'duplicate', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
},
archive: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return { intent: 'archive', success: 'Piece archiviert.' };
} catch (cause) {
return fail(409, { intent: 'archive', error: actionError(cause) });
}
}
} satisfies Actions;
+116 -63
View File
@@ -1,19 +1,9 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { resolve } from '$app/paths';
import { formatClock } from '#lib/format.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let query = $state('');
let filter = $state<'all' | 'ready' | 'preparing'>('all');
let filteredPieces = $derived(
data.pieces.filter((piece) => {
const text =
`${piece.title} ${piece.summary} ${piece.format} ${piece.topics.join(' ')}`.toLowerCase();
return text.includes(query.toLowerCase()) && (filter === 'all' || piece.status === filter);
})
);
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
const statusLabel = {
ready: 'Sendefertig',
preparing: 'In Vorbereitung',
@@ -27,75 +17,138 @@
<div class="page-wrap">
<header class="page-heading split-heading">
<div>
<p class="eyebrow">Redaktioneller Fundus</p>
<h1>Gute Stücke, sofort griffbereit.</h1>
<p class="page-intro">
Vorbereitete Ideen, Gespräche und Fundstücke für die nächste Sendung.
</p>
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div>
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}>
<div class="heading-stat" aria-label={`${data.pieces.length} gefundene Inhalte`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<PreviewNotice area="Fundus" />
<section class="library-toolbar" aria-label="Fundus durchsuchen und filtern">
<label class="search-field">
<span aria-hidden="true">⌕</span>
<span class="sr-only">Fundus durchsuchen</span>
<input bind:value={query} type="search" placeholder="Titel, Thema oder Format suchen" />
</label>
<div class="filter-tabs" aria-label="Status filtern">
<button class:active={filter === 'all'} type="button" onclick={() => (filter = 'all')}
>Alle</button
>
<button class:active={filter === 'ready'} type="button" onclick={() => (filter = 'ready')}
>Sendefertig</button
>
<button
class:active={filter === 'preparing'}
type="button"
onclick={() => (filter = 'preparing')}>In Arbeit</button
>
<details class="template-picker">
<summary>Neues Piece anlegen</summary>
<div class="template-grid">
{#each data.templates as template (template.id)}
<form method="POST" action="?/create">
<input type="hidden" name="template" value={template.id} />
<button type="submit"
><strong>{template.name}</strong><span>{template.description}</span></button
>
</form>
{/each}
</div>
</section>
</details>
{#if filteredPieces.length > 0}
<form class="library-filters" method="GET" aria-label="Fundus durchsuchen und filtern">
<label class="search-field"
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
name="q"
type="search"
value={data.filters.query}
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<label
><span>Status</span><select name="status"
><option value="">Alle</option><option
value="ready"
selected={data.filters.status === 'ready'}>Sendefertig</option
><option value="preparing" selected={data.filters.status === 'preparing'}>In Arbeit</option
></select
></label
>
<label
><span>Format</span><select name="format"
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
value={item.id}
selected={data.filters.formatId === item.id}>{item.name}</option
>{/each}</select
></label
>
<label
><span>Thema</span><select name="topic"
><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option
value={item.id}
selected={data.filters.topicId === item.id}>{item.name}</option
>{/each}</select
></label
>
<label
><span>Abschnitt</span><select name="category"
><option value="">Alle</option>{#each data.taxonomy.categories as item (item.id)}<option
value={item.id}
selected={data.filters.categoryId === item.id}>{item.name}</option
>{/each}</select
></label
>
<button class="secondary-button" type="submit">Anwenden</button>
<a class="text-link" href={resolve('/fundus')}>Zurücksetzen</a>
</form>
{#if data.pieces.length}
<div class="piece-grid">
{#each filteredPieces as piece (piece.id)}
<article class="piece-card" style={`--category: ${piece.categoryColor}`}>
<div class="piece-card-topline">
<span class="category-label"><i></i>{piece.category}</span>
{#each data.pieces as piece (piece.id)}
<article
class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
>
{#if piece.mediaId}<img
class="piece-thumbnail"
src={`/media/${piece.mediaId}`}
alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"
>S</span
>{/if}
<div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
</div>
<div class="piece-classification">
<span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
>
<div class="piece-tags">
<span>{piece.format}</span>{#each piece.topics as topic (topic.id)}<span
>{topic.name}</span
>{/each}
</div>
</div>
<div class="piece-state">
<span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span
><span class="piece-facts"
><span><b>{formatClock(piece.durationSeconds)}</b> geplant</span><span
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
></span
>
</div>
<h2>{piece.title}</h2>
<p>{piece.summary}</p>
<div class="piece-tags">
<span>{piece.format}</span>
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each}
<div class="piece-actions">
<a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a>
<form method="POST" action="?/duplicate">
<input type="hidden" name="pieceId" value={piece.id} /><button
class="text-button"
type="submit">Duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="text-button danger-text" type="submit">Archivieren</button>
</form>
</div>
<footer>
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
</footer>
</article>
{/each}
</div>
{:else}
<div class="empty-state">
<span aria-hidden="true">⌕</span>
<h2>Kein passendes Stück</h2>
<p>Suche oder Filter zurücksetzen, um wieder alle Beispieldaten zu sehen.</p>
<button
type="button"
onclick={() => {
query = '';
filter = 'all';
}}>Filter zurücksetzen</button
>
<h2>Kein passendes Piece</h2>
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
</div>
{/if}
</div>
+171
View File
@@ -0,0 +1,171 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
deletePieceImage,
duplicatePiece,
getPiece,
getTaxonomy,
savePiece,
setPieceArchived,
uploadPieceImage,
type EditablePieceStatus,
type PieceInput,
type PieceSourceInput
} from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js';
function renderedFields(
piece: Pick<
PieceInput,
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText'
>
) {
return {
intro: renderMarkdown(piece.intro),
research: renderMarkdown(piece.researchText),
moderation: renderMarkdown(piece.moderationNotes),
transition: renderMarkdown(piece.transitionText),
presentation: renderMarkdown(piece.presentationText)
};
}
function text(data: FormData, name: string) {
return String(data.get(name) || '');
}
function pieceInput(data: FormData): PieceInput {
const labels = data.getAll('sourceLabel').map(String);
const urls = data.getAll('sourceUrl').map(String);
const timecodes = data.getAll('sourceTimecode').map(String);
const sources: PieceSourceInput[] = [];
for (let index = 0; index < Math.max(labels.length, urls.length, timecodes.length); index += 1) {
const label = labels[index]?.trim() ?? '';
const url = urls[index]?.trim() ?? '';
const timecode = timecodes[index]?.trim() ?? '';
if (!label && !url && !timecode) continue;
sources.push({
label,
url,
timecodeSeconds: timecode ? Number(timecode) : null
});
}
return {
title: text(data, 'title'),
summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus,
durationSeconds: Number(data.get('durationSeconds')),
intro: text(data, 'intro'),
researchText: text(data, 'researchText'),
moderationNotes: text(data, 'moderationNotes'),
transitionText: text(data, 'transitionText'),
presentationText: text(data, 'presentationText'),
topicIds: data.getAll('topicIds').map(String),
categoryIds: data.getAll('categoryIds').map(String),
sources
};
}
function saveError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Eine neuere Änderung liegt vor. Deine Eingaben wurden nicht überschrieben.',
conflict: true
};
if (code === 'INVALID_TITLE') return { status: 400, error: 'Bitte einen Titel eingeben.' };
if (code === 'INVALID_DURATION')
return { status: 400, error: 'Die Dauer muss zwischen 0 und 86.400 Sekunden liegen.' };
if (code === 'INVALID_SOURCES')
return {
status: 400,
error: 'Quellen benötigen einen Namen und eine vollständige HTTP- oder HTTPS-Adresse.'
};
if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return {
status: 500,
error: 'Speichern fehlgeschlagen. Deine Eingaben bleiben im Formular erhalten.'
};
}
export const load: PageServerLoad = ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv');
return { piece, taxonomy: getTaxonomy(), rendered: renderedFields(piece) };
};
export const actions = {
save: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const input = pieceInput(data);
try {
const saved = savePiece(params.id, Number(data.get('revision')), input);
return { intent: 'save', saved: true, ...saved, rendered: renderedFields(input) };
} catch (cause) {
const problem = saveError(cause);
return fail(problem.status, { intent: 'save', ...problem });
}
},
upload: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const image = data.get('image');
if (!(image instanceof File))
return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' });
try {
await uploadPieceImage(params.id, Number(data.get('revision')), image, text(data, 'altText'));
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return fail(409, {
intent: 'upload',
error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.'
});
if (code === 'INVALID_IMAGE_SIZE')
return fail(400, {
intent: 'upload',
error: 'Das Bild darf höchstens 5 MB groß und nicht leer sein.'
});
if (code === 'INVALID_IMAGE_TYPE')
return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' });
return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' });
}
redirect(303, `/fundus/${params.id}`);
},
deleteImage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
await deletePieceImage(params.id, text(data, 'mediaId'), Number(data.get('revision')));
} catch (cause) {
return fail(409, { intent: 'deleteImage', error: saveError(cause).error });
}
redirect(303, `/fundus/${params.id}`);
},
duplicate: async ({ locals, params }) => {
requireUser(locals.user);
let id: string;
try {
id = await duplicatePiece(params.id);
} catch {
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' });
}
redirect(303, `/fundus/${id}`);
},
archive: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(params.id, Number(data.get('revision')), true);
} catch (cause) {
return fail(409, { intent: 'archive', error: saveError(cause).error });
}
redirect(303, '/archiv');
}
} satisfies Actions;
+363
View File
@@ -0,0 +1,363 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, untrack } from 'svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let draft = $state(untrack(() => ({ ...data.piece })));
let revision = $state(untrack(() => data.piece.revision));
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
let rendered = $state(untrack(() => ({ ...data.rendered })));
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert');
let editorForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;
let saving = $state(false);
let actionsBlocked = $derived(
saveState === 'dirty' ||
saveState === 'saving' ||
saveState === 'error' ||
saveState === 'conflict'
);
let pendingHref: string | null = null;
let allowNavigation = false;
onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => {
if (allowNavigation) return;
if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Seite verlassen?')) navigation.cancel();
return;
}
if (saveState !== 'dirty' && saveState !== 'saving') return;
navigation.cancel();
if (navigation.willUnload) return;
pendingHref = navigation.to?.url.href ?? null;
if (!saving) submitSave();
});
function scheduleSave() {
generation += 1;
saveState = 'dirty';
saveMessage = 'Ungespeicherte Änderungen';
clearTimeout(timer);
timer = setTimeout(submitSave, 900);
}
function submitSave() {
if (saving) return;
editorForm.requestSubmit(saveButton);
}
function addSource() {
sources.push({ label: '', url: '', timecodeSeconds: null });
scheduleSave();
}
function removeSource(index: number) {
sources.splice(index, 1);
scheduleSave();
}
const enhanceSave: SubmitFunction = () => {
const submittedGeneration = generation;
saving = true;
saveState = 'saving';
saveMessage = 'Speichert …';
return async ({ result }) => {
saving = false;
if (result.type === 'success') {
const response = result.data as { revision: number; rendered: typeof rendered };
revision = response.revision;
rendered = response.rendered;
if (generation === submittedGeneration) {
saveState = 'saved';
saveMessage = 'Gespeichert';
if (pendingHref) {
const target = pendingHref;
pendingHref = null;
allowNavigation = true;
// The target comes from SvelteKit's own NavigationTarget, never from user input.
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(new URL(target), { invalidateAll: true });
}
} else {
saveState = 'dirty';
saveMessage = 'Weitere Änderungen warten';
timer = setTimeout(submitSave, 250);
}
} else if (result.type === 'failure') {
const response = result.data as { error?: string; conflict?: boolean };
saveState = response.conflict ? 'conflict' : 'error';
saveMessage = response.error ?? 'Speichern fehlgeschlagen.';
} else {
saveState = 'error';
saveMessage = 'Speichern fehlgeschlagen.';
}
};
};
</script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<div class="page-wrap editor-page">
<header class="editor-header">
<div>
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h1>{draft.title}</h1>
</div>
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form
bind:this={editorForm}
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-editor"
oninput={scheduleSave}
onchange={scheduleSave}
>
<input type="hidden" name="revision" value={revision} />
<section class="editor-section editor-basics">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h2>Worum geht es?</h2>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
>
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Dauer in Sekunden<input
name="durationSeconds"
type="number"
min="0"
max="86400"
step="1"
bind:value={draft.durationSeconds}
/></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="2"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
</div>
</section>
<section class="editor-section taxonomy-editor">
<div class="section-title">
<p class="eyebrow">Einordnung</p>
<h2>Themen und Abschnitte</h2>
</div>
<fieldset>
<legend>Themen</legend>
<div class="choice-grid">
{#each data.taxonomy.topics as item (item.id)}<label
><input
type="checkbox"
name="topicIds"
value={item.id}
bind:group={draft.topicIds}
/>{item.name}</label
>{/each}
</div>
</fieldset>
<fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid">
{#each data.taxonomy.categories as item (item.id)}<label
><input
type="checkbox"
name="categoryIds"
value={item.id}
bind:group={draft.categoryIds}
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
>{/each}
</div>
</fieldset>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h2>Getrennte Arbeitsbereiche</h2>
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea
name="intro"
rows="7"
maxlength="50000"
bind:value={draft.intro}></textarea>
<MarkdownPreview html={rendered.intro} /></label
>
<label class="private-field"
><span>Private Recherche</span><textarea
name="researchText"
rows="7"
maxlength="50000"
bind:value={draft.researchText}></textarea>
<MarkdownPreview html={rendered.research} /></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderationNotes"
rows="7"
maxlength="50000"
bind:value={draft.moderationNotes}></textarea>
<MarkdownPreview html={rendered.moderation} /></label
>
<label
><span>Überleitung</span><textarea
name="transitionText"
rows="7"
maxlength="50000"
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea
name="presentationText"
rows="7"
maxlength="50000"
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
</div>
</section>
<section class="editor-section source-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Belege</p>
<h2>Quellen</h2>
</div>
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
</section>
<div class="editor-savebar">
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
>Jetzt speichern</button
>
</div>
</form>
<section class="editor-section media-editor">
<div class="section-title">
<p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2>
<small>JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar.</small>
</div>
{#if data.piece.media.length}<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
<img src={`/media/${media.id}`} alt={media.altText} />
<figcaption>
<span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
>
</form>
</figcaption>
</figure>{/each}
</div>{/if}
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="revision" value={revision} /><label
>Bild<input
name="image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/></label
><label
>Alternativtext<input
name="altText"
maxlength="300"
placeholder="Was ist auf dem Bild zu sehen?"
/></label
><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button
>
</form>
</section>
<section class="editor-actions">
<form method="POST" action="?/duplicate">
<button class="secondary-button" type="submit" disabled={actionsBlocked}
>Piece duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="secondary-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece archivieren</button
>
</form>
</section>
</div>
+36
View File
@@ -0,0 +1,36 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
authenticate,
createSession,
hasUsers,
reserveLoginAttempt,
validEmail
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ locals }) => {
if (!hasUsers()) redirect(303, '/setup');
if (locals.user) redirect(303, '/fundus');
};
export const actions = {
default: async ({ request, cookies, getClientAddress }) => {
const data = await request.formData();
const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || '');
const address = getClientAddress();
if (!validEmail(email) || !password || Buffer.byteLength(password, 'utf8') > 256) {
return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
}
if (!reserveLoginAttempt(email, address)) {
return fail(429, {
email,
error: 'Zu viele Anmeldeversuche. Bitte in 15 Minuten erneut versuchen.'
});
}
const user = await authenticate(email, password);
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
createSession(user.id, cookies);
redirect(303, '/fundus');
}
} satisfies Actions;
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<svelte:head><title>Anmelden | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Redaktionszugang</p>
<h1>Bei Sendewerk anmelden</h1>
<p>Für Barkeeper und Thekencrew. Eine öffentliche Registrierung gibt es nicht.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
maxlength="256"
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Anmelden</button>
</form>
<a class="auth-link" href={resolve('/passwort-vergessen')}>Passwort vergessen?</a>
</section>
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { deleteSession } from '#lib/server/auth.js';
export const POST: RequestHandler = ({ cookies }) => {
deleteSession(cookies);
redirect(303, '/login');
};
+20
View File
@@ -0,0 +1,20 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getMediaFile } from '#lib/server/content.js';
export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user);
const media = await getMediaFile(params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'private, no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
@@ -0,0 +1,22 @@
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import { requestPasswordReset, reservePasswordResetRequest, validEmail } from '#lib/server/auth.js';
export const actions = {
default: async ({ request, getClientAddress }) => {
const data = await request.formData();
const email = String(data.get('email') || '').trim();
if (!validEmail(email))
return fail(400, { email, error: 'Bitte eine gültige E-Mail eingeben.' });
if (!reservePasswordResetRequest(email, getClientAddress())) {
return { sent: true };
}
try {
await requestPasswordReset(email);
} catch {
// Die sichtbare Antwort bleibt identisch, damit der Mailzustand kein Konto verrät.
console.error('Eine Passwortreset-Nachricht konnte nicht bereitgestellt werden.');
}
return { sent: true };
}
} satisfies Actions;
@@ -0,0 +1,31 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<svelte:head><title>Passwort vergessen | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Kontozugang</p>
<h1>Passwort zurücksetzen</h1>
<p>Wenn das Konto existiert, wird ein 24 Stunden gültiger, einmaliger Link bereitgestellt.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.sent}<div class="form-message success" role="status">
Anfrage verarbeitet. Bitte Postfach beziehungsweise lokale Outbox prüfen.
</div>{/if}
<form class="auth-form" method="POST">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<button class="primary-button" type="submit">Reset-Link anfordern</button>
</form>
<a class="auth-link" href={resolve('/login')}>Zurück zur Anmeldung</a>
</section>
@@ -0,0 +1,39 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
deleteLinkToken,
passwordResetIsValid,
readLinkToken,
RESET_COOKIE,
resetPassword,
storeLinkToken,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ url, cookies }) => {
const queryToken = url.searchParams.get('token');
if (queryToken) {
if (queryToken.length <= 100) storeLinkToken(cookies, RESET_COOKIE, queryToken);
else deleteLinkToken(cookies, RESET_COOKIE);
redirect(303, '/passwort-zuruecksetzen');
}
return { valid: passwordResetIsValid(readLinkToken(cookies, RESET_COOKIE)) };
};
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const token = readLinkToken(cookies, RESET_COOKIE);
const password = String(data.get('password') || '');
if (!validPassword(password)) {
return fail(400, { error: 'Das neue Passwort muss mindestens 12 Zeichen lang sein.' });
}
try {
await resetPassword(token, password);
deleteLinkToken(cookies, RESET_COOKIE);
} catch {
return fail(400, { error: 'Der Link ist ungültig, abgelaufen oder bereits verwendet.' });
}
redirect(303, '/login?reset=erfolgreich');
}
} satisfies Actions;
@@ -0,0 +1,33 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Neues Passwort | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Kontozugang</p>
<h1>Neues Passwort setzen</h1>
{#if !data.valid && !form?.error}
<div class="form-message error" role="alert">
Der Link ist ungültig, abgelaufen oder bereits verwendet.
</div>
<a class="auth-link" href={resolve('/passwort-vergessen')}>Neuen Link anfordern</a>
{:else}
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Neues Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<button class="primary-button" type="submit">Passwort speichern</button>
</form>
{/if}
</section>
+6 -17
View File
@@ -18,9 +18,10 @@
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div>
<p class="eyebrow">Nächste Sendung · {data.show.startsAtBerlin} Uhr</p>
<h1>{data.show.title}</h1>
<div class="show-heading-copy">
<h1>Sendungen</h1>
<p class="show-title">{data.show.title}</p>
<p class="show-date">{data.show.startsAtBerlin} Uhr</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Stücke</span></div>
@@ -57,15 +58,6 @@
</div>
<span>{data.pieces.filter((piece) => piece.status === 'ready').length}</span>
</header>
<label class="compact-search">
<span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span>
<input
type="search"
placeholder="Fundus durchsuchen"
disabled
title="Die vollständige Suche folgt in Etappe 3"
/>
</label>
<div class="source-list">
{#each data.pieces.filter((piece) => piece.status === 'ready') as piece (piece.id)}
<article class="source-card" style={`--category: ${piece.categoryColor}`}>
@@ -116,10 +108,9 @@
<div class="usage-list">
{#each section.usages.filter((usage) => !usage.isReserve) as usage (usage.id)}
<div class="usage-row">
<span class="drag-hint" aria-hidden="true">⠿</span>
<div>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · Eigene Sendungskopie</small
>{formatClock(usage.durationSeconds)} · Stand für diese Sendung</small
>
</div>
<span class:current={usage.status === 'current'} class="usage-status"
@@ -129,9 +120,7 @@
{/each}
</div>
{:else}
<div class="section-placeholder">
Eigenständiger Spieleblock · keine Piece-Kopie nötig
</div>
<div class="section-placeholder">Spieleblock ohne vorbereiteten Fundusinhalt</div>
{/if}
</article>
{/each}
+47
View File
@@ -0,0 +1,47 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
createFirstAdmin,
createSession,
hasUsers,
setupIsConfigured,
validDisplayName,
validEmail,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = () => {
if (hasUsers()) redirect(303, '/login');
return { configured: setupIsConfigured() };
};
export const actions = {
default: async ({ request, cookies }) => {
if (hasUsers()) redirect(303, '/login');
const data = await request.formData();
const setupToken = String(data.get('setupToken') || '');
const displayName = String(data.get('displayName') || '').trim();
const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || '');
if (!validDisplayName(displayName) || !validEmail(email) || !validPassword(password)) {
return fail(400, {
error: 'Bitte Name, gültige E-Mail und ein Passwort mit mindestens 12 Zeichen prüfen.',
displayName,
email
});
}
try {
const user = await createFirstAdmin({ setupToken, displayName, email, password });
createSession(user.id, cookies);
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'SETUP_CLOSED') redirect(303, '/login');
return fail(403, {
error: 'Das Betreiber-Setup-Token ist ungültig.',
displayName,
email
});
}
redirect(303, '/fundus');
}
} satisfies Actions;
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Erstadmin einrichten | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Einmalige Einrichtung</p>
<h1>Ersten Barkeeper anlegen</h1>
<p>
Dieses Konto verwaltet Einladungen, Rollen und Sperren. Danach wird die Einrichtung geschlossen.
</p>
{#if !data.configured}
<div class="form-message error" role="alert">
Vor dem Start muss ein mindestens 32 Zeichen langes <code>SENDEWERK_SETUP_TOKEN</code>
konfiguriert werden.
</div>
{:else}
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Betreiber-Setup-Token<input
name="setupToken"
type="password"
required
autocomplete="off"
/></label
>
<label
>Name<input
name="displayName"
required
maxlength="80"
value={form?.displayName ?? ''}
autocomplete="name"
/></label
>
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<p class="field-hint">
Mindestens 12 Zeichen. Das Passwort wird ausschließlich als Argon2id-Hash gespeichert.
</p>
<button class="primary-button" type="submit">Barkeeper anlegen</button>
</form>
{/if}
</section>
+133 -3
View File
@@ -1,5 +1,135 @@
import { getTaxonomy } from '#lib/server/previews.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
createTaxonomy,
deleteTaxonomy,
getTaxonomy,
type TaxonomyKind
} from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js';
import {
inviteUser,
isRole,
listOpenInvitations,
listUsers,
requireBarkeeper,
revokeInvitation,
setUserLocked,
setUserRole,
validEmail
} from '#lib/server/auth.js';
export function load() {
return { taxonomy: getTaxonomy() };
export const load: PageServerLoad = ({ locals }) => {
requireBarkeeper(locals.user);
return {
taxonomy: getTaxonomy(),
users: listUsers(),
invitations: listOpenInvitations(),
mailTransport: getMailConfig().transport
};
};
function actionError(cause: unknown): string {
const code = cause instanceof Error ? cause.message : '';
if (code === 'USER_EXISTS') return 'Für diese E-Mail existiert bereits ein Konto.';
if (code === 'SELF_LOCK') return 'Das eigene Konto kann nicht gesperrt werden.';
if (code === 'LAST_BARKEEPER') return 'Mindestens ein aktiver Barkeeper muss erhalten bleiben.';
if (code === 'NOT_FOUND') return 'Der Eintrag wurde nicht gefunden.';
if (code === 'TAXONOMY_EXISTS') return 'Dieser Name ist bereits vorhanden.';
if (code === 'TAXONOMY_IN_USE')
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.';
if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.';
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
export const actions = {
invite: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
const email = String(data.get('email') || '').trim();
const role = String(data.get('role') || '');
if (!validEmail(email) || !isRole(role)) {
return fail(400, { intent: 'invite', email, error: 'E-Mail und Rolle bitte prüfen.' });
}
try {
const transport = await inviteUser(actor, email, role);
return {
intent: 'invite',
success:
transport === 'local'
? 'Einladung wurde in der lokalen Mail-Outbox abgelegt.'
: 'Einladung wurde versandt.'
};
} catch (cause) {
return fail(400, { intent: 'invite', email, error: actionError(cause) });
}
},
role: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
const userId = String(data.get('userId') || '');
const role = String(data.get('role') || '');
if (!userId || !isRole(role)) return fail(400, { intent: 'role', error: 'Ungültige Rolle.' });
try {
setUserRole(actor, userId, role);
return { intent: 'role', success: 'Rolle aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'role', error: actionError(cause) });
}
},
lock: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
setUserLocked(actor, String(data.get('userId') || ''), true);
return { intent: 'lock', success: 'Konto gesperrt und bestehende Sitzungen beendet.' };
} catch (cause) {
return fail(400, { intent: 'lock', error: actionError(cause) });
}
},
unlock: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
setUserLocked(actor, String(data.get('userId') || ''), false);
return { intent: 'unlock', success: 'Konto entsperrt.' };
} catch (cause) {
return fail(400, { intent: 'unlock', error: actionError(cause) });
}
},
revokeInvitation: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
revokeInvitation(actor, String(data.get('invitationId') || ''));
return { intent: 'revokeInvitation', success: 'Einladung widerrufen.' };
},
createTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
const kind = String(data.get('kind') || '') as TaxonomyKind;
if (!['category', 'format', 'topic'].includes(kind)) {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
}
try {
createTaxonomy(kind, String(data.get('name') || ''), String(data.get('color') || ''));
return { intent: 'taxonomy', success: 'Eintrag angelegt.' };
} catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
}
},
deleteTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
const kind = String(data.get('kind') || '') as TaxonomyKind;
if (!['category', 'format', 'topic'].includes(kind)) {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
}
try {
deleteTaxonomy(kind, String(data.get('taxonomyId') || ''));
return { intent: 'taxonomy', success: 'Eintrag entfernt.' };
} catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
}
}
} satisfies Actions;
+174 -21
View File
@@ -1,50 +1,203 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
}
</script>
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">System & Struktur</p>
<h1>Verwaltung</h1>
<p class="page-intro">
Die bereits angelegte Taxonomie ist sichtbar; Bearbeitung und Benutzerverwaltung folgen mit
serverseitigen Rechten.
</p>
<p class="page-intro">Konten, Rollen und bestehende Taxonomie verwalten.</p>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section class="quiet-panel account-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Konten & Rollen</p>
<h2>Redaktionszugänge</h2>
</div>
<span class="mail-mode"
>Mail: {data.mailTransport === 'local' ? 'lokale Outbox' : 'SMTP'}</span
>
</div>
<div class="account-list">
{#each data.users as user (user.id)}
<article class="account-row">
<div><strong>{user.displayName}</strong><small>{user.email}</small></div>
<span class:locked={user.lockedAtUtc} class="account-status"
>{user.lockedAtUtc ? 'Gesperrt' : 'Aktiv'}</span
>
<form method="POST" action="?/role" class="inline-account-form">
<input type="hidden" name="userId" value={user.id} />
<label class="sr-only" for={`role-${user.id}`}>Rolle für {user.displayName}</label>
<select id={`role-${user.id}`} name="role">
<option value="barkeeper" selected={user.role === 'barkeeper'}>Barkeeper</option>
<option value="thekencrew" selected={user.role === 'thekencrew'}>Thekencrew</option>
</select>
<button class="text-button" type="submit">Rolle speichern</button>
</form>
<form method="POST" action={user.lockedAtUtc ? '?/unlock' : '?/lock'}>
<input type="hidden" name="userId" value={user.id} />
<button
class="secondary-button"
type="submit"
disabled={user.id === data.user?.id}
title={user.id === data.user?.id
? 'Das eigene Konto kann nicht gesperrt werden.'
: undefined}
>{user.lockedAtUtc
? 'Entsperren'
: user.id === data.user?.id
? 'Eigenes Konto'
: 'Sperren'}</button
>
</form>
</article>
{/each}
</div>
</section>
<div class="management-grid">
<section class="quiet-panel invite-panel">
<p class="eyebrow">Einladung</p>
<h2>Neues Konto einladen</h2>
<form class="compact-form" method="POST" action="?/invite">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.intent === 'invite' ? (form.email ?? '') : ''}
/></label
>
<label
>Rolle<select name="role"
><option value="thekencrew">Thekencrew</option><option value="barkeeper"
>Barkeeper</option
></select
></label
>
<button class="primary-button" type="submit">Einladung erstellen</button>
</form>
</section>
<section class="quiet-panel invite-panel">
<p class="eyebrow">Offene Einladungen</p>
<h2>Noch nicht angenommen</h2>
{#if data.invitations.length}
<div class="invitation-list">
{#each data.invitations as invitation (invitation.id)}
<div>
<span
><strong>{invitation.email}</strong><small
>{invitation.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small
></span
>
<form method="POST" action="?/revokeInvitation">
<input type="hidden" name="invitationId" value={invitation.id} /><button
class="text-button"
type="submit">Widerrufen</button
>
</form>
</div>
{/each}
</div>
{:else}<p class="quiet-copy">Keine offenen Einladungen.</p>{/if}
</section>
</div>
<h2 class="section-heading">Taxonomie</h2>
<div class="settings-grid">
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Abschnittskategorien</p>
<h2>Struktur im Ablauf</h2>
<div class="taxonomy-list">
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.categories as category (category.id)}<span
><i style={`--tax-color: ${category.color}`}></i>{category.name}</span
><i style={`--tax-color: ${category.color}`}></i>{category.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="category" /><input
type="hidden"
name="taxonomyId"
value={category.id}
/><button type="submit" aria-label={`${category.name} entfernen`}>×</button>
</form></span
>{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="category" /><label
><span class="sr-only">Neue Abschnittskategorie</span><input
name="name"
required
maxlength="80"
placeholder="Neue Kategorie"
/></label
><label class="color-field"
><span class="sr-only">Farbe</span><input
name="color"
type="color"
value="#67DDE8"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Formate</p>
<h2>Form des Stücks</h2>
<div class="taxonomy-list">
{#each data.taxonomy.formats as format (format.id)}<span>{format.name}</span>{/each}
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.formats as format (format.id)}<span
>{format.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="format" /><input
type="hidden"
name="taxonomyId"
value={format.id}
/><button type="submit" aria-label={`${format.name} entfernen`}>×</button>
</form></span
>{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="format" /><label
><span class="sr-only">Neues Format</span><input
name="name"
required
maxlength="80"
placeholder="Neues Format"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2>
<div class="taxonomy-list">
{#each data.taxonomy.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.topics as topic (topic.id)}<span
>{topic.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="topic" /><input
type="hidden"
name="taxonomyId"
value={topic.id}
/><button type="submit" aria-label={`${topic.name} entfernen`}>×</button>
</form></span
>{/each}
</div>
</section>
<section class="quiet-panel planned-settings">
<p class="eyebrow">Etappe 2</p>
<h2>Konten & Rollen</h2>
<p>
Barkeeper, Thekencrew und technische Zugänge werden erst mit vollständiger Authentifizierung
verwaltbar.
</p>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="topic" /><label
><span class="sr-only">Neues Thema</span><input
name="name"
required
maxlength="80"
placeholder="Neues Thema"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
</div>
</div>
+10 -1
View File
@@ -2,11 +2,20 @@
set -eu
mkdir -p /app/data/uploads
mkdir -p /app/data/uploads /app/data/mail-outbox
chown -R cloudron:cloudron /app/data
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}"
export SENDEWERK_MAIL_OUTBOX_DIR="${SENDEWERK_MAIL_OUTBOX_DIR:-/app/data/mail-outbox}"
export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-6M}"
export SENDEWERK_APP_ORIGIN="${SENDEWERK_APP_ORIGIN:-${CLOUDRON_APP_ORIGIN:-}}"
if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then
export PROTOCOL_HEADER="${PROTOCOL_HEADER:-x-forwarded-proto}"
export HOST_HEADER="${HOST_HEADER:-x-forwarded-host}"
export ADDRESS_HEADER="${ADDRESS_HEADER:-x-forwarded-for}"
export XFF_DEPTH="${XFF_DEPTH:-1}"
fi
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-3000}"
+430 -34
View File
@@ -1,77 +1,362 @@
import { expect, test } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
test('desktop fundus is usable, filterable and keeps its theme', async ({ page }, testInfo) => {
const adminEmail = 'barkeeper@example.test';
const adminPassword = 'Nur-fuer-E2E-Admin-1234';
const crewEmail = 'crew@example.test';
const crewPassword = 'Nur-fuer-E2E-Crew-1234';
const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live'];
const viewports = [
{ width: 390, height: 844, theme: 'light' },
{ width: 390, height: 844, theme: 'dark' },
{ width: 768, height: 900, theme: 'light' },
{ width: 768, height: 900, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'light' }
] as const;
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
}
async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
await page.getByLabel('Passwort', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL(/\/fundus$/);
}
function latestMailLink(subject: string): string {
const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR;
if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.');
const messages = readdirSync(outbox)
.sort()
.map(
(file) =>
JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as {
subject: string;
text: string;
}
)
.filter((message) => message.subject === subject);
const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/);
if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`);
return match[0];
}
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await page.goto('/');
if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) {
await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters');
await page.getByLabel('Name').fill('E2E Barkeeper');
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Barkeeper anlegen' }).click();
} else {
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Anmelden' }).click();
}
await expect(page).toHaveURL(/\/fundus$/);
const sessionCookie = (await page.context().cookies()).find(
(cookie) => cookie.name === 'sendewerk_session'
);
expect(sessionCookie?.httpOnly).toBe(true);
expect(sessionCookie?.secure).toBe(false);
expect(sessionCookie?.sameSite).toBe('Lax');
await page.goto('/setup');
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('button', { name: 'Abmelden', exact: true }).click();
await expect(page).toHaveURL(/\/login$/);
await page.close();
});
test.beforeEach(async ({ page }) => {
await login(page);
});
test('invitations, direct authorization, locking and password reset work end to end', async ({
page,
browser
}, testInfo) => {
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-konten-desktop.png'),
fullPage: true
});
const crossSite = await page.request.post('/verwaltung?/invite', {
form: { email: 'cross-site@example.test', role: 'barkeeper' },
headers: { origin: 'https://example.invalid' }
});
expect(crossSite.status()).toBe(403);
const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' });
await inviteForm.getByLabel('E-Mail').fill(crewEmail);
await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click();
await expect(
page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.')
).toBeVisible();
const crewContext = await browser.newContext();
const crewPage = await crewContext.newPage();
await crewPage.goto(latestMailLink('Einladung zu Sendewerk'));
await expect(crewPage).toHaveURL(/\/einladung$/);
await crewPage.getByLabel('Name').fill('E2E Crew');
await crewPage.getByLabel('Passwort').fill(crewPassword);
await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click();
await expect(crewPage).toHaveURL(/\/fundus$/);
await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0);
const forbidden = await crewPage.request.post('/verwaltung?/invite', {
form: { email: 'verboten@example.test', role: 'barkeeper' },
headers: { origin: 'http://127.0.0.1:4173' }
});
expect(forbidden.status()).toBe(403);
await page.reload();
const crewRow = page.locator('.account-row').filter({ hasText: crewEmail });
await crewRow.getByRole('button', { name: 'Sperren' }).click();
await crewPage.goto('/fundus');
await expect(crewPage).toHaveURL(/\/login$/);
await page.reload();
await page
.locator('.account-row')
.filter({ hasText: crewEmail })
.getByRole('button', { name: 'Entsperren' })
.click();
await crewPage.goto('/passwort-vergessen');
await crewPage.getByLabel('E-Mail').fill(crewEmail);
await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click();
await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible();
await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen'));
await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/);
await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234');
await crewPage.getByRole('button', { name: 'Passwort speichern' }).click();
await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234');
await crewContext.close();
const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
const loginPage = await anonymousContext.newPage();
await loginPage.goto('/login');
await setTheme(loginPage, 'dark');
await loginPage.reload();
await expectNoHorizontalOverflow(loginPage);
await loginPage.screenshot({
path: testInfo.outputPath('login-mobile-dark.png'),
fullPage: true
});
await anonymousContext.close();
});
test('desktop fundus is a compact, filterable list with persistent themes', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus');
await expect(
page.getByRole('heading', { name: 'Gute Stücke, sofort griffbereit.' })
).toBeVisible();
await expect(page.getByText('Gestaltungsvorschau: Fundus')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible();
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
await expect(page.locator('.piece-card')).toHaveCount(4);
const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
expect(firstRow?.x).toBe(secondRow?.x);
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130);
const searchBox = await page.locator('.search-field').boundingBox();
const statusBox = await page.getByLabel('Status').boundingBox();
expect(searchBox?.height).toBe(statusBox?.height);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await page.getByRole('button', { name: 'Anwenden' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'Pixelart, die nicht nostalgisch sein will' })
).toBeVisible();
await page.getByLabel('Status').selectOption('preparing');
await page.getByRole('button', { name: 'Anwenden' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible();
await page.getByRole('link', { name: 'Filter zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.getByRole('button', { name: 'Darstellung: System' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible();
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop.png'), fullPage: true });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
});
test('desktop builder shows fundus and run side by side', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/sendungen');
await expect(page.getByText('Gestaltungsvorschau: Sendungsbau')).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('heading', { name: 'Sendungen', exact: true })).toBeVisible();
await expect(page.getByText('Herbstsendung: Kleine Welten', { exact: true })).toBeVisible();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
await expect(
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
).toBeVisible();
const boxes = await Promise.all([
page.locator('.source-pane').boundingBox(),
page.locator('.run-pane').boundingBox()
]);
expect(boxes[0]?.x).toBeLessThan(boxes[1]?.x ?? 0);
const sourceBox = await page.locator('.source-pane').boundingBox();
const runBox = await page.locator('.run-pane').boundingBox();
expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0);
expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-desktop-dark.png'),
fullPage: true
});
});
test('mobile builder switches panes without horizontal scrolling', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => {
await page.setViewportSize({ width: 960, height: 850 });
await page.goto('/sendungen');
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.mobile-pane-switch')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
});
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
test('mobile and tablet navigation do not obscure switchable content', async ({
page
}, testInfo) => {
for (const width of [390, 768]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/sendungen');
await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const verticalSpace = await page.evaluate(() => {
const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect();
const content = document.querySelector('.source-pane')?.getBoundingClientRect();
return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 };
});
expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1);
}
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/sendungen');
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
});
test('live preview exposes readable prompts and working text controls', async ({ page }) => {
test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => {
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/fundus');
await setTheme(page, viewport.theme);
for (const route of routes) {
await page.goto(route);
await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme);
await expectNoHorizontalOverflow(page);
}
}
await page.setViewportSize({ width: 768, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-tablet-dark.png'),
fullPage: true
});
});
test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.evaluate(() => {
const list = document.querySelector('.piece-grid');
const rows = Array.from(document.querySelectorAll('.piece-card'));
const title = rows[0]?.querySelector('h2');
if (title) {
title.textContent =
'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen';
}
for (let repetition = 0; repetition < 3; repetition += 1) {
for (const row of rows) list?.append(row.cloneNode(true));
}
});
await expect(page.locator('.piece-card')).toHaveCount(16);
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column');
const mobileCard = await page.locator('.piece-card').first().boundingBox();
const mobilePrimary = await page.locator('.piece-primary').first().boundingBox();
expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40);
await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText(
'Ein außergewöhnlich langer redaktioneller Titel'
);
await page.screenshot({
path: testInfo.outputPath('fundus-mobile-long-content.png'),
fullPage: true
});
});
test('inbox, archive and administration stay concise', async ({ page }) => {
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/eingang');
await expect(page.locator('.capture-panel')).toBeVisible();
await expect(page.locator('.inbox-item')).toHaveCount(1);
await expectNoHorizontalOverflow(page);
await page.goto('/archiv');
await expect(page.locator('.archive-list article')).toHaveCount(1);
await expectNoHorizontalOverflow(page);
}
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/verwaltung');
const panels = page.locator('.taxonomy-panel');
await expect(panels).toHaveCount(3);
const boxes = await Promise.all([
panels.nth(0).boundingBox(),
panels.nth(1).boundingBox(),
panels.nth(2).boundingBox()
]);
expect(boxes[0]?.y).toBe(boxes[1]?.y);
expect(boxes[1]?.y).toBe(boxes[2]?.y);
});
test('live preview retains its existing readable controls', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/live');
await expect(page.getByText('Gestaltungsvorschau: Live-Modus')).toBeVisible();
await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible();
await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible();
const before = await page
.locator('.live-current')
@@ -83,4 +368,115 @@ test('live preview exposes readable prompts and working text controls', async ({
expect(after).not.toBe(before);
await page.getByText('Recherche öffnen').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await expectNoHorizontalOverflow(page);
});
test('pieces autosave safely, detect conflicts and protect uploaded images', async ({
page,
browser
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus');
await page.getByText('Neues Piece anlegen').click();
await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url();
await page.getByLabel('Titel').fill('E2E Fundstück mit Markdown');
await page.getByRole('link', { name: '← Fundus', exact: true }).click();
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl);
await page
.getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Sekunden').fill('615');
await page.getByLabel('Gaming').check();
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await page
.getByLabel('Präsentationstext')
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last();
await source.getByLabel('Name').fill('Originalbeitrag');
await source.getByLabel('URL').fill('https://example.com/e2e-fundstueck');
await source.getByLabel('Zeitmarke in Sekunden').fill('42');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'redaktionsbild.png',
mimeType: 'image/png',
buffer: png
});
await page.getByLabel('Alternativtext').fill('Ein Testpixel');
await page.getByRole('button', { name: 'Bild hochladen' }).click();
const image = page.locator('.media-grid img');
await expect(image).toHaveCount(1);
await expect(image).toHaveAttribute('alt', 'Ein Testpixel');
const mediaUrl = await image.getAttribute('src');
const anonymous = await browser.newContext();
const anonymousResponse = await anonymous.request.get(new URL(mediaUrl!, page.url()).toString(), {
maxRedirects: 0
});
expect(anonymousResponse.status()).toBe(303);
await anonymous.close();
const concurrent = await page.context().newPage();
await concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
await expect(concurrent.locator('.save-indicator')).toContainText('neuere Änderung', {
timeout: 10_000
});
await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
await concurrent.close();
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).toBeVisible();
await page.getByRole('link', { name: 'Bearbeiten' }).click();
await page.getByRole('button', { name: 'Piece archivieren' }).click();
await expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
await expect(archived).toBeVisible();
await archived.getByRole('button', { name: 'Wiederherstellen' }).click();
await expect(archived).toHaveCount(0);
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click();
await expect(page.getByLabel('Titel')).toHaveValue(
'Kopie von E2E Fundstück, extern aktualisiert'
);
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
fullPage: true
});
});
+4 -1
View File
@@ -9,7 +9,10 @@ export default defineConfig({
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
adapter: adapter(),
paths: {
origin: process.env.SENDEWERK_APP_ORIGIN
}
})
],
test: {