Release 0.7.0
This commit is contained in:
1 parent
d586fbd8ce
commit
6d96900d81
27 files changed
+2060
-25
No files matched your search
@@ -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": {
|
||||
|
||||
+45
-10
@@ -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.
|
||||
|
||||
+43
-6
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+32
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
Generated
+123
-2
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -62,7 +62,7 @@ describe('database setup', () => {
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({
|
||||
migrations: 17,
|
||||
migrations: 18,
|
||||
pieces: 6,
|
||||
shows: 2,
|
||||
usages: 5,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
})();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user