From 6d96900d8109910fb212df3829a59231b4c40f1b Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sun, 25 Oct 2026 18:40:01 +0100 Subject: [PATCH] Release 0.7.0 --- CloudronManifest.json | 2 +- docs/ACCEPTANCE.md | 55 ++- docs/ARCHITECTURE.md | 49 +- docs/DECISIONS.md | 33 ++ docs/RELEASE_NOTES.md | 21 + docs/STATUS.md | 34 +- migrations/0018_rss_and_api.sql | 66 +++ package-lock.json | 125 ++++- package.json | 3 +- src/hooks.server.ts | 4 + src/lib/server/api-http.ts | 47 ++ src/lib/server/database.spec.ts | 2 +- src/lib/server/feeds.spec.ts | 134 +++++ src/lib/server/feeds.ts | 457 ++++++++++++++++++ src/lib/server/technical-access.spec.ts | 97 ++++ src/lib/server/technical-access.ts | 151 ++++++ src/routes/api/v1/openapi.json/+server.ts | 105 ++++ src/routes/api/v1/pieces/+server.ts | 69 +++ src/routes/api/v1/pieces/[id]/+server.ts | 44 ++ src/routes/api/v1/shows/+server.ts | 45 ++ src/routes/api/v1/shows/[id]/+server.ts | 16 + .../api/v1/shows/[id]/usages/+server.ts | 31 ++ .../v1/shows/[id]/usages/[usageId]/+server.ts | 30 ++ src/routes/eingang/+page.server.ts | 31 +- src/routes/eingang/+page.svelte | 116 +++++ src/routes/verwaltung/+page.server.ts | 83 ++++ src/routes/verwaltung/+page.svelte | 235 +++++++++ 27 files changed, 2060 insertions(+), 25 deletions(-) create mode 100644 migrations/0018_rss_and_api.sql create mode 100644 src/lib/server/api-http.ts create mode 100644 src/lib/server/feeds.spec.ts create mode 100644 src/lib/server/feeds.ts create mode 100644 src/lib/server/technical-access.spec.ts create mode 100644 src/lib/server/technical-access.ts create mode 100644 src/routes/api/v1/openapi.json/+server.ts create mode 100644 src/routes/api/v1/pieces/+server.ts create mode 100644 src/routes/api/v1/pieces/[id]/+server.ts create mode 100644 src/routes/api/v1/shows/+server.ts create mode 100644 src/routes/api/v1/shows/[id]/+server.ts create mode 100644 src/routes/api/v1/shows/[id]/usages/+server.ts create mode 100644 src/routes/api/v1/shows/[id]/usages/[usageId]/+server.ts diff --git a/CloudronManifest.json b/CloudronManifest.json index d0be45b..7451db5 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -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.1", + "version": "0.7.0", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index c05396b..c8ad78c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,17 +1,52 @@ # 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 | +| 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77647a7..fd657c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2de2df0..acbc832 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -459,3 +459,36 @@ 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. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index c99a847..ff6201b 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,5 +1,26 @@ # 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 diff --git a/docs/STATUS.md b/docs/STATUS.md index bd62ab1..f245360 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,10 +2,40 @@ ## 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 diff --git a/migrations/0018_rss_and_api.sql b/migrations/0018_rss_and_api.sql new file mode 100644 index 0000000..06ef29d --- /dev/null +++ b/migrations/0018_rss_and_api.sql @@ -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); diff --git a/package-lock.json b/package-lock.json index 1c0102e..39a0a77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "sendewerk", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.6.1", + "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", diff --git a/package.json b/package.json index f74a05f..20016cd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.6.1", + "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", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 942d2f4..9f0d6df 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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'); diff --git a/src/lib/server/api-http.ts b/src/lib/server/api-http.ts new file mode 100644 index 0000000..1ee9dd8 --- /dev/null +++ b/src/lib/server/api-http.ts @@ -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; + } 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 }; +} diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index 31019e9..ab77ef4 100644 --- a/src/lib/server/database.spec.ts +++ b/src/lib/server/database.spec.ts @@ -62,7 +62,7 @@ describe('database setup', () => { database.close(); expect(counts).toEqual({ - migrations: 17, + migrations: 18, pieces: 6, shows: 2, usages: 5, diff --git a/src/lib/server/feeds.spec.ts b/src/lib/server/feeds.spec.ts new file mode 100644 index 0000000..7a8a6b0 --- /dev/null +++ b/src/lib/server/feeds.spec.ts @@ -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 = `Test +entry-1Ein Beitraghttps://example.org/post +Kurz und gut.

]]>
+Sat, 24 Oct 2026 12:00:00 GMT
`; + +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('