2 Commits
Author SHA1 Message Date
hnzio 6d96900d81 Release 0.7.0 2026-10-25 18:40:01 +01:00
hnzio d586fbd8ce Release 0.6.1 2026-10-25 18:18:23 +01:00
47 changed files with 2443 additions and 76 deletions

No files matched your search

+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.6.0",
"version": "0.7.0",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+56
View File
@@ -1,5 +1,61 @@
# Abnahme
## Etappe 7
| Kriterium | Nachweis | Stand |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| Feedverwaltung und geplanter/manueller Abruf | Barkeeper-Oberfläche, Prozess-Scheduler und gemeinsame Abruffunktion | erfüllt am 25.10.2026 |
| Getrennter Vorschau-Eingang | RSS-Beiträge erscheinen separat und werden nur nach Mehrfachauswahl zu Pieces | erfüllt am 25.10.2026 |
| Kategoriezuordnung und Herkunft | Auswahlkategorien oder Feed-Standard sowie Original-URL als Piece-Quelle im Verhaltenstest | erfüllt am 25.10.2026 |
| Dubletten und dauerhaftes Verwerfen | wiederholter Testabruf erzeugt keinen zweiten Eintrag und stellt Verworfenes nicht wieder her | erfüllt am 25.10.2026 |
| HTML-Bereinigung und Abrufgrenzen | Testfeed entfernt fremdes HTML; Loopback wird vor Request blockiert; Größe, Zeit, Redirects und Eintragszahl sind begrenzt | erfüllt am 25.10.2026 |
| Fehlerisolation | defekter lokaler Testfeed wird protokolliert, weiterer fälliger Feed trotzdem importiert | erfüllt am 25.10.2026 |
| Versionierte REST-API und OpenAPI | `/api/v1` für Pieces, Sendungen und Einplanungen; `/api/v1/openapi.json` | erfüllt am 25.10.2026 |
| Begrenzte und widerrufbare Zugänge | gehashte Tokens; Verhaltenstest für Lesen, verweigertes Schreiben und sofortigen Widerruf | erfüllt am 25.10.2026 |
| Änderungsnachweis und Idempotenz | Audit-Ereignisse und persistente Idempotenz; identische Wiederholung liefert dasselbe Ergebnis | erfüllt am 25.10.2026 |
| API-Rundlauf | echter HTTP-Test: lesen, anlegen, ändern, erneut lesen, einplanen und sortieren | erfüllt am 25.10.2026 |
| Nutzung ohne KI | RSS und API sind optional; bestehende Redaktion funktioniert unverändert ohne externen Client | erfüllt am 25.10.2026 |
| Konkrete ChatGPT-/Codex-Anbindung | keine Clientumgebung oder Zugangsdaten vorhanden | offen |
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 58 von 58 Verhaltenstests erfolgreich.
- Gezielte Etappe-7-Tests: 6 von 6 erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm audit --omit=dev`: 0 gemeldete Schwachstellen.
- Gezielte Chromium-Prüfung: Desktop-Verwaltung, mobiler Eingang und kompletter
HTTP-API-Rundlauf erfolgreich, keine Konsolenfehler.
- `npm run test:e2e`: auf dem Standardport wegen eines bereits laufenden fremden
Prozesses nicht startbar; identische temporäre Konfiguration auf Port 4188 mit
16 von 18 erfolgreichen Abläufen. Bekannte Fehler bleiben bei Live-Zeitanzeige
und Abschluss/Freigabe bestehen.
- `npm run lint`: nicht erfolgreich wegen acht bereits vorhandener
Prettier-Abweichungen außerhalb der Etappe-7-Dateien; geänderte Dateien wurden
separat formatiert.
- Dokumentationsstatus: **API/MCP getestet, Client-Anbindung offen**. Konkret ist
die REST-API getestet; ein zusätzlicher MCP-Adapter wurde nicht benötigt und
keine echte ChatGPT-/Codex-Verbindung hergestellt.
## Wartungsrelease 0.6.1
| Bereich | Stand |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Live-Timer, Kopfzeile, Drawer und Positionsanzeige | behoben |
| Mobile Formulare und Iconsuche | behoben |
| Vorbereitungsaufwand und Übersicht | behoben |
| Bild-Präsentationsmodus | behoben |
| Twitch-Tags je Abschnitt | behoben |
| Sendungskarten und Gastabstände | behoben |
| Gemeinsamer Freigabedialog einschließlich Live-Regie | behoben |
| Editorzustand und Drag-and-drop | bestehende zustandserhaltende bzw. alternative Bedienwege unverändert; Browserprüfung offen |
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 52 von 52 Verhaltenstests erfolgreich.
- `npm run build`: erfolgreich.
- `npm run test:e2e`: zweiter Lauf mit 14 erfolgreichen Abläufen; Live-Regie und
Abschluss/Freigaben schlugen fehl, zwei nachfolgende Abläufe wurden wegen des
120-Sekunden-Zeitlimits nicht mehr ausgeführt. Die Browsermatrix ist offen.
- Safari auf echtem iOS-Gerät: nicht verfügbar.
## Etappe 1
| Kriterium | Nachweis | Stand |
+52 -6
View File
@@ -3,7 +3,7 @@
## Systemgrenze
Sendewerk ist ein einzelner SvelteKit-Prozess. Browserseiten, serverseitige
Actions, spätere REST-Endpunkte und Hintergrundaufgaben verwenden dieselbe
Actions, REST-Endpunkte und Hintergrundaufgaben verwenden dieselbe
Geschäftslogik. SQLite und ein lokales Upload-Verzeichnis sind die einzigen
persistenten Laufzeitabhängigkeiten. Externe Mail-, RSS- oder KI-Dienste sind
nicht Voraussetzung für Kernfunktionen.
@@ -11,7 +11,7 @@ nicht Voraussetzung für Kernfunktionen.
```text
Browser -> SvelteKit-Routen -> Anwendungsdienste -> SQLite
| -> Upload-Verzeichnis
+-> später: Mail/RSS über Adapter
+-> Mail/RSS über begrenzte Adapter
```
Servercode lebt unter `src/lib/server` und darf nicht in Browser-Bundles
@@ -33,13 +33,13 @@ spätere API dieselben Regeln verwenden.
## Gesamtdatenmodell
Die ersten sechs Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung, Live-Durchführung, Historie und Lesefreigaben. Folgende Grenzen
gelten für umgesetzte und spätere Bereiche.
Die ersten sieben Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung, Live-Durchführung, Historie, Lesefreigaben, RSS-Import und eine
technische API. Folgende Grenzen gelten für umgesetzte und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sowie getrennte technische Zugänge |
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
@@ -99,6 +99,43 @@ 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.
Technische Zugänge sind keine Browsersitzungen. Sie besitzen zufällige
Bearer-Tokens, von denen nur SHA-256-Hashes gespeichert werden, und genau einen
der Umfänge `read` oder `read_write`. Widerruf wirkt beim nächsten Request.
`/api/v1` umgeht lediglich den Browser-Loginredirect; jeder Endpunkt prüft Token
und erforderlichen Umfang selbst. Schreibzugriffe hinterlassen ein
`audit_event`; Neuanlage und Planungsänderungen verlangen zusätzlich einen je
Zugang eindeutigen, persistent gespeicherten Idempotenzschlüssel.
## RSS-Import
`src/lib/server/feeds.ts` bildet Abruf, XML-Verarbeitung und Übernahme gemeinsam
für manuelle Aktionen und den Prozess-Scheduler ab. Der Scheduler startet beim
ersten Request und prüft minütlich höchstens zehn fällige aktive Feeds. Jeder
Feed wird getrennt behandelt, sodass ein Fehler weitere Abrufe nicht stoppt.
HTTP-Abrufe erlauben nur HTTP(S) auf Standardports, prüfen DNS sowie jedes
Redirectziel gegen interne Adressen und begrenzen Laufzeit, Redirects und
Antwortgröße. RSS/Atom wird mit deaktivierter Entity-Verarbeitung gelesen;
höchstens 200 Einträge je Antwort werden berücksichtigt und fremdes HTML wird zu
reinem Text reduziert.
`feed_entries` ist ein eigener Eingang. `(feed_id, external_id)` verhindert
Dubletten; Aktualisierungen ändern den redaktionellen Status nicht. `discarded`
bleibt deshalb auch nach späteren Abrufen erhalten. Erst die bewusste
Mehrfachauswahl erzeugt innerhalb einer Transaktion normale Inbox-Pieces mit
Kategorien und Original-URL als Quelle und setzt den Eintrag auf `imported`.
## Technische REST-API
Die Routen unter `/api/v1` bilden keine zweite Fachlogik, sondern rufen
`content.ts` und `shows.ts` auf. Piece- und Sendungsrevisionen schützen weiterhin
vor verlorenen Änderungen. `api_idempotency` speichert Request-Hash, Status und
JSON-Antwort, sodass wiederholte Schreibbefehle dasselbe Ergebnis liefern und
ein gleicher Schlüssel mit verändertem Inhalt abgewiesen wird. Die OpenAPI-3.1-
Beschreibung ist unter `/api/v1/openapi.json` erreichbar. Ein MCP-Adapter oder
produktspezifischer KI-Client gehört nicht zur Laufzeit und ist nicht
Voraussetzung für die Bedienung.
## Inhalte, Markdown und Medien
Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen
@@ -404,3 +441,12 @@ betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für
Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach
DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits.
## Wartungsfelder 0.6.1
Migration `0017` ergänzt den optionalen Vorbereitungsaufwand am Original und
kommagetrennt validierte Twitch-Tags am Abschnitt. Der Bildmodus wird wegen der
bestehenden SQLite-Check-Constraints als zusätzliches Kennzeichen an Original
und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus
`image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte
Präsentationsmedium.
+41
View File
@@ -451,3 +451,44 @@ oder URL zu wechseln. Bearbeitungen behalten den Link, invalidieren aber
vorhandene Passwortsitzungen. Vollständiges Löschen ist der unmittelbare
Widerruf. Die Sendungsbearbeitung und die zentrale Verwaltung verwenden dieselbe
Geschäftslogik und bleiben Barkeepern vorbehalten.
## D-028: Additive Wartungsfelder und gemeinsamer Freigabedialog
Vorbereitungsaufwand und Twitch-Tags sind optionale additive Felder. Der neue
Bildmodus verwendet ein separates boolesches Kennzeichen, damit bestehende
Tabellen und Daten trotz älterer Modus-Check-Constraints ohne riskanten
Tabellenumbau erhalten bleiben. Freigabeverwaltung bleibt eine gemeinsame
Komponente und öffnet in allen drei internen Einstiegen als nativer Dialog.
## D-029: RSS als persistenter Eingang mit begrenztem Abruf
Externe Feeds schreiben nie direkt in den Fundus. Quelle und stabiler
Eintragsschlüssel werden in einem eigenen Eingang gespeichert; dessen Zustand
`discarded` oder `imported` wird bei einem erneuten Abruf nicht zurückgesetzt.
Erst eine bewusste Auswahl erzeugt über `content.ts` ein normales Piece samt
Originalquelle und gewählten Kategorien. Dadurch bleiben redaktionelle
Entscheidung, Dublettenschutz und Herkunft nachvollziehbar.
Manueller und geplanter Abruf verwenden denselben Adapter. Wie bei
Linkmetadaten werden private Ziele, Nichtstandardports und ungeprüfte Redirects
gesperrt; zusätzlich gelten enge Zeit-, Größen- und Mengenlimits. XML-Entities
bleiben deaktiviert und fremdes HTML wird nicht gespeichert. Ein Fehler wird am
betroffenen Feed persistiert und unterbricht keine weiteren fälligen Feeds.
## D-030: Technische Tokens, Scopes und persistente Idempotenz
Externe Assistenz erhält keine simulierte Redaktionssitzung und keine
Sonderrechte. Eigene zufällige Bearer-Tokens liegen nur gehasht vor, sind sofort
widerrufbar und erlauben entweder Lesen oder Lesen und Schreiben. REST-Routen
unter `/api/v1` verwenden die bestehenden Inhalts- und Sendungsdienste, damit
Validierung, Revisionen und Transaktionen identisch bleiben. Jede erfolgreiche
Mutation wird mit technischem Akteur, Aktion und Ziel protokolliert.
Neuanlage, Einplanung und Sortierung verlangen einen `Idempotency-Key`.
Zugang, Schlüssel, Hash des Requestinhalts und vollständige Antwort werden in
derselben SQLite-Transaktion gespeichert. Eine identische Wiederholung erhält
dieselbe Antwort; ein veränderter Request mit demselben Schlüssel wird
abgewiesen. OpenAPI 3.1 beschreibt diese Grenze maschinenlesbar. Ein MCP-Adapter
oder ein bestimmter KI-Client wird nicht vorgetäuscht: Die Anwendung bleibt ohne
KI vollständig nutzbar und eine konkrete Client-Verbindung muss separat geprüft
werden.
+32
View File
@@ -1,5 +1,37 @@
# Release Notes
## 0.7.0 - RSS-Import und externe Assistenz
Veröffentlicht am 25. Oktober 2026
- Barkeeper verwalten RSS-/Atom-Feeds mit Intervall und Standardkategorie;
sichere manuelle und geplante Abrufe laufen fehlerisoliert.
- Neue Beiträge landen in einem getrennten Auswahlbereich im Eingang. Erst eine
bewusste Übernahme erzeugt Pieces, während dauerhaftes Verwerfen, Herkunft,
Kategorien und Dublettenschutz erhalten bleiben.
- Abrufe sperren interne Ziele und begrenzen Ports, Redirects, Laufzeit,
Antwortgröße und Eintragszahl. Fremdes Feed-HTML wird zu Text bereinigt.
- Die versionierte REST-API `/api/v1` liest und ändert Pieces, Sendungen und
Einplanungen über getrennte technische Lese- oder Schreibzugänge.
- Technische Tokens werden nur einmal gezeigt und gehasht gespeichert. Widerruf,
Audit-Ereignisse, Revisionen und persistente Idempotenz schützen Änderungen.
- `/api/v1/openapi.json` beschreibt die Schnittstelle für externe Clients.
Sendewerk bleibt ohne KI nutzbar; eine konkrete ChatGPT-/Codex-Anbindung ist
weiterhin offen.
- Migration `0018_rss_and_api.sql` ergänzt Feed-, Zugangs-, Audit- und
Idempotenzdaten. Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.0`.
## 0.6.1 - Redaktionelle Wartung
Veröffentlicht am 25. Oktober 2026
- Live-Countdowns übernehmen geänderte Abschnittsplanzeiten, ohne Laufzeit zu verlieren; die mobile Regie gruppiert ihre Kopfaktionen robuster und zeigt den Ablauf als oberstes Sheet.
- Mobile Formularfelder und die Iconsuche vermeiden iOS-Fokuszoom; Sendungskarten und Gastansicht erhalten klarere Abstände.
- Pieces führen optionalen Vorbereitungsaufwand, die Sendungsübersicht bündelt offene Vorbereitung und Abschnitte speichern kopierbare Twitch-Tags.
- Präsentationen können ein ausdrücklich gewähltes Bild ohne Begleittext ausgeben; Originale und Sendungsfassungen bleiben getrennte Snapshots.
- Lesefreigaben öffnen in Sendung, Verwaltung und Live-Regie über denselben Dialog.
- Migration `0017_maintenance_fields.sql` ergänzt ausschließlich optionale, rückwärtskompatible Felder.
## 0.6.0 - Abschluss, Archiv und Freigaben
Veröffentlicht am 25. Oktober 2026
+45 -2
View File
@@ -2,10 +2,53 @@
## Aktueller Umfang
Etappe 1 bis Etappe 6 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Etappe 1 bis Etappe 7 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv und sichere Lesefreigaben sind dokumentiert.
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert.
## RSS-Import und externe Assistenz 0.7.0
- Barkeeper verwalten RSS-/Atom-Feeds mit Standardkategorie, Aktivierung und
Abrufintervall; manueller Abruf und ein begrenzter Prozess-Scheduler verwenden
dieselbe Fehler- und Dublettenlogik.
- Feedabrufe prüfen Protokoll, Port, DNS und Redirects, begrenzen Laufzeit,
Antwortgröße und Eintragszahl und lassen Fehler anderer Feeds unberührt.
- Fremdes HTML wird entfernt. Beiträge bleiben mit stabiler Herkunft im
getrennten Importeingang, bis die Redaktion sie auswählt, kategorisiert und
als normale Pieces übernimmt oder dauerhaft verwirft.
- `/api/v1` bietet Pieces und Sendungen über gehashte, widerrufbare technische
Bearer-Tokens mit reinen Lese- oder Lese-/Schreibrechten an. Mutationen nutzen
bestehende Dienste, Audit-Ereignisse, Revisionen und persistente
Idempotenzschlüssel.
- Die maschinenlesbare Beschreibung liegt unter `/api/v1/openapi.json`; eine
ChatGPT-/Codex- oder andere konkrete Client-Anbindung wurde nicht eingerichtet.
- Migration `0018_rss_and_api.sql` ergänzt ausschließlich additive Tabellen und
Indizes. Sendewerk bleibt ohne KI und ohne konfigurierte Feeds vollständig
nutzbar.
Geprüft wurden 58 Verhaltenstests, Typprüfung, Produktionsbuild, Dependency-
Audit, vier RSS- und zwei Zugangssicherheitstests sowie ein echter HTTP-Rundlauf
lesen → anlegen → idempotent wiederholen → ändern → erneut lesen → einplanen →
sortieren. Desktop-Verwaltung und mobiler Eingang wurden in Chromium ohne
Konsolenfehler geprüft. In der bestehenden Gesamtmatrix waren 16 von 18 Abläufen
erfolgreich; die bereits bekannte Live-Zeitanzeige und der Ablauf
Abschluss/Freigabe schlugen weiterhin fehl. Der globale Lintlauf bleibt an acht
vorhandenen Formatierungsabweichungen außerhalb dieser Etappe offen.
## Wartungsrelease 0.6.1
- Abschnitts-Countdown, mobile Live-Kopfzeile und Ablauf-Sheet wurden korrigiert; die doppelte Piece-Positionsanzeige entfällt.
- Optionaler Vorbereitungsaufwand, kompakte Vorbereitungsliste und Abschnitts-Twitch-Tags sind additiv persistiert.
- Der Präsentationsmodus `image` gibt ausschließlich das bewusst gewählte Snapshot-Medium aus.
- Mobile Eingaben, Iconsuche, Sendungskarten und Gastabstände wurden angepasst.
- Lesefreigaben verwenden einen gemeinsamen Dialog und sind auch aus der Live-Regie erreichbar.
Geprüft wurden 52 Verhaltenstests, Typprüfung und Produktionsbuild. Im zweiten
Chromium-Lauf waren 14 Abläufe erfolgreich; zwei Abläufe schlugen vor dem
Zeitlimit fehl, zwei weitere wurden nicht mehr ausgeführt. Die vollständige
Browsermatrix bleibt damit offen.
## Abschluss, Archiv und Freigaben 0.6.0
+10
View File
@@ -0,0 +1,10 @@
ALTER TABLE pieces ADD COLUMN preparation_effort TEXT CHECK (
preparation_effort IS NULL OR preparation_effort IN ('low', 'medium', 'high')
);
ALTER TABLE pieces ADD COLUMN presentation_image_only INTEGER NOT NULL DEFAULT 0
CHECK (presentation_image_only IN (0, 1));
ALTER TABLE piece_usages ADD COLUMN presentation_image_only_snapshot INTEGER NOT NULL DEFAULT 0
CHECK (presentation_image_only_snapshot IN (0, 1));
ALTER TABLE show_sections ADD COLUMN twitch_tags TEXT NOT NULL DEFAULT '';
ALTER TABLE show_template_sections ADD COLUMN twitch_tags TEXT NOT NULL DEFAULT '';
+66
View File
@@ -0,0 +1,66 @@
CREATE TABLE feed_sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL UNIQUE,
is_enabled INTEGER NOT NULL DEFAULT 1 CHECK (is_enabled IN (0, 1)),
refresh_interval_minutes INTEGER NOT NULL DEFAULT 60 CHECK (refresh_interval_minutes BETWEEN 15 AND 10080),
default_category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL,
etag TEXT NOT NULL DEFAULT '',
last_modified TEXT NOT NULL DEFAULT '',
last_fetched_at_utc TEXT,
next_fetch_at_utc TEXT,
last_error TEXT NOT NULL DEFAULT '',
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE feed_entries (
id TEXT PRIMARY KEY,
feed_id TEXT NOT NULL REFERENCES feed_sources(id) ON DELETE CASCADE,
external_id TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
published_at_utc TEXT,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'imported', 'discarded')),
piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL,
first_seen_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL,
UNIQUE (feed_id, external_id)
);
CREATE INDEX feed_entries_status_idx ON feed_entries(status, first_seen_at_utc DESC);
CREATE INDEX feed_sources_due_idx ON feed_sources(is_enabled, next_fetch_at_utc);
CREATE TABLE technical_accesses (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE,
permissions TEXT NOT NULL CHECK (permissions IN ('read', 'read_write')),
created_by_user_id TEXT NOT NULL REFERENCES users(id),
created_at_utc TEXT NOT NULL,
last_used_at_utc TEXT,
revoked_at_utc TEXT
);
CREATE TABLE api_idempotency (
access_id TEXT NOT NULL REFERENCES technical_accesses(id) ON DELETE CASCADE,
request_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status_code INTEGER NOT NULL,
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (access_id, request_key)
);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'technical')),
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at_utc TEXT NOT NULL
);
CREATE INDEX audit_events_created_idx ON audit_events(created_at_utc DESC);
+123 -2
View File
@@ -1,15 +1,16 @@
{
"name": "sendewerk",
"version": "0.6.0",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.6.0",
"version": "0.7.0",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0",
"marked": "^18.1.1",
"metascraper": "^5.58.5",
@@ -599,6 +600,18 @@
"node": ">= 22.13"
}
},
"node_modules/@nodable/entities": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@node-rs/argon2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
@@ -1630,6 +1643,18 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -2689,6 +2714,45 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.11.2",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
"integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.1",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.2",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3055,6 +3119,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/is-uri": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/is-uri/-/is-uri-1.2.14.tgz",
@@ -4052,6 +4128,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -4545,6 +4636,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/strnum": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
"integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/strtok3": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz",
@@ -5284,6 +5390,21 @@
"node": ">=18"
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.6.0",
"version": "0.7.0",
"type": "module",
"engines": {
"node": ">=22.17 <25"
@@ -26,6 +26,7 @@
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0",
"marked": "^18.1.1",
"metascraper": "^5.58.5",
+78
View File
@@ -328,6 +328,18 @@
object-fit: contain;
}
.output-image-only {
display: grid;
height: 100%;
place-items: center;
}
.output-image-only img {
width: 100%;
height: 100%;
object-fit: contain;
}
.shared-copy,
.historical-usage section,
.historical-usage details {
@@ -4731,6 +4743,45 @@ textarea:focus {
gap: 0.5rem;
}
.preparation-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 0.5rem;
}
.preparation-list a {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.preparation-list span {
display: grid;
min-width: 0;
}
.preparation-list small {
overflow: hidden;
color: var(--text-muted);
font-size: 0.68rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.effort {
padding: 0.2rem 0.45rem;
border-radius: 2rem;
background: var(--panel-strong);
color: var(--text-muted);
font-size: 0.62rem;
}
.show-card {
display: flex;
align-items: center;
@@ -6427,6 +6478,21 @@ textarea:focus {
flex-direction: column;
gap: 0.75rem;
}
input:not([type='checkbox']):not([type='radio']):not([type='hidden']),
select,
textarea,
.icon-picker-popover input[type='search'] {
font-size: 1rem;
}
.icon-picker-popover {
display: grid;
max-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav) - 1rem);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.icon-picker-grid {
min-height: 0;
overflow-y: auto;
}
.usage-editor-dialog {
width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem);
@@ -7361,6 +7427,18 @@ body:has(.heinzlive-output.empty),
calc(0.5rem + env(safe-area-inset-left));
width: auto;
max-height: none;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.icon-picker-popover input[type='search'] {
font-size: 1rem;
}
.icon-picker-grid {
min-height: 0;
overflow-y: auto;
}
.piece-subsection-heading .choice-picker-popover {
+4
View File
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
import { startFeedScheduler } from '#lib/server/feeds.js';
const publicPaths = new Set([
'/',
@@ -14,9 +15,11 @@ const publicPaths = new Set([
]);
export const handle: Handle = async ({ event, resolve }) => {
startFeedScheduler();
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const sharePath = event.url.pathname.startsWith('/freigabe/');
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
const apiPath = event.url.pathname.startsWith('/api/v1/');
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
@@ -26,6 +29,7 @@ export const handle: Handle = async ({ event, resolve }) => {
!presentationPath &&
!sharePath &&
!publicPaths.has(event.url.pathname) &&
!apiPath &&
!event.locals.user
) {
redirect(303, hasUsers() ? '/login' : '/setup');
@@ -60,6 +60,11 @@
{/each}
</footer>{/if}
</article>
{:else if content.mode === 'image'}
<article class="output-content output-image-only">
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
{#if imageSrc}<img src={imageSrc} alt={content.images?.[0]?.altText ?? content.title} />{/if}
</article>
{:else if content.mode === 'link'}
<article class="output-content output-link-card">
{#if imageSrc && !imageUnavailable}<div class="output-link-image">
+14 -3
View File
@@ -31,7 +31,7 @@
shows: ShareShow[];
shares: ShareItem[];
fixedShowId?: string;
form?: { intent?: string; error?: string; success?: string; shareUrl?: string } | null;
form?: { intent?: string; error?: string; success?: string | boolean; shareUrl?: string } | null;
} = $props();
let visibleShows = $derived(
@@ -39,6 +39,7 @@
? shows.filter((show) => show.id === fixedShowId)
: shows.filter((show) => shares.some((share) => share.showId === show.id))
);
let dialog: HTMLDialogElement;
function germanDate(value: string | null, fallback = 'Termin offen') {
return value
@@ -86,6 +87,10 @@
}
</script>
<button class="secondary-button share-dialog-trigger" type="button" onclick={() => dialog.showModal()}>
<AppIcon name="link" /> Lesefreigaben
</button>
<dialog bind:this={dialog} class="share-management-dialog">
<section
class="quiet-panel share-management"
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
@@ -96,6 +101,9 @@
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}>
<AppIcon name="xmark" />
</button>
</div>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
@@ -107,7 +115,7 @@
>
{form.error}
</div>{/if}
{#if form?.intent?.toLowerCase().includes('share') && form.success}<div
{#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div
class="form-message success"
role="status"
>
@@ -187,6 +195,7 @@
{:else}
<form class="share-edit-form" method="POST" action="?/updateShare">
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<label
>Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option>
@@ -230,7 +239,8 @@
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button>
</form>
</div>
@@ -247,3 +257,4 @@
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
</dialog>
+2 -2
View File
@@ -227,7 +227,7 @@
bind:value={draft.presentation}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
><input
type="radio"
@@ -251,7 +251,7 @@
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode !== 'text'}
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings">
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
+47
View File
@@ -0,0 +1,47 @@
import { json } from '@sveltejs/kit';
export async function apiBody(request: Request) {
const type = request.headers.get('content-type') ?? '';
if (!type.includes('application/json')) throw new Error('API_JSON_REQUIRED');
try {
return (await request.json()) as Record<string, unknown>;
} catch {
throw new Error('API_INVALID_JSON');
}
}
export function apiFailure(cause: unknown) {
const code = cause instanceof Error ? cause.message : 'API_ERROR';
const status =
code === 'API_UNAUTHORIZED'
? 401
: code === 'API_FORBIDDEN'
? 403
: code === 'NOT_FOUND'
? 404
: code === 'CONFLICT' || code === 'API_IDEMPOTENCY_CONFLICT'
? 409
: 400;
return json(
{
error: {
code,
message:
status === 401
? 'Authentifizierung erforderlich.'
: status === 403
? 'Diese Berechtigung fehlt.'
: status === 404
? 'Nicht gefunden.'
: 'Anfrage konnte nicht verarbeitet werden.'
}
},
{ status }
);
}
export function pageInput(url: URL) {
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 50) || 50));
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0) || 0);
return { limit, offset };
}
+8 -1
View File
@@ -74,7 +74,8 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
],
...overrides
...overrides,
preparationEffort: overrides.preparationEffort ?? null
};
}
@@ -332,11 +333,17 @@ describe('content management', () => {
presentationMediaId: selected.id
});
savePiece(id, source.revision, {
...source,
status: 'preparing',
presentationMode: 'image'
});
const duplicateId = await duplicatePiece(id);
const duplicate = getPiece(duplicateId)!;
expect(duplicate.media).toHaveLength(1);
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
expect(duplicate.presentationMediaId).not.toBe(selected.id);
expect(duplicate.presentationMode).toBe('image');
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
const snapshot = getDatabase()
+34 -12
View File
@@ -14,6 +14,7 @@ import {
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 PreparationEffort = 'low' | 'medium' | 'high' | null;
export type TaxonomyKind = 'category' | 'format' | 'topic';
export type PieceSourceInput = {
@@ -42,6 +43,7 @@ export type PieceInput = {
summary: string;
formatId: string | null;
status: EditablePieceStatus;
preparationEffort: PreparationEffort;
durationSeconds: number;
intro: string;
researchText: string;
@@ -82,6 +84,7 @@ export type PieceListItem = {
sourceCount: number;
mediaId: string | null;
isFavorite: boolean;
preparationEffort: PreparationEffort;
};
export type PieceEditor = Omit<
@@ -198,6 +201,9 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
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 (![null, 'low', 'medium', 'high'].includes(input.preparationEffort)) {
throw new Error('INVALID_PREPARATION_EFFORT');
}
if (
!Number.isInteger(input.durationSeconds) ||
input.durationSeconds < 0 ||
@@ -226,7 +232,7 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
}
return { label, url, timecodeSeconds: source.timecodeSeconds };
});
if (!['text', 'link', 'video'].includes(input.presentationMode)) {
if (!['text', 'image', 'link', 'video'].includes(input.presentationMode)) {
throw new Error('INVALID_PRESENTATION');
}
const sourcePosition = input.presentationSourcePosition;
@@ -236,7 +242,7 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
) {
throw new Error('INVALID_PRESENTATION');
}
if (input.presentationMode !== 'text' && sourcePosition === null) {
if (['link', 'video'].includes(input.presentationMode) && sourcePosition === null) {
throw new Error('INVALID_PRESENTATION');
}
const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image');
@@ -249,12 +255,16 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
input.presentationMediaId === undefined
? undefined
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
if (input.presentationMode === 'image' && !presentationMediaId) {
throw new Error('INVALID_PRESENTATION');
}
return {
title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null,
status: input.status,
preparationEffort: input.preparationEffort,
durationSeconds: input.durationSeconds,
intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'),
@@ -317,6 +327,7 @@ type PieceListRow = {
source_count: number;
media_id: string | null;
is_favorite: number;
preparation_effort: PreparationEffort;
};
export function listPieces(filters: {
@@ -366,6 +377,7 @@ export function listPieces(filters: {
const rows = database
.prepare(
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
p.preparation_effort,
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 AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id
@@ -398,7 +410,8 @@ export function listPieces(filters: {
topics: topicStatement.all(row.id) as PieceListItem['topics'],
sourceCount: row.source_count,
mediaId: row.media_id,
isFavorite: row.is_favorite === 1
isFavorite: row.is_favorite === 1,
preparationEffort: row.preparation_effort
}));
}
@@ -433,14 +446,16 @@ type PieceRow = {
updated_at_utc: string;
is_favorite: number;
presentation_media_id: string | null;
preparation_effort: PreparationEffort;
};
export function getPiece(id: string): PieceEditor | null {
const database = getDatabase();
const row = database
.prepare(
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode,
`SELECT id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro, research_text,
moderation_notes, interaction_text, transition_text, presentation_text,
CASE WHEN presentation_image_only = 1 THEN 'image' ELSE presentation_mode END AS presentation_mode,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual,
@@ -460,6 +475,7 @@ export function getPiece(id: string): PieceEditor | null {
summary: row.summary,
formatId: row.format_id,
status: row.status,
preparationEffort: row.preparation_effort,
durationSeconds: row.duration_seconds,
intro: row.intro,
researchText: row.research_text,
@@ -612,9 +628,9 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const save = database.transaction(() => {
const result = database
.prepare(
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, preparation_effort = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
presentation_mode = ?, presentation_image_only = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
presentation_description_manual = ?, presentation_image_manual = ?,
@@ -628,6 +644,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.summary,
input.formatId,
input.status,
input.preparationEffort,
input.durationSeconds,
input.intro,
input.researchText,
@@ -635,7 +652,8 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.interactionText ?? null,
input.transitionText,
input.presentationText,
input.presentationMode,
input.presentationMode === 'image' ? 'text' : input.presentationMode,
input.presentationMode === 'image' ? 1 : 0,
input.presentationSourcePosition,
input.presentationTitle,
input.presentationDescription,
@@ -671,6 +689,7 @@ function blankPieceInput(): ValidatedPieceInput {
summary: '',
formatId: null,
status: 'preparing',
preparationEffort: null,
durationSeconds: 0,
intro: '',
researchText: '',
@@ -798,6 +817,7 @@ export function savePieceAsTemplate(
delete snapshot.updatedAtUtc;
delete snapshot.media;
delete snapshot.presentationMediaId;
if (snapshot.presentationMode === 'image') snapshot.presentationMode = 'text';
snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase();
@@ -917,15 +937,15 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode,
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro,
research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, presentation_image_only,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
)`
)
@@ -935,6 +955,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.outputTitle,
source.summary,
source.formatId,
source.preparationEffort,
source.durationSeconds,
source.intro,
source.researchText,
@@ -942,7 +963,8 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.interactionText,
source.transitionText,
source.presentationText,
source.presentationMode,
source.presentationMode === 'image' ? 'text' : source.presentationMode,
source.presentationMode === 'image' ? 1 : 0,
source.presentationSourcePosition,
source.presentationTitle,
source.presentationDescription,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 16,
migrations: 18,
pieces: 6,
shows: 2,
usages: 5,
+134
View File
@@ -0,0 +1,134 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
discardFeedEntries,
fetchDueFeeds,
fetchFeed,
importFeedEntries,
listFeedEntries,
saveFeedSource
} from './feeds.js';
import { getPiece } from './content.js';
vi.mock('node:dns/promises', () => ({
lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }])
}));
const validFeed = `<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title><item>
<guid>entry-1</guid><title>Ein Beitrag</title><link>https://example.org/post</link>
<description><![CDATA[<p>Kurz <strong>und</strong> gut.</p><script>evil()</script>]]></description>
<pubDate>Sat, 24 Oct 2026 12:00:00 GMT</pubDate></item></channel></rss>`;
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-feed-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(() => {
vi.unstubAllGlobals();
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('RSS import inbox', () => {
it('keeps one entry across fetches and preserves a discard decision', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () => new Response(validFeed, { headers: { 'content-type': 'application/rss+xml' } })
)
);
const feedId = saveFeedSource(null, {
name: 'Feed',
url: 'https://example.org/feed.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await fetchFeed(feedId);
const [entry] = listFeedEntries();
expect(entry.title).toBe('Ein Beitrag');
expect(discardFeedEntries([entry.id])).toBe(1);
await fetchFeed(feedId);
expect(listFeedEntries()).toHaveLength(0);
expect(listFeedEntries('discarded')).toHaveLength(1);
});
it('sanitizes foreign HTML and retains the original source on explicit import', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(validFeed))
);
const feedId = saveFeedSource(null, {
name: 'Feed',
url: 'https://example.org/feed.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: 'cat-jc'
});
await fetchFeed(feedId);
const [entry] = listFeedEntries();
const [pieceId] = importFeedEntries([entry.id], []);
const piece = getPiece(pieceId)!;
expect(piece.summary).toContain('Kurz und gut.');
expect(piece.summary).not.toContain('<script>');
expect(piece.sources).toEqual([
{ label: 'RSS: Originalbeitrag', url: 'https://example.org/post', timecodeSeconds: null }
]);
expect(piece.categoryIds).toEqual(['cat-jc']);
});
it('isolates a broken feed while fetching other due feeds', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async (url: URL) => new Response(url.href.includes('broken') ? '<not-feed />' : validFeed)
)
);
saveFeedSource(null, {
name: 'A broken',
url: 'https://example.org/broken.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
saveFeedSource(null, {
name: 'B valid',
url: 'https://example.org/valid.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await fetchDueFeeds();
expect(listFeedEntries()).toHaveLength(1);
const errors = getDatabase()
.prepare("SELECT count(*) AS count FROM feed_sources WHERE last_error <> ''")
.get() as { count: number };
expect(errors.count).toBe(1);
});
it('blocks loopback targets before sending a request', async () => {
const request = vi.fn();
vi.stubGlobal('fetch', request);
const feedId = saveFeedSource(null, {
name: 'Local',
url: 'http://127.0.0.1/feed.xml',
isEnabled: false,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await expect(fetchFeed(feedId)).rejects.toThrow('FEED_BLOCKED');
expect(request).not.toHaveBeenCalled();
});
});
+457
View File
@@ -0,0 +1,457 @@
import { createHash, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import sanitizeHtml from 'sanitize-html';
import { XMLParser } from 'fast-xml-parser';
import { createPiece, getPiece, savePiece } from './content.js';
import { getDatabase } from './database.js';
const MAX_FEED_BYTES = 2 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 8_000;
const MAX_REDIRECTS = 3;
export type FeedSource = {
id: string;
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
lastFetchedAtUtc: string | null;
nextFetchAtUtc: string | null;
lastError: string;
};
export type FeedEntry = {
id: string;
feedId: string;
feedName: string;
title: string;
url: string;
summary: string;
publishedAtUtc: string | null;
status: 'new' | 'imported' | 'discarded';
pieceId: string | null;
defaultCategoryId: string | null;
};
function clean(value: unknown, maximum: number) {
return String(value ?? '')
.trim()
.slice(0, maximum);
}
function validHttpUrl(value: string) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password;
} catch {
return false;
}
}
function privateIpv4(address: string) {
const p = address.split('.').map(Number);
return (
p[0] === 0 ||
p[0] === 10 ||
p[0] === 127 ||
(p[0] === 169 && p[1] === 254) ||
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
(p[0] === 192 && p[1] === 168) ||
p[0] >= 224
);
}
function privateAddress(address: string) {
if (isIP(address) === 4) return privateIpv4(address);
const value = address.toLowerCase().split('%')[0];
if (value === '::' || value === '::1' || value.startsWith('fc') || value.startsWith('fd'))
return true;
if (/^fe[89ab]/.test(value)) return true;
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return mapped ? privateIpv4(mapped) : false;
}
async function assertPublic(url: URL) {
if (!validHttpUrl(url.href) || (url.port && !['80', '443'].includes(url.port)))
throw new Error('FEED_BLOCKED');
const hostname = url.hostname.replace(/^\[|\]$/g, '');
if (hostname === 'localhost' || hostname.endsWith('.localhost')) throw new Error('FEED_BLOCKED');
const addresses = isIP(hostname)
? [{ address: hostname }]
: await lookup(hostname, { all: true, verbatim: true });
if (!addresses.length || addresses.some((entry) => privateAddress(entry.address)))
throw new Error('FEED_BLOCKED');
}
async function fetchXml(
input: string,
headers: Record<string, string>,
redirects = 0
): Promise<{ xml: string; etag: string; lastModified: string; unchanged: boolean }> {
const url = new URL(input);
await assertPublic(url);
const response = await fetch(url, {
headers: {
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
...headers
},
redirect: 'manual',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (redirects >= MAX_REDIRECTS) throw new Error('FEED_REDIRECT');
const location = response.headers.get('location');
if (!location) throw new Error('FEED_FETCH');
return fetchXml(new URL(location, url).href, headers, redirects + 1);
}
if (response.status === 304)
return {
xml: '',
etag: headers['If-None-Match'] ?? '',
lastModified: headers['If-Modified-Since'] ?? '',
unchanged: true
};
if (!response.ok) throw new Error(`FEED_HTTP_${response.status}`);
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > MAX_FEED_BYTES) throw new Error('FEED_TOO_LARGE');
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > MAX_FEED_BYTES) throw new Error('FEED_TOO_LARGE');
return {
xml: buffer.toString('utf8'),
etag: response.headers.get('etag') ?? '',
lastModified: response.headers.get('last-modified') ?? '',
unchanged: false
};
}
function values<T>(value: T | T[] | undefined): T[] {
return value === undefined ? [] : Array.isArray(value) ? value : [value];
}
function textFrom(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
return textFrom(record['#text'] ?? record.__cdata ?? '');
}
return '';
}
function entryLink(value: unknown) {
for (const link of values(
value as Record<string, unknown> | Record<string, unknown>[] | string | undefined
)) {
if (typeof link === 'string') return link;
if (link && typeof link === 'object') {
const href = clean((link as Record<string, unknown>)['@_href'], 2_000);
const rel = clean((link as Record<string, unknown>)['@_rel'], 30);
if (href && (!rel || rel === 'alternate')) return href;
}
}
return '';
}
function parseDate(value: unknown) {
const raw = textFrom(value);
if (!raw) return null;
const date = new Date(raw);
return Number.isNaN(date.valueOf()) ? null : date.toISOString();
}
function parseFeed(xml: string) {
let document: Record<string, unknown>;
try {
document = new XMLParser({
ignoreAttributes: false,
processEntities: false,
trimValues: true
}).parse(xml) as Record<string, unknown>;
} catch {
throw new Error('INVALID_FEED');
}
const rssChannel = (document.rss as Record<string, unknown> | undefined)?.channel as
Record<string, unknown> | undefined;
const atom = document.feed as Record<string, unknown> | undefined;
if (!rssChannel && !atom) throw new Error('INVALID_FEED');
const rawEntries = values(
(rssChannel?.item ?? atom?.entry) as
Record<string, unknown> | Record<string, unknown>[] | undefined
);
return rawEntries.slice(0, 200).flatMap((entry) => {
const title = clean(textFrom(entry.title), 300);
const url = clean(entryLink(entry.link) || textFrom(entry.link), 2_000);
if (!title || !validHttpUrl(url)) return [];
const guid = clean(textFrom(entry.guid ?? entry.id), 2_000);
const externalId = guid || createHash('sha256').update(url).digest('hex');
const rawSummary = textFrom(entry.description ?? entry.summary ?? entry.content);
const summary = clean(
sanitizeHtml(rawSummary, { allowedTags: [], allowedAttributes: {} }),
5_000
);
return [
{
externalId,
title,
url,
summary,
publishedAtUtc: parseDate(entry.pubDate ?? entry.published ?? entry.updated)
}
];
});
}
export function listFeedSources(): FeedSource[] {
return (
getDatabase()
.prepare(
`SELECT id, name, url, is_enabled, refresh_interval_minutes,
default_category_id, last_fetched_at_utc, next_fetch_at_utc, last_error FROM feed_sources ORDER BY name`
)
.all() as Array<Record<string, unknown>>
).map((row) => ({
id: String(row.id),
name: String(row.name),
url: String(row.url),
isEnabled: row.is_enabled === 1,
refreshIntervalMinutes: Number(row.refresh_interval_minutes),
defaultCategoryId: row.default_category_id ? String(row.default_category_id) : null,
lastFetchedAtUtc: row.last_fetched_at_utc ? String(row.last_fetched_at_utc) : null,
nextFetchAtUtc: row.next_fetch_at_utc ? String(row.next_fetch_at_utc) : null,
lastError: String(row.last_error)
}));
}
export function saveFeedSource(
id: string | null,
input: {
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
}
) {
const name = clean(input.name, 120);
const url = clean(input.url, 2_000);
if (
!name ||
!validHttpUrl(url) ||
!Number.isInteger(input.refreshIntervalMinutes) ||
input.refreshIntervalMinutes < 15 ||
input.refreshIntervalMinutes > 10080
)
throw new Error('INVALID_FEED');
const database = getDatabase();
if (
input.defaultCategoryId &&
!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(input.defaultCategoryId)
)
throw new Error('INVALID_TAXONOMY');
const now = new Date().toISOString();
if (!id) {
id = randomUUID();
database
.prepare(
`INSERT INTO feed_sources (id, name, url, is_enabled, refresh_interval_minutes,
default_category_id, next_fetch_at_utc, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
name,
url,
input.isEnabled ? 1 : 0,
input.refreshIntervalMinutes,
input.defaultCategoryId,
input.isEnabled ? now : null,
now,
now
);
} else {
const result = database
.prepare(
`UPDATE feed_sources SET name = ?, url = ?, is_enabled = ?, refresh_interval_minutes = ?,
default_category_id = ?, next_fetch_at_utc = CASE WHEN ? = 1 THEN COALESCE(next_fetch_at_utc, ?) ELSE NULL END,
updated_at_utc = ? WHERE id = ?`
)
.run(
name,
url,
input.isEnabled ? 1 : 0,
input.refreshIntervalMinutes,
input.defaultCategoryId,
input.isEnabled ? 1 : 0,
now,
now,
id
);
if (!result.changes) throw new Error('NOT_FOUND');
}
return id;
}
export function deleteFeedSource(id: string) {
if (!getDatabase().prepare('DELETE FROM feed_sources WHERE id = ?').run(id).changes)
throw new Error('NOT_FOUND');
}
export function listFeedEntries(status: 'new' | 'imported' | 'discarded' = 'new'): FeedEntry[] {
return (
getDatabase()
.prepare(
`SELECT e.id, e.feed_id, f.name AS feed_name, e.title, e.url, e.summary,
e.published_at_utc, e.status, e.piece_id, f.default_category_id FROM feed_entries e
JOIN feed_sources f ON f.id = e.feed_id WHERE e.status = ? ORDER BY COALESCE(e.published_at_utc, e.first_seen_at_utc) DESC`
)
.all(status) as Array<Record<string, unknown>>
).map((row) => ({
id: String(row.id),
feedId: String(row.feed_id),
feedName: String(row.feed_name),
title: String(row.title),
url: String(row.url),
summary: String(row.summary),
publishedAtUtc: row.published_at_utc ? String(row.published_at_utc) : null,
status: row.status as FeedEntry['status'],
pieceId: row.piece_id ? String(row.piece_id) : null,
defaultCategoryId: row.default_category_id ? String(row.default_category_id) : null
}));
}
export async function fetchFeed(feedId: string) {
const database = getDatabase();
const feed = database.prepare('SELECT * FROM feed_sources WHERE id = ?').get(feedId) as
Record<string, unknown> | undefined;
if (!feed) throw new Error('NOT_FOUND');
const now = new Date();
try {
const response = await fetchXml(String(feed.url), {
...(feed.etag ? { 'If-None-Match': String(feed.etag) } : {}),
...(feed.last_modified ? { 'If-Modified-Since': String(feed.last_modified) } : {})
});
let inserted = 0;
if (!response.unchanged) {
const add =
database.prepare(`INSERT INTO feed_entries (id, feed_id, external_id, title, url, summary,
published_at_utc, first_seen_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(feed_id, external_id) DO UPDATE SET title = excluded.title, url = excluded.url,
summary = excluded.summary, published_at_utc = excluded.published_at_utc, updated_at_utc = excluded.updated_at_utc`);
for (const entry of parseFeed(response.xml)) {
const existed = database
.prepare('SELECT 1 FROM feed_entries WHERE feed_id = ? AND external_id = ?')
.get(feedId, entry.externalId);
add.run(
randomUUID(),
feedId,
entry.externalId,
entry.title,
entry.url,
entry.summary,
entry.publishedAtUtc,
now.toISOString(),
now.toISOString()
);
if (!existed) inserted += 1;
}
}
const next = new Date(
now.getTime() + Number(feed.refresh_interval_minutes) * 60_000
).toISOString();
database
.prepare(
`UPDATE feed_sources SET etag = ?, last_modified = ?, last_fetched_at_utc = ?,
next_fetch_at_utc = ?, last_error = '', updated_at_utc = ? WHERE id = ?`
)
.run(
response.etag,
response.lastModified,
now.toISOString(),
next,
now.toISOString(),
feedId
);
return { inserted, unchanged: response.unchanged };
} catch (cause) {
const message = cause instanceof Error ? cause.message.slice(0, 500) : 'FEED_FETCH';
const next = new Date(
now.getTime() + Number(feed.refresh_interval_minutes) * 60_000
).toISOString();
database
.prepare(
'UPDATE feed_sources SET last_fetched_at_utc = ?, next_fetch_at_utc = ?, last_error = ?, updated_at_utc = ? WHERE id = ?'
)
.run(now.toISOString(), next, message, now.toISOString(), feedId);
throw cause;
}
}
export async function fetchDueFeeds() {
const rows = getDatabase()
.prepare(
`SELECT id FROM feed_sources WHERE is_enabled = 1
AND (next_fetch_at_utc IS NULL OR next_fetch_at_utc <= ?) ORDER BY next_fetch_at_utc LIMIT 10`
)
.all(new Date().toISOString()) as { id: string }[];
for (const row of rows) await fetchFeed(row.id).catch(() => undefined);
}
export function importFeedEntries(entryIds: string[], categoryIds: string[]) {
const database = getDatabase();
const categories = [...new Set(categoryIds.filter(Boolean))];
if (categories.length > 20) throw new Error('INVALID_TAXONOMY');
for (const category of categories)
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(category))
throw new Error('INVALID_TAXONOMY');
return database.transaction(() =>
entryIds.map((entryId) => {
const entry = database
.prepare(
`SELECT e.*, f.default_category_id FROM feed_entries e JOIN feed_sources f ON f.id = e.feed_id
WHERE e.id = ? AND e.status = 'new'`
)
.get(entryId) as Record<string, unknown> | undefined;
if (!entry) throw new Error('NOT_FOUND');
const pieceId = createPiece('blank');
const piece = getPiece(pieceId)!;
const assigned = categories.length
? categories
: entry.default_category_id
? [String(entry.default_category_id)]
: [];
savePiece(pieceId, piece.revision, {
...piece,
title: String(entry.title),
summary: String(entry.summary),
status: 'inbox',
categoryIds: assigned,
sources: [{ label: 'RSS: Originalbeitrag', url: String(entry.url), timecodeSeconds: null }]
});
database
.prepare(
"UPDATE feed_entries SET status = 'imported', piece_id = ?, updated_at_utc = ? WHERE id = ?"
)
.run(pieceId, new Date().toISOString(), entryId);
return pieceId;
})
)();
}
export function discardFeedEntries(entryIds: string[]) {
const database = getDatabase();
const update = database.prepare(
"UPDATE feed_entries SET status = 'discarded', updated_at_utc = ? WHERE id = ? AND status = 'new'"
);
const now = new Date().toISOString();
return database.transaction(() =>
entryIds.reduce((count, id) => count + update.run(now, id).changes, 0)
)();
}
let schedulerStarted = false;
export function startFeedScheduler() {
if (schedulerStarted || process.env.NODE_ENV === 'test') return;
schedulerStarted = true;
void fetchDueFeeds();
setInterval(() => void fetchDueFeeds(), 60_000).unref();
}
+2 -2
View File
@@ -40,7 +40,7 @@ afterEach(() => {
});
describe('live mode', () => {
it('persists a frozen section countdown across usage changes and reloads', () => {
it('keeps elapsed section time while applying an updated section plan', () => {
getDatabase()
.prepare(
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
@@ -68,7 +68,7 @@ describe('live mode', () => {
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
expect(live.current?.id).toBe('usage-effects');
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 300,
plannedDurationSeconds: 600,
elapsedSeconds: 240
});
completeCurrent(
+17 -4
View File
@@ -261,10 +261,22 @@ function syncSectionTimer(
if (!section) throw new Error('NOT_FOUND');
const active = database
.prepare(
'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL'
`SELECT id, section_id, planned_duration_seconds
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
)
.get(showId) as { id: string; section_id: string } | undefined;
if (active?.section_id === section.id) return;
.get(showId) as
| { id: string; section_id: string; planned_duration_seconds: number }
| undefined;
if (active?.section_id === section.id) {
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
closeSectionTimer(database, showId, now);
} else if (active.planned_duration_seconds !== section.explicit_duration_seconds) {
database
.prepare('UPDATE live_section_timers SET planned_duration_seconds = ? WHERE id = ?')
.run(section.explicit_duration_seconds, active.id);
}
return;
}
if (active) closeSectionTimer(database, showId, now);
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
const timerId = randomUUID();
@@ -1126,7 +1138,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
const usage = database
.prepare(
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
+1 -1
View File
@@ -8,7 +8,7 @@ import image from 'metascraper-image';
import publisher from 'metascraper-publisher';
import title from 'metascraper-title';
export type PresentationMode = 'text' | 'link' | 'video';
export type PresentationMode = 'text' | 'image' | 'link' | 'video';
export type VideoProvider = 'youtube' | 'tiktok';
export type PresentationMetadata = {
+61 -30
View File
@@ -27,6 +27,7 @@ export type ShowTemplateSection = {
categoryId: string | null;
position: number;
explicitDurationSeconds: number | null;
twitchTags: string[];
};
export type ShowTemplate = {
@@ -106,6 +107,7 @@ export type ShowSection = {
categoryColor: string | null;
position: number;
explicitDurationSeconds: number | null;
twitchTags: string[];
durationSeconds: number;
usages: PieceUsage[];
};
@@ -144,6 +146,7 @@ export type ShowSectionInput = {
name: string;
categoryId: string | null;
explicitDurationSeconds: number | null;
twitchTags?: string[];
position?: number;
};
@@ -207,6 +210,7 @@ type SectionRow = {
category_color_snapshot: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
is_reserve_bank: number;
};
@@ -226,6 +230,7 @@ type UsageRow = {
transition_snapshot: string;
presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode;
presentation_image_only_snapshot: number;
presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null;
@@ -254,6 +259,14 @@ function validDuration(value: number | null): number | null {
return value;
}
function validTwitchTags(values: string[]) {
const tags = [...new Set(values.map((tag) => tag.trim()).filter(Boolean))];
if (tags.length > 10 || tags.some((tag) => tag.length > 25 || tag.includes(','))) {
throw new Error('INVALID_TWITCH_TAGS');
}
return tags;
}
function validPosition(value: number, maximum: number): number {
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
return Math.min(value, maximum);
@@ -277,7 +290,7 @@ function validPresentation(
imageUrl: string,
siteName: string
) {
if (!['text', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
if (!['text', 'image', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source');
const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
for (const value of [cleanUrl, cleanImageUrl]) {
@@ -289,7 +302,7 @@ function validPresentation(
throw new Error('INVALID_PRESENTATION');
}
}
if (mode !== 'text' && !cleanUrl) throw new Error('INVALID_PRESENTATION');
if ((mode === 'link' || mode === 'video') && !cleanUrl) throw new Error('INVALID_PRESENTATION');
if (
timecodeSeconds !== null &&
(!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400)
@@ -762,7 +775,7 @@ export function listShowTemplates(): ShowTemplate[] {
.prepare('SELECT id, name, description FROM show_templates ORDER BY name')
.all() as Omit<ShowTemplate, 'sections'>[];
const sectionStatement = database.prepare(
`SELECT id, name, category_id, position, explicit_duration_seconds
`SELECT id, name, category_id, position, explicit_duration_seconds, twitch_tags
FROM show_template_sections WHERE template_id = ? ORDER BY position`
);
return templates.map((template) => ({
@@ -774,13 +787,15 @@ export function listShowTemplates(): ShowTemplate[] {
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
}[]
).map((section) => ({
id: section.id,
name: section.name,
categoryId: section.category_id,
position: section.position,
explicitDurationSeconds: section.explicit_duration_seconds
explicitDurationSeconds: section.explicit_duration_seconds,
twitchTags: section.twitch_tags ? section.twitch_tags.split(',') : []
}))
}));
}
@@ -798,7 +813,7 @@ export function getShow(id: string): Show | null {
.prepare(
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
s.category_name_snapshot, s.category_color_snapshot,
s.position, s.explicit_duration_seconds, s.is_reserve_bank
s.position, s.explicit_duration_seconds, s.twitch_tags, s.is_reserve_bank
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position`
)
@@ -808,7 +823,9 @@ export function getShow(id: string): Show | null {
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
u.presentation_image_only_snapshot,
u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
@@ -841,6 +858,7 @@ export function getShow(id: string): Show | null {
: (row.category_color_snapshot ?? row.category_color),
position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds,
twitchTags: row.twitch_tags ? row.twitch_tags.split(',') : [],
durationSeconds:
row.explicit_duration_seconds ??
usages.reduce((total, usage) => total + usage.durationSeconds, 0),
@@ -957,7 +975,7 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
const templateSections = input.templateId
? (database
.prepare(
`SELECT name, category_id, position, explicit_duration_seconds
`SELECT name, category_id, position, explicit_duration_seconds, twitch_tags
FROM show_template_sections WHERE template_id = ? ORDER BY position`
)
.all(input.templateId) as {
@@ -965,11 +983,12 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
}[])
: [];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)`
explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const section of templateSections) {
insertSection.run(
@@ -979,10 +998,11 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
section.category_id,
section.position,
section.explicit_duration_seconds,
section.twitch_tags,
0
);
}
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1);
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, '', 1);
});
create();
return { id, revision: 1, updatedAtUtc: now };
@@ -1033,7 +1053,7 @@ export function duplicateCompletedShow(
const sections = database
.prepare(
`SELECT id, name, category_id, category_name_snapshot, category_color_snapshot,
position, explicit_duration_seconds, is_reserve_bank
position, explicit_duration_seconds, twitch_tags, is_reserve_bank
FROM show_sections WHERE show_id = ? ORDER BY position`
)
.all(sourceShowId) as {
@@ -1044,12 +1064,13 @@ export function duplicateCompletedShow(
category_color_snapshot: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
is_reserve_bank: number;
}[];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot,
category_color_snapshot, position, explicit_duration_seconds, is_reserve_bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
category_color_snapshot, position, explicit_duration_seconds, twitch_tags, is_reserve_bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const usages = database.prepare(
'SELECT * FROM piece_usages WHERE show_section_id = ? ORDER BY position'
@@ -1062,8 +1083,8 @@ export function duplicateCompletedShow(
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const section of sections) {
const sectionId = randomUUID();
@@ -1076,6 +1097,7 @@ export function duplicateCompletedShow(
section.category_color_snapshot,
section.position,
section.explicit_duration_seconds,
section.twitch_tags,
section.is_reserve_bank
);
for (const usage of usages.all(section.id) as (UsageRow & { is_reserve: number })[]) {
@@ -1104,6 +1126,7 @@ export function duplicateCompletedShow(
usage.presentation_site_name_snapshot,
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.presentation_image_only_snapshot,
usage.own_notes,
usage.is_reserve
);
@@ -1150,6 +1173,7 @@ export function addShowSection(
): CreatedMutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const twitchTags = validTwitchTags(input.twitchTags ?? []);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
@@ -1174,9 +1198,9 @@ export function addShowSection(
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)`
explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, 0)`
)
.run(id, showId, name, input.categoryId, temporaryPosition, duration);
.run(id, showId, name, input.categoryId, temporaryPosition, duration, twitchTags.join(','));
visibleIds.splice(position, 0, id);
const reserveId = reserveSectionId(database, showId);
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
@@ -1192,6 +1216,7 @@ export function updateShowSection(
): MutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const twitchTags = validTwitchTags(input.twitchTags ?? []);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
@@ -1199,11 +1224,11 @@ export function updateShowSection(
sectionBelongsToShow(database, showId, sectionId);
database
.prepare(
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?,
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?, twitch_tags = ?,
category_name_snapshot = NULL, category_color_snapshot = NULL
WHERE id = ?`
)
.run(name, input.categoryId, duration, sectionId);
.run(name, input.categoryId, duration, twitchTags.join(','), sectionId);
return result;
})();
}
@@ -1275,7 +1300,8 @@ export function addPieceUsage(
const piece = database
.prepare(
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.interaction_text, p.transition_text, p.presentation_text, p.status, p.presentation_mode,
p.interaction_text, p.transition_text, p.presentation_text, p.status,
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
@@ -1329,12 +1355,12 @@ export function addPieceUsage(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
@@ -1350,7 +1376,8 @@ export function addPieceUsage(
piece.interaction_text,
piece.transition_text,
piece.presentation_text,
piece.presentation_mode,
piece.presentation_mode === 'image' ? 'text' : piece.presentation_mode,
piece.presentation_mode === 'image' ? 1 : 0,
piece.presentation_source_label,
piece.presentation_source_url,
piece.presentation_source_timecode,
@@ -1436,7 +1463,7 @@ export function duplicatePieceUsage(
.prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
@@ -1459,12 +1486,12 @@ export function duplicatePieceUsage(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
@@ -1481,6 +1508,7 @@ export function duplicatePieceUsage(
source.transition_snapshot,
source.presentation_snapshot,
source.presentation_mode_snapshot,
source.presentation_image_only_snapshot,
source.presentation_source_label_snapshot,
source.presentation_source_url_snapshot,
source.presentation_source_timecode_snapshot,
@@ -1512,7 +1540,8 @@ export function refreshPieceUsageFromOriginal(
const source = database
.prepare(
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text, p.presentation_mode,
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text,
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
@@ -1551,7 +1580,7 @@ export function refreshPieceUsageFromOriginal(
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_image_only_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
@@ -1568,7 +1597,8 @@ export function refreshPieceUsageFromOriginal(
source.interaction_text,
source.transition_text,
source.presentation_text,
source.presentation_mode,
source.presentation_mode === 'image' ? 'text' : source.presentation_mode,
source.presentation_mode === 'image' ? 1 : 0,
source.presentation_source_label,
source.presentation_source_url,
source.presentation_source_timecode,
@@ -1630,7 +1660,7 @@ export function updatePieceUsage(
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_mode_snapshot = ?, presentation_image_only_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
@@ -1646,7 +1676,8 @@ export function updatePieceUsage(
values.interaction,
values.transition,
values.presentation,
presentation.mode,
presentation.mode === 'image' ? 'text' : presentation.mode,
presentation.mode === 'image' ? 1 : 0,
presentation.sourceLabel,
presentation.sourceUrl,
presentation.timecodeSeconds,
+97
View File
@@ -0,0 +1,97 @@
import { execFileSync } from 'node:child_process';
import { 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 { AuthUser } from './auth.js';
import { closeDatabase, getDatabase } from './database.js';
import {
authenticateTechnical,
createTechnicalAccess,
idempotentResponse,
revokeTechnicalAccess
} from './technical-access.js';
let directory: string;
let actor: AuthUser;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-api-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 });
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc) VALUES ('test-user', 'test@example.org',
'test@example.org', 'Test', 'not-used', 'barkeeper', ?, ?, ?)`
)
.run(now, now, now);
actor = {
id: 'test-user',
email: 'test@example.org',
displayName: 'Test',
role: 'barkeeper',
lockedAtUtc: null
};
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function request(token: string, key?: string) {
return new Request('https://sendewerk.test/api/v1/pieces', {
headers: { Authorization: `Bearer ${token}`, ...(key ? { 'Idempotency-Key': key } : {}) }
});
}
describe('technical API access', () => {
it('enforces permissions and revocation without storing the raw token', () => {
const created = createTechnicalAccess(actor, 'Leser', 'read');
expect(authenticateTechnical(request(created.token))).toMatchObject({
name: 'Leser',
permissions: 'read'
});
expect(() => authenticateTechnical(request(created.token), true)).toThrow('API_FORBIDDEN');
expect(
getDatabase()
.prepare('SELECT token_hash FROM technical_accesses WHERE id = ?')
.get(created.id)
).not.toMatchObject({ token_hash: created.token });
revokeTechnicalAccess(actor, created.id);
expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED');
});
it('replays an identical idempotent write and rejects a changed payload', () => {
const created = createTechnicalAccess(actor, 'Schreiber', 'read_write');
const technical = authenticateTechnical(request(created.token), true);
let calls = 0;
const first = idempotentResponse(
technical,
request(created.token, 'command-1'),
{ title: 'A' },
() => ({ status: 201, body: { calls: ++calls } })
);
const replay = idempotentResponse(
technical,
request(created.token, 'command-1'),
{ title: 'A' },
() => ({ status: 201, body: { calls: ++calls } })
);
expect(first.duplicate).toBe(false);
expect(replay).toMatchObject({ duplicate: true, body: { calls: 1 } });
expect(calls).toBe(1);
expect(() =>
idempotentResponse(technical, request(created.token, 'command-1'), { title: 'B' }, () => ({
status: 201,
body: {}
}))
).toThrow('API_IDEMPOTENCY_CONFLICT');
});
});
+151
View File
@@ -0,0 +1,151 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { AuthUser } from './auth.js';
import { getDatabase } from './database.js';
export type TechnicalPermission = 'read' | 'read_write';
export type TechnicalActor = { id: string; name: string; permissions: TechnicalPermission };
function tokenHash(token: string) {
return createHash('sha256').update(token).digest('hex');
}
export function listTechnicalAccesses() {
return getDatabase()
.prepare(
`SELECT id, name, permissions, created_at_utc AS createdAtUtc,
last_used_at_utc AS lastUsedAtUtc, revoked_at_utc AS revokedAtUtc FROM technical_accesses ORDER BY name`
)
.all() as {
id: string;
name: string;
permissions: TechnicalPermission;
createdAtUtc: string;
lastUsedAtUtc: string | null;
revokedAtUtc: string | null;
}[];
}
export function createTechnicalAccess(actor: AuthUser, nameValue: string, permissions: string) {
const name = nameValue.trim();
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
throw new Error('INVALID_ACCESS');
const token = randomBytes(32).toString('base64url');
const id = randomUUID();
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO technical_accesses
(id, name, token_hash, permissions, created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(id, name, tokenHash(token), permissions, actor.id, now);
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.create',
'technical_access',
id,
'user'
);
return { id, token };
}
export function revokeTechnicalAccess(actor: AuthUser, id: string) {
const result = getDatabase()
.prepare(
'UPDATE technical_accesses SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL'
)
.run(new Date().toISOString(), id);
if (!result.changes) throw new Error('NOT_FOUND');
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.revoke',
'technical_access',
id,
'user'
);
}
export function authenticateTechnical(request: Request, write = false): TechnicalActor {
const authorization = request.headers.get('authorization') ?? '';
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
if (!token) throw new Error('API_UNAUTHORIZED');
const row = getDatabase()
.prepare(
`SELECT id, name, permissions FROM technical_accesses
WHERE token_hash = ? AND revoked_at_utc IS NULL`
)
.get(tokenHash(token)) as TechnicalActor | undefined;
if (!row) throw new Error('API_UNAUTHORIZED');
if (write && row.permissions !== 'read_write') throw new Error('API_FORBIDDEN');
getDatabase()
.prepare('UPDATE technical_accesses SET last_used_at_utc = ? WHERE id = ?')
.run(new Date().toISOString(), row.id);
return row;
}
export function auditTechnical(
actor: TechnicalActor,
action: string,
targetType: string,
targetId: string,
actorType: 'technical' | 'user' = 'technical'
) {
getDatabase()
.prepare(
`INSERT INTO audit_events (id, actor_type, actor_id, action, target_type, target_id, created_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
actorType,
actor.id,
action.slice(0, 120),
targetType.slice(0, 80),
targetId.slice(0, 200),
new Date().toISOString()
);
}
export function idempotentResponse(
actor: TechnicalActor,
request: Request,
body: unknown,
operation: () => { status: number; body: unknown }
) {
const key = request.headers.get('idempotency-key')?.trim() ?? '';
if (!key || key.length > 200) throw new Error('API_IDEMPOTENCY_REQUIRED');
const hash = createHash('sha256')
.update(`${request.method} ${new URL(request.url).pathname}\n${JSON.stringify(body)}`)
.digest('hex');
const database = getDatabase();
return database.transaction(() => {
const existing = database
.prepare(
'SELECT request_hash, status_code, response_json FROM api_idempotency WHERE access_id = ? AND request_key = ?'
)
.get(actor.id, key) as
{ request_hash: string; status_code: number; response_json: string } | undefined;
if (existing) {
if (existing.request_hash !== hash) throw new Error('API_IDEMPOTENCY_CONFLICT');
return {
status: existing.status_code,
body: JSON.parse(existing.response_json),
duplicate: true
};
}
const result = operation();
database
.prepare(
`INSERT INTO api_idempotency
(access_id, request_key, request_hash, status_code, response_json, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
actor.id,
key,
hash,
result.status,
JSON.stringify(result.body),
new Date().toISOString()
);
return { ...result, duplicate: false };
})();
}
+105
View File
@@ -0,0 +1,105 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
const usageId = { name: 'usageId', in: 'path', required: true, schema: { type: 'string' } };
const idempotency = {
name: 'Idempotency-Key',
in: 'header',
required: true,
schema: { type: 'string' }
};
const ok = { '200': { description: 'Erfolgreich' } };
const jsonBody = (schema: Record<string, unknown>) => ({
required: true,
content: { 'application/json': { schema } }
});
export const GET: RequestHandler = () =>
json({
openapi: '3.1.0',
info: {
title: 'Sendewerk API',
version: '1.0.0',
description:
'Versionierte API für technische Redaktionszugänge. Schreibzugriffe benötigen einen Idempotency-Key.'
},
servers: [{ url: '/api/v1' }],
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'Sendewerk technical token' }
},
schemas: {
PieceWrite: {
type: 'object',
properties: {
title: { type: 'string' },
summary: { type: 'string' },
status: { enum: ['inbox', 'preparing', 'ready'] },
durationSeconds: { type: 'integer', minimum: 0 }
}
},
ShowWrite: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string' },
startsAtUtc: { type: ['string', 'null'], format: 'date-time' },
templateId: { type: ['string', 'null'] }
}
}
}
},
paths: {
'/pieces': {
get: { summary: 'Pieces lesen', responses: ok },
post: {
summary: 'Piece anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/PieceWrite' }),
responses: { '201': { description: 'Angelegt' } }
}
},
'/pieces/{id}': {
get: { summary: 'Piece lesen', parameters: [id], responses: ok },
patch: {
summary: 'Piece ändern',
parameters: [id],
requestBody: jsonBody({
allOf: [
{ $ref: '#/components/schemas/PieceWrite' },
{ type: 'object', required: ['revision'] }
]
}),
responses: ok
}
},
'/shows': {
get: { summary: 'Sendungen lesen', responses: ok },
post: {
summary: 'Sendung anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
responses: { '201': { description: 'Angelegt' } }
}
},
'/shows/{id}': { get: { summary: 'Sendung lesen', parameters: [id], responses: ok } },
'/shows/{id}/usages': {
post: {
summary: 'Piece einplanen',
parameters: [id, idempotency],
requestBody: jsonBody({ type: 'object', required: ['revision', 'pieceId'] }),
responses: { '201': { description: 'Eingeplant' } }
}
},
'/shows/{id}/usages/{usageId}': {
patch: {
summary: 'Einplanung sortieren',
parameters: [id, usageId, idempotency],
requestBody: jsonBody({ type: 'object', required: ['revision', 'position'] }),
responses: ok
}
}
}
});
+69
View File
@@ -0,0 +1,69 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
import {
createPiece,
getPiece,
listPieces,
savePiece,
type PieceStatus
} from '#lib/server/content.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, url }) => {
try {
authenticateTechnical(request);
const { limit, offset } = pageInput(url);
const status = url.searchParams.get('status') as PieceStatus | null;
const statuses = status ? [status] : (['inbox', 'preparing', 'ready'] as PieceStatus[]);
const all = listPieces({ statuses, query: url.searchParams.get('q') ?? '' });
return json({
data: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
} catch (cause) {
return apiFailure(cause);
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const id = createPiece('blank');
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
title: String(body.title ?? ''),
summary: String(body.summary ?? ''),
status: ['inbox', 'preparing', 'ready'].includes(String(body.status))
? (body.status as 'inbox' | 'preparing' | 'ready')
: 'preparing',
durationSeconds: Number(body.durationSeconds ?? 0),
sources: Array.isArray(body.sources)
? body.sources.map((source) => ({
label: String((source as Record<string, unknown>).label ?? ''),
url: String((source as Record<string, unknown>).url ?? ''),
timecodeSeconds:
(source as Record<string, unknown>).timecodeSeconds == null
? null
: Number((source as Record<string, unknown>).timecodeSeconds)
}))
: []
});
auditTechnical(actor, 'piece.create', 'piece', id);
return { status: 201, body: { data: getPiece(id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+44
View File
@@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { getPiece, savePiece } from '#lib/server/content.js';
import { auditTechnical, authenticateTechnical } from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, params }) => {
try {
authenticateTechnical(request);
const piece = getPiece(params.id);
if (!piece) throw new Error('NOT_FOUND');
return json({ data: piece });
} catch (cause) {
return apiFailure(cause);
}
};
export const PATCH: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const piece = getPiece(params.id);
if (!piece) throw new Error('NOT_FOUND');
if (piece.status === 'archived') throw new Error('INVALID_STATUS');
if (Number(body.revision) !== piece.revision) throw new Error('CONFLICT');
const status =
body.status !== undefined
? (String(body.status) as 'inbox' | 'preparing' | 'ready')
: piece.status;
savePiece(params.id, piece.revision, {
...piece,
status,
...(body.title !== undefined ? { title: String(body.title) } : {}),
...(body.summary !== undefined ? { summary: String(body.summary) } : {}),
...(body.durationSeconds !== undefined
? { durationSeconds: Number(body.durationSeconds) }
: {})
});
auditTechnical(actor, 'piece.update', 'piece', params.id);
return json({ data: getPiece(params.id) });
} catch (cause) {
return apiFailure(cause);
}
};
+45
View File
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
import { createShow, getShow, listShows } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, url }) => {
try {
authenticateTechnical(request);
const { limit, offset } = pageInput(url);
const all = listShows();
return json({
data: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
} catch (cause) {
return apiFailure(cause);
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const created = createShow({
title: String(body.title ?? ''),
startsAtUtc: body.startsAtUtc ? String(body.startsAtUtc) : null,
templateId: body.templateId ? String(body.templateId) : null
});
auditTechnical(actor, 'show.create', 'show', created.id);
return { status: 201, body: { data: getShow(created.id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+16
View File
@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiFailure } from '#lib/server/api-http.js';
import { getShow } from '#lib/server/shows.js';
import { authenticateTechnical } from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, params }) => {
try {
authenticateTechnical(request);
const show = getShow(params.id);
if (!show) throw new Error('NOT_FOUND');
return json({ data: show });
} catch (cause) {
return apiFailure(cause);
}
};
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { addPieceUsage, getShow } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const POST: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const created = addPieceUsage(params.id, Number(body.revision), {
pieceId: String(body.pieceId ?? ''),
sectionId: body.sectionId ? String(body.sectionId) : null,
position: body.position == null ? undefined : Number(body.position)
});
auditTechnical(actor, 'usage.create', 'usage', created.id);
return { status: 201, body: { data: getShow(params.id), usageId: created.id } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { getShow, movePieceUsage } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const PATCH: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
movePieceUsage(params.id, params.usageId, Number(body.revision), {
sectionId: body.sectionId ? String(body.sectionId) : null,
position: Number(body.position)
});
auditTechnical(actor, 'usage.move', 'usage', params.usageId);
return { status: 200, body: { data: getShow(params.id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+29 -2
View File
@@ -1,11 +1,16 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js';
import { captureInbox, getTaxonomy, listPieces, setPieceDeleted } from '#lib/server/content.js';
import { discardFeedEntries, importFeedEntries, listFeedEntries } from '#lib/server/feeds.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['inbox'] }) };
return {
pieces: listPieces({ statuses: ['inbox'] }),
feedEntries: listFeedEntries(),
taxonomy: getTaxonomy()
};
};
export const actions = {
@@ -36,5 +41,27 @@ export const actions = {
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' });
}
},
importFeeds: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' });
try {
const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String));
return {
feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.`
};
} catch {
return fail(400, { feedError: 'Die Auswahl konnte nicht übernommen werden.' });
}
},
discardFeeds: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' });
const count = discardFeedEntries(entryIds);
return { feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.` };
}
} satisfies Actions;
+116
View File
@@ -1,4 +1,5 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types';
@@ -43,6 +44,65 @@
<button class="primary-button" type="submit">Im Eingang ablegen</button>
</form>
</section>
<section class="quiet-panel feed-inbox">
<div class="panel-heading">
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2>Beiträge sichten</h2>
</div>
<span>{data.feedEntries.length} neu</span>
</div>
<p>Beiträge bleiben hier getrennt, bis du sie auswählst und in den Fundus übernimmst.</p>
{#if form?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if}
{#if form?.feedSuccess}<div class="form-message success" role="status">
{form.feedSuccess}
</div>{/if}
{#if data.feedEntries.length}
<form method="POST" class="feed-selection">
<fieldset class="feed-categories">
<legend>Kategorien für die Auswahl <span>(Feed-Standard gilt ohne Auswahl)</span></legend>
{#each data.taxonomy.categories as category (category.id)}
<label
><input type="checkbox" name="categoryIds" value={category.id} />
{category.name}</label
>
{/each}
</fieldset>
<div class="feed-entry-list">
{#each data.feedEntries as entry (entry.id)}
<label class="feed-entry">
<input type="checkbox" name="entryIds" value={entry.id} />
<span
><small
>{entry.feedName}{entry.publishedAtUtc
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
: ''}</small
><strong>{entry.title}</strong>{#if entry.summary}<span>{entry.summary}</span>{/if}
<a
href={entry.url}
target="_blank"
rel="noreferrer"
onclick={(event) => event.stopPropagation()}>Original öffnen</a
></span
>
</label>
{/each}
</div>
<div class="feed-actions">
<button class="primary-button" type="submit" formaction="?/importFeeds"
>Auswahl übernehmen</button
>
<button class="text-button danger-text" type="submit" formaction="?/discardFeeds"
>Auswahl verwerfen</button
>
</div>
</form>
{:else}
<p class="empty-copy">
Keine neuen Feed-Beiträge. Aktivierte Feeds werden automatisch abgerufen.
</p>
{/if}
</section>
{#if data.pieces.length}
<section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
@@ -81,3 +141,59 @@
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if}
</div>
<style>
.feed-inbox {
display: grid;
gap: 1rem;
margin-block: 1.5rem;
}
.feed-selection,
.feed-entry-list {
display: grid;
gap: 0.75rem;
}
.feed-categories {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1rem;
border: 0;
padding: 0;
}
.feed-categories legend {
width: 100%;
font-weight: 700;
margin-bottom: 0.45rem;
}
.feed-categories legend span {
font-weight: 400;
color: var(--color-text-muted);
}
.feed-entry {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.8rem;
align-items: start;
padding: 0.9rem;
border: 1px solid var(--color-border);
border-radius: 0.7rem;
cursor: pointer;
}
.feed-entry > span {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.feed-entry small {
color: var(--color-text-muted);
}
.feed-entry a {
width: fit-content;
}
.feed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
</style>
+1
View File
@@ -75,6 +75,7 @@ function pieceInput(data: FormData): PieceInput {
summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus,
preparationEffort: (text(data, 'preparationEffort') || null) as PieceInput['preparationEffort'],
durationSeconds: durationInSeconds(data),
intro: text(data, 'intro'),
researchText: text(data, 'researchText'),
+8 -1
View File
@@ -347,6 +347,13 @@
bind:value={durationMinutes}
/></label
>
<label
>Vorbereitungsaufwand<select name="preparationEffort" bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option><option
value="medium">Mittel</option
><option value="high">Hoch</option></select
></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
@@ -542,7 +549,7 @@
bind:value={draft.presentationText}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input
type="radio"
name="presentationMode"
+45 -2
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js';
import {
completeCurrent,
@@ -25,6 +25,8 @@ import {
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) {
return String(data.get(name) || '');
@@ -71,13 +73,15 @@ function attempt(intent: string, operation: () => unknown) {
}
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const actor = requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden');
try {
const live = getLiveView(showId);
return {
live,
canManageShares: actor.role === 'barkeeper',
shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [],
commandId: randomUUID(),
completion:
live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null
@@ -90,6 +94,45 @@ export const load: PageServerLoad = ({ locals, url }) => {
};
export const actions = {
createShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: text(data, 'showId'), scope: text(data, 'scope'), password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
});
return { intent: 'createShare', success: 'Leselink angelegt.', shareUrl: `/freigabe/${share.token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createShare', ...problem });
}
},
updateShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(actor, text(data, 'shareId'), {
scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
}, text(data, 'showId'));
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'updateShare', ...problem });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, text(data, 'shareId'), text(data, 'showId'));
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'deleteShare', ...problem });
}
},
start: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
+18 -1
View File
@@ -4,6 +4,7 @@
import { onMount, tick, untrack } from 'svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
@@ -49,6 +50,9 @@
let currentSection = $derived(
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
);
let viewedSection = $derived(
data.live.sections.find((section) => section.id === viewedUsage?.sectionId) ?? null
);
let presentedUsage = $derived(
allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null
);
@@ -101,6 +105,16 @@
data.live.sectionTimer.sectionId === data.live.current.sectionId
)
);
async function copyTwitchTags() {
if (!viewedSection?.twitchTags.length) return;
try {
await navigator.clipboard.writeText(viewedSection.twitchTags.join(', '));
popupMessage = 'Twitch-Tags kopiert.';
} catch {
popupMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
let runningOffsetSeconds = $derived(
data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0
);
@@ -577,6 +591,7 @@
</section>
<section>
<strong>Ausgabekanal</strong>
{#if data.canManageShares}<ShareManagement shows={[{ ...data.live.show, startsAtUtc: null }]} shares={data.shares} fixedShowId={data.live.show.id} {form} />{/if}
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
@@ -794,12 +809,14 @@
>
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading">
<span class="live-piece-position">Piece {viewedPosition}/{data.live.totalCount}</span>
<h1 id="current-title">{viewedUsage.title}</h1>
{#if showTimes}<strong class="live-piece-time">
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if}
</div>
{#if viewedSection?.twitchTags.length}<button class="text-button live-twitch-tags" type="button" onclick={copyTwitchTags}>
<AppIcon name="copy" /> {viewedSection.twitchTags.join(' · ')}
</button>{/if}
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
<span>Einleitung</span>
+2
View File
@@ -3,6 +3,7 @@ import type { Actions, PageServerLoad } from './$types';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
import { listPieces } from '#lib/server/content.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
export const load: PageServerLoad = ({ locals, url }) => {
@@ -12,6 +13,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return {
shows: listShows(['draft', 'ready', 'live']),
preparationPieces: listPieces({ statuses: ['preparing'] }),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
selectedMonth,
+10
View File
@@ -125,6 +125,16 @@
</form>
</details>
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title">
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header>
<div class="preparation-list">
{#each data.preparationPieces as piece (piece.id)}<a href={resolve('/fundus/[id]', { id: piece.id })}>
<span><strong>{piece.title}</strong>{#if piece.summary}<small>{piece.summary}</small>{/if}</span>
{#if piece.preparationEffort}<b class={`effort ${piece.preparationEffort}`}>{piece.preparationEffort === 'low' ? 'Gering' : piece.preparationEffort === 'medium' ? 'Mittel' : 'Hoch'}</b>{/if}
</a>{/each}
</div>
</section>{/if}
<section class="show-library" aria-labelledby="show-library-title">
<header>
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
+4 -2
View File
@@ -233,7 +233,8 @@ export const actions = {
addShowSection(params.id, Number(data.get('revision')), {
name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data)
explicitDurationSeconds: nullableDuration(data),
twitchTags: text(data, 'twitchTags').split(/[\n,]/)
})
);
},
@@ -244,7 +245,8 @@ export const actions = {
updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), {
name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data)
explicitDurationSeconds: nullableDuration(data),
twitchTags: text(data, 'twitchTags').split(/[\n,]/)
})
);
},
+9
View File
@@ -828,6 +828,13 @@
value={section.explicitDurationSeconds ?? ''}
placeholder="Summe der Pieces"
/></label
><label
>Twitch-Tags<input
name="twitchTags"
maxlength="300"
value={section.twitchTags.join(', ')}
placeholder="Gaming, Deutsch"
/></label
><button class="secondary-button" type="submit">Abschnitt speichern</button>
</form>
<form
@@ -885,6 +892,8 @@
max="86400"
placeholder="optional, Sekunden"
/></label
><label
>Twitch-Tags<input name="twitchTags" maxlength="300" placeholder="Gaming, Deutsch" /></label
><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form>
+83
View File
@@ -12,6 +12,12 @@ import { createShare, deleteShare, listShares, updateShare } from '#lib/server/s
import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import { deleteFeedSource, fetchFeed, listFeedSources, saveFeedSource } from '#lib/server/feeds.js';
import {
createTechnicalAccess,
listTechnicalAccesses,
revokeTechnicalAccess
} from '#lib/server/technical-access.js';
import {
inviteUser,
isRole,
@@ -33,6 +39,8 @@ export const load: PageServerLoad = ({ locals }) => {
tools: listTools(true),
shows: listShows(),
shares: listShares(locals.user!),
feeds: listFeedSources(),
technicalAccesses: listTechnicalAccesses(),
mailTransport: getMailConfig().transport
};
};
@@ -59,6 +67,8 @@ function actionError(cause: unknown): string {
if (code === 'INVALID_EXPIRY') return 'Der Ablaufzeitpunkt muss in der Zukunft liegen.';
if (code === 'INVALID_SCOPE') return 'Der Informationsumfang ist ungültig.';
if (code === 'INVALID_PASSWORD_MODE') return 'Die Passwortoption ist ungültig.';
if (code === 'INVALID_FEED') return 'Name, URL und Abrufintervall bitte prüfen.';
if (code === 'INVALID_ACCESS') return 'Name und Berechtigung bitte prüfen.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
@@ -249,5 +259,78 @@ export const actions = {
} catch (cause) {
return fail(400, { intent: 'tool', error: actionError(cause) });
}
},
saveFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
saveFeedSource(String(data.get('feedId') || '') || null, {
name: String(data.get('name') || ''),
url: String(data.get('url') || ''),
isEnabled: data.get('isEnabled') === '1',
refreshIntervalMinutes: Number(data.get('refreshIntervalMinutes')),
defaultCategoryId: String(data.get('defaultCategoryId') || '') || null
});
return { intent: 'feed', success: 'Feed gespeichert.' };
} catch (cause) {
return fail(400, { intent: 'feed', error: actionError(cause) });
}
},
deleteFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteFeedSource(String(data.get('feedId') || ''));
return { intent: 'feed', success: 'Feed entfernt.' };
} catch (cause) {
return fail(400, { intent: 'feed', error: actionError(cause) });
}
},
fetchFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
const result = await fetchFeed(String(data.get('feedId') || ''));
return {
intent: 'feed',
success: result.unchanged
? 'Feed ist unverändert.'
: `${result.inserted} neue Beiträge abgerufen.`
};
} catch {
return fail(400, {
intent: 'feed',
error: 'Feed konnte nicht sicher abgerufen oder gelesen werden.'
});
}
},
createTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const created = createTechnicalAccess(
actor,
String(data.get('name') || ''),
String(data.get('permissions') || '')
);
return {
intent: 'technicalAccess',
success:
'Technischer Zugang angelegt. Token jetzt sicher hinterlegen; es wird nicht erneut angezeigt.',
technicalToken: created.token
};
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
},
revokeTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
revokeTechnicalAccess(actor, String(data.get('accessId') || ''));
return { intent: 'technicalAccess', success: 'Technischer Zugang widerrufen.' };
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
}
} satisfies Actions;
+235
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
@@ -32,6 +33,176 @@
{form.success}
</div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} />
<section class="quiet-panel integration-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Import</p>
<h2>RSS-Feeds</h2>
</div>
<span>{data.feeds.length} eingerichtet</span>
</div>
<p>
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang.
</p>
<form class="integration-form" method="POST" action="?/saveFeed">
<label>Name<input name="name" required maxlength="120" /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
placeholder="https://example.org/feed.xml"
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value="60"
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label
>
<button class="primary-button" type="submit">Feed anlegen</button>
</form>
<div class="integration-list">
{#each data.feeds as feed (feed.id)}
<details>
<summary
><span
><strong>{feed.name}</strong><small
>{feed.lastError
? `Fehler: ${feed.lastError}`
: feed.lastFetchedAtUtc
? `Zuletzt: ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht abgerufen'}</small
></span
><AppIcon name="pen" /></summary
>
<form class="integration-form" method="POST" action="?/saveFeed">
<input type="hidden" name="feedId" value={feed.id} />
<label>Name<input name="name" required maxlength="120" value={feed.name} /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
value={feed.url}
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value={feed.refreshIntervalMinutes}
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={feed.defaultCategoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked={feed.isEnabled} /> Automatisch
abrufen</label
>
<button class="secondary-button" type="submit">Speichern</button>
</form>
<div class="row-actions">
<form method="POST" action="?/fetchFeed">
<input type="hidden" name="feedId" value={feed.id} /><button
class="text-button"
type="submit">Jetzt abrufen</button
>
</form>
<form
method="POST"
action="?/deleteFeed"
onsubmit={(event) =>
!confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()}
>
<input type="hidden" name="feedId" value={feed.id} /><button
class="text-button danger-text"
type="submit">Feed löschen</button
>
</form>
</div>
</details>
{/each}
</div>
</section>
<section class="quiet-panel integration-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2>Technische Zugänge</h2>
</div>
<a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI</a>
</div>
<p>
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
idempotent sein.
</p>
{#if form?.technicalToken}<div class="token-reveal" role="status">
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
</div>{/if}
<form class="integration-form" method="POST" action="?/createTechnicalAccess">
<label
>Name<input name="name" required maxlength="120" placeholder="Redaktionsassistenz" /></label
>
<label
>Berechtigung<select name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
<button class="primary-button" type="submit">Zugang anlegen</button>
</form>
<div class="integration-list">
{#each data.technicalAccesses as access (access.id)}
<div class="access-row">
<span
><strong>{access.name}</strong><small
>{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
? 'Widerrufen'
: access.lastUsedAtUtc
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht genutzt'}</small
></span
>{#if !access.revokedAtUtc}<form method="POST" action="?/revokeTechnicalAccess">
<input type="hidden" name="accessId" value={access.id} /><button
class="secondary-button"
type="submit">Widerrufen</button
>
</form>{/if}
</div>
{/each}
</div>
</section>
<section class="quiet-panel account-panel">
<div class="panel-heading">
@@ -272,3 +443,67 @@
</section>
</div>
</div>
<style>
.integration-panel {
display: grid;
gap: 1rem;
margin-block: 1.5rem;
}
.integration-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.8rem;
align-items: end;
}
.integration-form label:not(.check-row) {
display: grid;
gap: 0.35rem;
}
.check-row {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 2.6rem;
}
.integration-list {
display: grid;
gap: 0.5rem;
}
.integration-list details,
.access-row {
border-top: 1px solid var(--color-border);
padding-top: 0.8rem;
}
.integration-list summary,
.access-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.integration-list summary > span,
.access-row > span {
display: grid;
gap: 0.2rem;
}
.integration-list small {
color: var(--color-text-muted);
}
.row-actions {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
}
.token-reveal {
display: grid;
gap: 0.4rem;
padding: 0.8rem;
border: 2px solid var(--color-accent);
border-radius: 0.7rem;
overflow-wrap: anywhere;
}
.token-reveal code {
user-select: all;
}
</style>
+2
View File
@@ -1665,6 +1665,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
addCompletionFixture();
try {
await page.goto('/sendungen/e2e-completion');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const planningShares = page.locator('.share-management');
await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible();
await planningShares.getByLabel('Informationsumfang').selectOption('moderation');
@@ -1738,6 +1739,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const shareManagement = page.locator('.share-management');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');