Files
sendewerk/docs/ARCHITECTURE.md
T
2026-10-23 13:16:32 +02:00

293 lines
18 KiB
Markdown

# Architektur
## Systemgrenze
Sendewerk ist ein einzelner SvelteKit-Prozess. Browserseiten, serverseitige
Actions, spätere 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.
```text
Browser -> SvelteKit-Routen -> Anwendungsdienste -> SQLite
| -> Upload-Verzeichnis
+-> später: Mail/RSS über Adapter
```
Servercode lebt unter `src/lib/server` und darf nicht in Browser-Bundles
gelangen. Routen liefern nur die für ihre Ansicht erlaubten Daten. Fachlogik
wird nicht an Svelte-Komponenten gebunden, damit Browseraktionen und die
spätere API dieselben Regeln verwenden.
## Laufzeit und Speicher
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
- Tests: pro Lauf ein temporäres Verzeichnis unter dem Betriebssystem-Tempdir.
- Cloudron: `/app/data/sendewerk.sqlite` und `/app/data/uploads`.
- Migrationen liegen nummeriert unter `migrations/`, laufen in Transaktionen
und werden in `schema_migrations` protokolliert.
- Der Seed nutzt stabile IDs und Transaktionen. Wiederholung liefert denselben
fachlichen Ausgangsbestand, ohne Migrationstabellen anzutasten.
- Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative
ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`.
## Gesamtdatenmodell
Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung und Live-Durchführung. 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 |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Favorit, Dauer, Revision, Texten, Quellen, Werkzeugzuordnungen und privaten Medien; Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themensnapshots, Reservebank und Vorlagen; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
### Historien- und Kopierregel
Beim Einplanen entsteht eine bearbeitbare `piece_usage`-Kopie der
sendungsrelevanten Texte und Dauer. Das Original bleibt verknüpft, wird aber
nicht live gespiegelt. Beim Abschluss wird der damalige Sendungsinhalt stabil
eingefroren. Eine historische Sendung wird dupliziert, nie weiterbearbeitet.
### Dauerregel
Eine Abschnittsdauer ist entweder die Summe ihrer eingeplanten Verwendungen
oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
getrennt.
Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt in
einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit.
## Anmeldung und Rechte
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
`thekencrew`. Passwörter werden mit Argon2id gespeichert. Sitzungs-, Einladungs-
und Reset-Tokens entstehen aus kryptografischem Zufall; in SQLite liegt jeweils
nur ihr SHA-256-Hash. Der Browser erhält ausschließlich das rohe Sitzungstoken
in einem `HttpOnly`-, `SameSite=Lax`-Cookie.
`hooks.server.ts` löst die Sitzung für jeden Request auf. Die Datenbankabfrage
verbindet die Session immer mit dem aktuellen Nutzerzustand, sodass Sperren und
Rollenwechsel ohne verzögerte Token-Erneuerung wirken. Geschützte Routen
erfordern eine Anmeldung; Verwaltungs-Loads und jede Verwaltungs-Action prüfen
zusätzlich die Barkeeper-Rolle. Sperrung und Passwortwechsel löschen bestehende
Sessions transaktional.
Der Erstadmin benötigt sowohl eine leere Nutzertabelle als auch ein extern
konfiguriertes Setup-Token. Einladungs- und Reset-Links sind 24 Stunden gültig,
werden erst beim bestätigenden POST verbraucht und sind genau einmal nutzbar.
Fehlgeschlagene Loginversuche werden pro E-Mail-Schlüssel und Clientadresse in
einem Zeitfenster begrenzt, ohne das Konto dauerhaft zu sperren.
Mail verwendet einen Adapter: `local` schreibt geschützte Dateien in den
konfigurierten Outbox-Pfad, `smtp` nutzt Nodemailer nur nach expliziter Auswahl.
Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der
Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen.
## Inhalte, Markdown und Medien
Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen
erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständige
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
`is_favorite` ist eine globale Eigenschaft des Originals und keine Nutzung oder
Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate
sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten
Arbeitslink und eine Notiz, während Deaktivierung das Katalogobjekt und alle
bestehenden Zuordnungen erhält.
Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
diese Felder nicht.
Piece-Vorlagen liegen als validierte JSON-Snapshots in `piece_templates`. Jeder
Speichervorgang kopiert den vollständigen Vorlageninhalt tief und erhöht bei
einer Aktualisierung die Revision; spätere Änderungen am Ausgangs-Piece wirken
nicht auf die Vorlage zurück. Die leere Vorlage bleibt virtuell. Interner Titel
und optionaler Präsentationstitel sind getrennte Felder; ohne öffentlichen Titel
verwendet die Präsentationsprojektion den internen Titel als Fallback.
Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
Zuordnungen. Archivieren merkt sich den vorherigen Arbeitsstatus für die
Wiederherstellung. Duplikate erhalten neue IDs und unabhängige Bilddateien.
Piece-Dauern werden in den Fundus- und Verwendungseditoren als Minuten erfasst,
an der Servergrenze auf volle Sekunden gerundet und ausschließlich als ganze
Sekunden persistiert.
Markdown wird roh gespeichert, mit Marked serverseitig gerendert und danach mit
sanitize-html auf eine kleine Allowlist reduziert. Bilder werden nicht über
Markdown eingebettet. Uploads akzeptieren höchstens 5 MB und nur anhand ihrer
Dateisignatur erkannte JPEG-, PNG-, WebP- oder GIF-Dateien. Interne Dateinamen
sind zufällige IDs; Originalnamen werden nie als Pfad verwendet. `/media/[id]`
prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`,
Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus.
## Sendungsbau
Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine
Sendung ist die Konflikt- und Transaktionsgrenze: Jede Änderung an Metadaten,
Abschnitten, Verwendungen oder Reihenfolge vergleicht die erwartete Revision und
erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
weitere Bearbeitung geschützt.
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz sowie die
Themennamen. Eigene Notizen liegen ausschließlich an
der Verwendung. Die Referenz zum Original bleibt für Historie und
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte
und den Präsentationszustand atomar aus dem referenzierten Original. Eigene
Notizen, Status und vorhandene Live-Intervalle werden dabei nicht verändert;
fehlt das Original oder ist die Sendungsrevision veraltet, wird die gesamte
Mutation verworfen.
Eine Einplanung zu duplizieren kopiert genau diesen Snapshot mit derselben
Original-ID direkt hinter das Ausgangselement. Die neue Verwendung erhält den
Status `open`, eine neue ID und damit weder Live-Referenzen noch Zeitintervalle.
Die Verwendungshistorie wird nicht separat gepflegt. Sie wird über die
Original-ID aus Verwendungen mit Status `done` in abgeschlossenen Sendungen
abgeleitet; Reservebank und alle anderen Verwendungs- oder Sendungsstatus sind
ausgeschlossen. Über dieselbe Original-ID wird unabhängig davon ermittelt, ob
mindestens eine Verwendung in einer noch nicht abgeschlossenen Sendung liegt.
Der allgemeine Fundus blendet diesen globalen Kontext standardmäßig aus. Die
Quellspalte einer Sendung betrachtet für ihren separaten Einplanungsfilter nur
die aktuelle Sendung einschließlich Reservebank.
Jede Sendung besitzt intern genau einen ausgeblendeten Reserveabschnitt. Dadurch
verwenden Plan und Reserve dieselbe geprüfte Verwendungslogik, während die
Reserve in der öffentlichen Struktur und allen Dauerberechnungen getrennt
bleibt. Positionen werden nach jeder Transaktion kollisionsfrei normalisiert.
Eine explizite Abschnittsdauer ersetzt die Summe der Verwendungen vollständig.
Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
die Vorlage deshalb nicht verändern.
Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer
verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die
Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs
zurückgibt und weder ein zweites Piece noch eine zweite Einplanung erzeugt.
Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem
nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das
Dokument bei Planungsmutationen bestehen; nur die serverseitigen Seitendaten
werden aktualisiert. Ladeanzeige und FLIP-Animation liegen am betroffenen
Abschnitt oder an der betroffenen Verwendung. Eindeutige Drag-Griffe übertragen
entweder eine Quellenkopie, einen Abschnitt oder eine Verwendung auf dieselben
serverseitig geprüften Actions wie die alternativen Schaltflächen. Während des
Ziehens bleibt das Original transparent an seinem Ausgangsort; eine flüchtige
Vorschau in der Liste belegt bereits die anhand der Zeigerposition berechnete
Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst
beim Loslassen.
Die Ausschlussfilter für gesendete und bereits eingeplante Pieces arbeiten
clientseitig auf der serverseitig berechneten Kontextmenge. Ihr Zustand wird
ohne Navigation in die URL übernommen und bei nachfolgenden Suchfiltern
mitgesendet. Planmutationen invalidieren anschließend die Seitendaten, sodass
das Entfernen der letzten Einplanung ein Original unmittelbar wieder anbietet.
## Live-Modus und Präsentationsgrenze
`src/lib/server/live.ts` ist die Transaktionsgrenze der Live-Durchführung. Der
Live-Stand besitzt eine eigene Revision. Jeder steuernde Befehl trägt zusätzlich
eine vom Browser erzeugte Befehls-ID; `(show_id, command_id)` ist eindeutig und
liefert bei Wiederholung das bereits gespeicherte Ergebnis. Tatsächliche Zeiten
liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
geplanten Dauer nachvollziehbar.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
Navigation oder Nutzerdaten. Der geheime 256-Bit-Schlüssel steht nur im
URL-Fragment und wird per POST gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie
getauscht. SQLite speichert seinen SHA-256-Hash für die Zugangsprüfung sowie den
rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
erneutes Öffnen behält ihn bei.
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
Grenze passieren.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
erst nach einer ausdrücklichen Snapshot-Aktualisierung.
Linkmetadaten werden nur auf ausdrückliche Aktion durch
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost
serverseitig auf, sperrt lokale und private IPv4-/IPv6-Ziele und bindet den
Request an die geprüfte Adresse. Dieselbe Prüfung gilt nach höchstens drei
Redirects. Zeit- und Größenlimits begrenzen HTML, Anbieter-Metadaten und Bilder.
Externe Vorschaubilder gelangen nur über token- beziehungsweise
sitzungsgebundene Proxyrouten in die Ausgabe; fremdes HTML und fremde Skripte
werden nie übernommen.
Videoquellen werden aus der gespeicherten URL erneut auf eine streng validierte
YouTube- oder TikTok-ID reduziert. Der Browser erhält nur daraus erzeugte
offizielle Embed-URLs, startet sie nicht automatisch und ersetzt das Iframe bei
jedem Inhaltswechsel. YouTube erhält die Startsekunde als Embed-Parameter;
TikTok wird nach dessen Bereitschaft über die offizielle Player-`postMessage`-
Schnittstelle positioniert. Nicht unterstützte oder unvollständige Videoquellen
fallen auf eine normale Linkkarte zurück. Eine authentifizierte Testansicht
verwendet denselben Renderer, schreibt aber keinen Live-Zustand.
Die Ausgabe synchronisiert ihren kleinen öffentlichen Zustand per
`no-store`-Polling. Der letzte gültige Inhalt bleibt bei einem Verbindungsfehler
sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung
mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses
Zustandsobjekts.
## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation.
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
Mutationen.
## Sicherheitsgrenzen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für
Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach
DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits.