diff --git a/CloudronManifest.json b/CloudronManifest.json
index d4cb3a9..75a11cc 100644
--- a/CloudronManifest.json
+++ b/CloudronManifest.json
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
- "version": "0.5.12",
+ "version": "0.6.0",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md
index 5aa7a47..e09882f 100644
--- a/docs/ACCEPTANCE.md
+++ b/docs/ACCEPTANCE.md
@@ -506,3 +506,37 @@ vollständige erneute Matrix.
- Zusätzliche visuelle Chromium-Prüfung bei 656 × 400 und 390 × 700 Pixeln:
Sendungskarte unter 130 Pixel hoch, Menü-/Ausgabe-/Timericons sichtbar und
Pausebutton wechselt bedienbar zu Fortsetzen.
+
+## Etappe 6
+
+| Kriterium | Nachweis | Stand |
+| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------- |
+| Spätere Originaländerung verändert Historie nicht | Verhaltenstest ändert Piece und Abschnittskategorie nach Abschluss; historische Snapshots bleiben gleich | erfüllt am 25.10.2026 |
+| Abschluss nicht doppelt; übersprungen ist nicht gesendet | idempotente Befehlswiederholung, fremde Befehls-ID und materialisierte Usage-Historie im Verhaltenstest | erfüllt am 25.10.2026 |
+| Bewusste Auswahl archiviert nur gewählte Originale | Abschlusskandidaten und explizite Archivauswahl im Service; Dialog trennt gesendet, übersprungen und offen | erfüllt am 25.10.2026 |
+| Archivierte Sendung stabil und duplizierbar | Service- und Browsertest prüfen Nur-Lese-Ansicht sowie neuen Entwurf ohne Live-, Historien- oder Freigabedaten | erfüllt am 25.10.2026 |
+| Gast sieht genau eine Sendung und nur erlaubte Felder | anonymer Chromium-Kontext und Scope-Verhaltenstests für Ablauf, Moderation und Recherche | erfüllt am 25.10.2026 |
+| Gast-Schreibzugriff und fremde IDs abgelehnt | anonymer direkter POST erhält 403; fremde Medien-ID erhält 404 | erfüllt am 25.10.2026 |
+| Passwort, Ablauf und Widerruf schützen direkte Aufrufe | Service- und Browsertests prüfen Seite, Gast-Sitzung und direkten Medienrequest vor und nach Widerruf | erfüllt am 25.10.2026 |
+| Barkeeper verwaltet Freigaben | Anlage, Bearbeitung und Löschung direkt in der Sendung sowie gruppiert in der Verwaltung im Browser geprüft | erfüllt am 25.10.2026 |
+| Offene Sendungen sind sicher freigebbar | Service und anonymer Browser prüfen aktuellen Plan vor Abschluss und stabilen Stand danach | erfüllt am 25.10.2026 |
+| Bestehende Abschlüsse werden einmalig übernommen | Upgrade-Test wendet `0015` auf einen Stand bis `0014` zweimal an und findet genau eine Historie | erfüllt am 25.10.2026 |
+| Bestehende Freigaben bleiben bei Erweiterung erhalten | Migration `0016` ersetzt nur die Fremdschlüsselgrenze und kopiert Freigaben sowie Gast-Sitzungen | erfüllt am 25.10.2026 |
+
+## Ausgeführte Prüfungen Etappe 6
+
+- `npm run check`: 0 Fehler und 0 Warnungen
+- `npm test`: 52 Tests in 11 Dateien erfolgreich
+- gezielter Chromium-Ablauf für Abschluss, Archiv, historische Ansicht,
+ offene und abgeschlossene Freigabe, Bearbeitung und Löschung in beiden
+ Barkeeper-Oberflächen, Passwort, privaten Scope, Reserve-Medienschutz, fremde
+ ID und Gast-POST: 1 von 1 erfolgreich
+- `npm run test:e2e`: nach Korrektur eines bestehenden, auf den 24. Oktober
+ festgelegten „Heute“-Fixtures 18 von 18 Chromium-Browsertests erfolgreich
+- Browserprüfung von Sendungsfreigabe und Gastansicht bei 390 × 844 Pixeln sowie
+ der Verwaltung bei 1280 × 900 Pixeln ohne horizontalen Überlauf; Screenshots
+ visuell kontrolliert
+- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
+- `npm run lint`: Prettier und ESLint erfolgreich
+- Versionsgleichheit `0.6.0` in Paket, Lockdatei und Cloudron-Manifest geprüft
+- `bash -n start.sh` und `git diff --check`: erfolgreich
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index f9a63bd..8c4518a 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -33,22 +33,22 @@ spätere API dieselben Regeln verwenden.
## 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.
+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.
-| 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` 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 |
+| 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` 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 |
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnitts-/Gesamttimer 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 |
+| 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
@@ -238,13 +238,12 @@ Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von de
geplanten Dauer nachvollziehbar.
`live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen
-behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden setzt den
-Sendungsstatus auf `completed`, schließt laufende Intervalle, markiert offene
-Pieces aber nicht als erledigt. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten
-und Fortschritt; nur „Neuer Durchlauf“ verwirft Laufintervalle und setzt die
-Piece-Status zurück. Die Restzeitschätzung verwendet je Abschnitt entweder die
-positive Abschnittsdauer oder die verbleibenden Piece-Dauern und zählt beide nie
-zusammen.
+behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden schließt
+laufende Intervalle und setzt `ended_at_utc`, finalisiert die Sendung aber noch
+nicht. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten und Fortschritt; nur
+„Neuer Durchlauf“ verwirft Laufintervalle und setzt die Piece-Status zurück. Die
+Restzeitschätzung verwendet je Abschnitt entweder die positive Abschnittsdauer
+oder die verbleibenden Piece-Dauern und zählt beide nie zusammen.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
@@ -295,6 +294,52 @@ sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung
mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses
Zustandsobjekts.
+## Abschluss, Historie und Lesefreigaben
+
+`src/lib/server/completion.ts` ist die Transaktionsgrenze des endgültigen
+Sendungsabschlusses. Er verlangt einen beendeten Live-Lauf sowie passende Show-
+und Live-Revisionen. Eine Befehls-ID in `show_finalizations` macht Wiederholungen
+idempotent. In derselben Transaktion werden offene Intervalle geschlossen,
+Abschnittskategorien eingefroren, ausschließlich erledigte Verwendungen außerhalb
+der Reservebank in `piece_usage_history` materialisiert, bewusst gewählte
+Fundus-Originale archiviert und die Sendung auf `completed` gesetzt. Jede normale
+Planungs- und Live-Mutation sperrt diesen Zustand.
+
+Die eigentlichen Sendungstexte, Quellen, Werkzeuge, Themen und Medien liegen
+weiterhin in den beim Einplanen erzeugten Usage-Snapshots. Finalisierung friert
+die zuvor noch katalogabhängige Abschnittskategorie ein. Abgeschlossene
+Sendungen werden ausschließlich gelesen oder vollständig in einen neuen Entwurf
+dupliziert. Das Duplikat erhält neue Show-, Abschnitts-, Usage- und Relations-IDs,
+offene Usage-Status und weder Live-, Historien- noch Freigabedaten.
+
+`src/lib/server/shares.ts` erzeugt für Barkeeper zufällige 256-Bit-Lesetokens;
+SQLite speichert nur SHA-256-Hashes. Eine Freigabe verweist direkt auf eine
+Sendung und darf deshalb bereits im Entwurf, im bereiten oder im laufenden
+Zustand entstehen. Der Umfang ist eine Positivliste aus `rundown`, `moderation`
+oder `research`. Optionale Passwörter liegen als Argon2id-Hash vor. Nach
+erfolgreicher Prüfung entsteht eine höchstens zwölf Stunden gültige, ebenfalls
+nur gehashte Gast-Sitzung; Fehlversuche werden pro Link und Clientadresse
+begrenzt. Änderungen an Umfang, Passwort oder Ablauf invalidieren bestehende
+Gast-Sitzungen. Ablauf und Löschung werden bei jedem HTML-, Seitendaten- und
+Medienrequest erneut geprüft.
+
+Die öffentliche Route `/freigabe/[token]` erhält keine interne Sendungs-ID und
+keinen ungefilterten `Show`-Datensatz. Sie projiziert genau die am Token gebundene
+Sendung und nur die Felder ihres Umfangs. Vor dem Abschluss werden aktuelle
+Sendungs- und Usage-Snapshots bei jedem Aufruf neu gelesen; danach liefert
+dieselbe Projektion die durch die Finalisierung stabilisierten Snapshots. Die
+separate Medienroute akzeptiert ausschließlich das ausdrücklich gewählte
+Präsentationsmedium einer eingeplanten Nicht-Reserve-Verwendung derselben
+Sendung. Private `/media`-Dateien bleiben weiterhin an eine Redaktionssitzung
+gebunden.
+
+Die wiederverwendbare Barkeeper-Komponente `ShareManagement.svelte` liegt direkt
+in der Sendungsbearbeitung und in der Verwaltung. Beide Routen verwenden
+dieselben Servicefunktionen für Anlage, Bearbeitung und Löschung; die
+Sendungsroute bindet Mutationen zusätzlich an ihre eigene Sendungs-ID. Die
+Verwaltung gruppiert bestehende Freigaben nach Sendung und zeigt deren aktuellen
+Status und Termin.
+
## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 66b4223..f51ca82 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -419,9 +419,35 @@ verbleibenden Piece-Dauern. Bereits abgeschlossene Abschnitte werden ausgelassen
ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert
nicht die geplante Zeit kommender Inhalte.
-`completed` beendet einen Durchlauf, nicht automatisch dessen offene Pieces.
+`ended_at_utc` beendet einen Durchlauf, nicht automatisch dessen offene Pieces.
Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere
bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück.
Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze
wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen
Präsentations-Positivliste.
+
+## D-027: Abschlusszustand und Freigaben als eigene Sicherheitsgrenzen
+
+Ein beendeter Live-Lauf ist wiederaufnehmbar und deshalb nicht identisch mit
+einer historischen Sendung. Erst eine eigene transaktionale Finalisierung setzt
+`shows.status` auf `completed`. Eine dauerhaft gespeicherte Befehls-ID verhindert
+Doppelverbuchung; Usage-Historie enthält nur `done`, nie `skipped` oder Reserve.
+Bestehende Usage-Snapshots bleiben Inhaltsquelle, während Abschnittskategorien
+beim Abschluss zusätzlich eingefroren werden. Historische Sendungen werden nicht
+erneut bearbeitet, sondern mit neuen IDs und zurückgesetztem Laufzustand kopiert.
+
+Lesefreigaben sind keine abgeschwächten Redaktionssitzungen. Sie verwenden eine
+eigene Positivprojektion mit drei kumulativen Umfängen. Roh-Tokens und
+Gast-Sitzungstokens stehen nie in SQLite, optionale Passwörter verwenden Argon2id.
+Passwort, Ablauf und Widerruf werden zentral vor Seite, Seitendaten und Medium
+geprüft. Präsentationsmedien sind an Freigabe, Sendung und Usage-Snapshot
+gebunden; beliebige Fundus-Dateien bleiben ausgeschlossen.
+
+Freigaben verweisen auf `shows` statt ausschließlich auf Finalisierungen. Damit
+kann ein Barkeeper bereits einen Entwurf oder eine laufende Planung teilen. Die
+Gastprojektion liest bei jedem Aufruf die aktuellen Sendungssnapshots; der
+spätere Abschluss stabilisiert genau diese bestehende Datenquelle, ohne Token
+oder URL zu wechseln. Bearbeitungen behalten den Link, invalidieren aber
+vorhandene Passwortsitzungen. Vollständiges Löschen ist der unmittelbare
+Widerruf. Die Sendungsbearbeitung und die zentrale Verwaltung verwenden dieselbe
+Geschäftslogik und bleiben Barkeepern vorbehalten.
diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md
index 1ea864a..12a8aa9 100644
--- a/docs/RELEASE_NOTES.md
+++ b/docs/RELEASE_NOTES.md
@@ -1,5 +1,58 @@
# Release Notes
+## 0.6.0 - Abschluss, Archiv und Freigaben
+
+Veröffentlicht am 25. Oktober 2026
+
+Sendewerk 0.6.0 trennt das wiederaufnehmbare Ende eines Live-Laufs vom
+unveränderlichen redaktionellen Abschluss. Abgeschlossene Sendungen erhalten
+eine stabile Historie, liegen im Archiv und können als neuer Entwurf dupliziert
+werden. Barkeeper geben genau eine geplante, laufende oder historische Sendung
+über widerrufbare, optional passwortgeschützte Leselinks frei.
+
+### Abschluss und Historie
+
+- Ein Abschlussdialog zeigt gesendete, übersprungene und offene Pieces getrennt
+ und bietet ausschließlich gesendete Originale zur bewussten Archivierung an.
+- Finalisierung ist transaktional und idempotent. Übersprungene Pieces und die
+ Reservebank werden nicht als gesendet verbucht.
+- Usage-Texte, Relationen, Kategorien und tatsächliche Zeiten bleiben nach dem
+ Abschluss stabil, auch wenn Fundus-Originale oder Kataloge später wechseln.
+- Historische Sendungen sind intern nur lesbar. Die Duplikation erzeugt einen
+ unabhängigen Entwurf mit offenen Pieces und ohne früheren Lauf- oder
+ Freigabezustand.
+
+### Archiv und Freigaben
+
+- Das Archiv zeigt abgeschlossene Sendungen neben archivierten und gelöschten
+ Pieces und bietet die Duplikation direkt an.
+- Barkeeper erstellen Leselinks direkt in der Sendungsbearbeitung oder zentral
+ in der Verwaltung für Ablauf, Moderation oder vollständige Recherche, optional
+ mit Passwort und Ablaufzeit.
+- Noch nicht abgeschlossene Sendungen zeigen Gästen beim nächsten Aufruf den
+ aktuellen Plan. Nach dem Abschluss bleibt derselbe Link auf dem stabilen
+ historischen Stand.
+- Freigaben lassen sich in nach Sendung gruppierten Verwaltungskarten bearbeiten
+ und endgültig löschen; Passwortänderungen beenden vorhandene Gast-Sitzungen.
+- Tokens und Gast-Sitzungen liegen nur gehasht vor; Passwörter verwenden
+ Argon2id und Fehlversuche sind begrenzt.
+- Gastseiten projizieren genau eine Sendung. Private Originaldaten, Nutzer,
+ interne IDs und Felder außerhalb des gewählten Umfangs werden nicht geliefert.
+- Direkte Medienaufrufe prüfen dieselbe Freigabe, Passwortsitzung, Ablaufzeit und
+ den Widerruf erneut und erlauben nur das freigegebene Präsentationsmedium.
+
+### Technik und Prüfung
+
+- Migration `0015_completion_archive_and_shares.sql` übernimmt bestehende
+ abgeschlossene Sendungen und ergänzt Finalisierung, Historie,
+ Duplikationsbefehle, Freigaben, Gast-Sitzungen und
+ Passwortversuchsbegrenzung.
+- Migration `0016_shares_for_active_shows.sql` erhält vorhandene Freigaben und
+ erweitert ihre Referenz auf alle Sendungszustände.
+- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.6.0`.
+- 52 Verhaltenstests und 18 Chromium-Abläufe decken den bisherigen Umfang sowie
+ Abschluss, Archiv und anonyme Lesefreigaben ab.
+
## 0.5.12 - Live-Regie und Sendungskalender
Veröffentlicht am 24. Oktober 2026
diff --git a/docs/STATUS.md b/docs/STATUS.md
index ded0c8a..72803f9 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -2,10 +2,45 @@
## Aktueller Umfang
-Etappe 1 bis Etappe 5 sind abgeschlossen. Architektur, Gesamtdatenmodell,
+Etappe 1 bis Etappe 6 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
-Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
-Etappe 6 wurde nicht begonnen.
+Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
+Archiv und sichere Lesefreigaben sind dokumentiert.
+
+## Abschluss, Archiv und Freigaben 0.6.0
+
+- das Ende eines Live-Laufs bleibt wiederaufnehmbar; erst der getrennte,
+ idempotente Abschluss friert Sendung, Kategorien, tatsächliche Verwendungen
+ und Zeiten unveränderlich ein
+- der Abschlussdialog zählt gesendete, übersprungene und offene Pieces getrennt
+ und archiviert Fundus-Originale ausschließlich nach bewusster Auswahl
+- die Verwendungshistorie wird beim Abschluss materialisiert; übersprungene
+ Verwendungen und die Reservebank zählen nicht als gesendet
+- abgeschlossene Sendungen liegen getrennt im Archiv, sind intern nur lesbar und
+ lassen sich mit neuen IDs, offenen Verwendungen und ohne Live- oder
+ Freigabezustand in einen neuen Entwurf duplizieren
+- Barkeeper verwalten gehashte Leselinks direkt in der Sendungsbearbeitung und
+ gesammelt in der Verwaltung; Umfang, Passwort und Ablauf lassen sich ändern,
+ Freigaben vollständig löschen
+- Entwürfe, bereite und laufende Sendungen lassen sich bereits freigeben;
+ Planänderungen werden beim nächsten Gastaufruf sichtbar, während der Abschluss
+ denselben Link auf den unveränderlichen historischen Stand festlegt
+- passwortgeschützte Freigaben verwenden kurzlebige, gehashte Gast-Sitzungen und
+ begrenzen Fehlversuche pro Link und Clientadresse
+- Gastseiten liefern genau eine positiv projizierte Sendung ohne interne IDs,
+ Originaldaten oder nicht freigegebene Felder; geschützte Präsentationsmedien
+ prüfen Umfang, Passwortsitzung, Ablauf und Widerruf bei jedem Direktaufruf
+- bestehende abgeschlossene Sendungen werden durch Migration `0015` mit
+ eingefrorener Historie übernommen; Migration `0016` erweitert bestehende
+ Freigaben verlustfrei auf noch nicht abgeschlossene Sendungen
+
+Die lokale Prüfmatrix umfasst 52 Verhaltenstests und 18 Chromium-Abläufe.
+Abschluss, Archiv, historische Nur-Lese-Ansicht, Passwortfreigabe,
+Scope-Begrenzung, direkter Medienzugriff, fremde IDs, Gast-Schreibversuch und
+Widerruf wurden zusätzlich in einem anonymen Browserkontext geprüft. Der
+erweiterte Ablauf prüft außerdem offene Sendungen, aktuelle Planänderungen,
+Bearbeiten und Löschen in beiden Barkeeper-Oberflächen sowie Desktop- und
+Smartphone-Layout.
## Live-Regie und Sendungskalender 0.5.12
diff --git a/migrations/0015_completion_archive_and_shares.sql b/migrations/0015_completion_archive_and_shares.sql
new file mode 100644
index 0000000..7f03a58
--- /dev/null
+++ b/migrations/0015_completion_archive_and_shares.sql
@@ -0,0 +1,115 @@
+ALTER TABLE shows ADD COLUMN completed_at_utc TEXT;
+
+ALTER TABLE show_sections ADD COLUMN category_name_snapshot TEXT;
+ALTER TABLE show_sections ADD COLUMN category_color_snapshot TEXT;
+
+CREATE TABLE show_finalizations (
+ show_id TEXT PRIMARY KEY REFERENCES shows(id) ON DELETE CASCADE,
+ command_id TEXT NOT NULL,
+ source_revision INTEGER NOT NULL CHECK (source_revision >= 1),
+ finalized_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
+ finalized_at_utc TEXT NOT NULL,
+ UNIQUE (show_id, command_id)
+);
+
+CREATE TABLE show_finalization_archived_pieces (
+ show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
+ piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE RESTRICT,
+ PRIMARY KEY (show_id, piece_id)
+);
+
+CREATE TABLE piece_usage_history (
+ usage_id TEXT PRIMARY KEY REFERENCES piece_usages(id) ON DELETE RESTRICT,
+ show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE RESTRICT,
+ original_piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE RESTRICT,
+ show_title_snapshot TEXT NOT NULL,
+ usage_title_snapshot TEXT NOT NULL,
+ used_at_utc TEXT NOT NULL,
+ actual_duration_seconds INTEGER NOT NULL CHECK (actual_duration_seconds >= 0)
+);
+
+CREATE INDEX idx_piece_usage_history_original
+ ON piece_usage_history(original_piece_id, used_at_utc DESC);
+
+CREATE TABLE show_duplication_commands (
+ source_show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
+ command_id TEXT NOT NULL,
+ created_show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
+ created_at_utc TEXT NOT NULL,
+ PRIMARY KEY (source_show_id, command_id)
+);
+
+CREATE TABLE show_shares (
+ id TEXT PRIMARY KEY,
+ show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
+ token_hash TEXT NOT NULL UNIQUE,
+ scope TEXT NOT NULL CHECK (scope IN ('rundown', 'moderation', 'research')),
+ password_hash TEXT,
+ expires_at_utc TEXT,
+ revoked_at_utc TEXT,
+ created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
+ created_at_utc TEXT NOT NULL
+);
+
+CREATE INDEX idx_show_shares_show ON show_shares(show_id, created_at_utc DESC);
+
+CREATE TABLE show_share_sessions (
+ token_hash TEXT PRIMARY KEY,
+ share_id TEXT NOT NULL REFERENCES show_shares(id) ON DELETE CASCADE,
+ created_at_utc TEXT NOT NULL,
+ expires_at_utc TEXT NOT NULL
+);
+
+CREATE INDEX idx_show_share_sessions_share ON show_share_sessions(share_id, expires_at_utc);
+
+CREATE TABLE share_password_attempts (
+ id TEXT PRIMARY KEY,
+ share_key TEXT NOT NULL,
+ address_key TEXT NOT NULL,
+ attempted_at_utc TEXT NOT NULL
+);
+
+CREATE INDEX idx_share_password_attempts_share
+ ON share_password_attempts(share_key, attempted_at_utc);
+CREATE INDEX idx_share_password_attempts_address
+ ON share_password_attempts(address_key, attempted_at_utc);
+
+UPDATE show_sections
+SET category_name_snapshot = (
+ SELECT name FROM section_categories WHERE id = show_sections.category_id
+ ),
+ category_color_snapshot = (
+ SELECT color FROM section_categories WHERE id = show_sections.category_id
+ )
+WHERE show_id IN (SELECT id FROM shows WHERE status = 'completed');
+
+UPDATE shows
+SET completed_at_utc = COALESCE(
+ (SELECT ended_at_utc FROM show_live_states WHERE show_id = shows.id),
+ updated_at_utc
+)
+WHERE status = 'completed';
+
+INSERT INTO show_finalizations (
+ show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc
+)
+SELECT id, 'migration-' || id, revision, NULL, completed_at_utc
+FROM shows
+WHERE status = 'completed';
+
+INSERT INTO piece_usage_history (
+ usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
+ used_at_utc, actual_duration_seconds
+)
+SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
+ COALESCE(sh.starts_at_utc, sh.completed_at_utc),
+ COALESCE((
+ SELECT sum(max(0, CAST((julianday(COALESCE(r.ended_at_utc, sh.completed_at_utc)) -
+ julianday(r.started_at_utc)) * 86400 AS INTEGER)))
+ FROM live_usage_runs r WHERE r.usage_id = u.id
+ ), 0)
+FROM piece_usages u
+JOIN show_sections s ON s.id = u.show_section_id
+JOIN shows sh ON sh.id = s.show_id
+WHERE sh.status = 'completed' AND s.is_reserve_bank = 0
+ AND u.status = 'done' AND u.original_piece_id IS NOT NULL;
diff --git a/migrations/0016_shares_for_active_shows.sql b/migrations/0016_shares_for_active_shows.sql
new file mode 100644
index 0000000..b77ef63
--- /dev/null
+++ b/migrations/0016_shares_for_active_shows.sql
@@ -0,0 +1,43 @@
+DROP INDEX idx_show_share_sessions_share;
+DROP INDEX idx_show_shares_show;
+
+ALTER TABLE show_share_sessions RENAME TO show_share_sessions_v15;
+ALTER TABLE show_shares RENAME TO show_shares_v15;
+
+CREATE TABLE show_shares (
+ id TEXT PRIMARY KEY,
+ show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
+ token_hash TEXT NOT NULL UNIQUE,
+ scope TEXT NOT NULL CHECK (scope IN ('rundown', 'moderation', 'research')),
+ password_hash TEXT,
+ expires_at_utc TEXT,
+ revoked_at_utc TEXT,
+ created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
+ created_at_utc TEXT NOT NULL
+);
+
+CREATE INDEX idx_show_shares_show ON show_shares(show_id, created_at_utc DESC);
+
+CREATE TABLE show_share_sessions (
+ token_hash TEXT PRIMARY KEY,
+ share_id TEXT NOT NULL REFERENCES show_shares(id) ON DELETE CASCADE,
+ created_at_utc TEXT NOT NULL,
+ expires_at_utc TEXT NOT NULL
+);
+
+CREATE INDEX idx_show_share_sessions_share ON show_share_sessions(share_id, expires_at_utc);
+
+INSERT INTO show_shares (
+ id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
+ created_by_user_id, created_at_utc
+)
+SELECT id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
+ created_by_user_id, created_at_utc
+FROM show_shares_v15;
+
+INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
+SELECT token_hash, share_id, created_at_utc, expires_at_utc
+FROM show_share_sessions_v15;
+
+DROP TABLE show_share_sessions_v15;
+DROP TABLE show_shares_v15;
diff --git a/package-lock.json b/package-lock.json
index 125d290..b6d1d29 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sendewerk",
- "version": "0.5.12",
+ "version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
- "version": "0.5.12",
+ "version": "0.6.0",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
diff --git a/package.json b/package.json
index 75cf458..9b518f3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
- "version": "0.5.12",
+ "version": "0.6.0",
"type": "module",
"engines": {
"node": ">=22.17 <25"
diff --git a/src/app.css b/src/app.css
index 5589ed8..33f06ba 100644
--- a/src/app.css
+++ b/src/app.css
@@ -47,6 +47,329 @@
--radius-small: 0.65rem;
}
+.completion-dialog {
+ width: min(38rem, calc(100% - 2rem));
+ max-height: calc(100dvh - 2rem);
+ padding: 0;
+ border: 1px solid color-mix(in srgb, var(--gold) 55%, var(--line));
+ border-radius: var(--radius-large);
+ background: var(--panel-strong);
+ color: var(--text);
+}
+
+.completion-dialog::backdrop {
+ background: rgb(3 8 16 / 68%);
+}
+
+.completion-dialog form {
+ display: grid;
+ gap: 1rem;
+ padding: 1.25rem;
+}
+
+.completion-dialog h2,
+.completion-dialog p {
+ margin: 0;
+}
+
+.completion-dialog fieldset {
+ display: grid;
+ gap: 0.5rem;
+ max-height: 15rem;
+ overflow-y: auto;
+ padding: 0.8rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-medium);
+}
+
+.completion-dialog footer,
+.completion-counts,
+.check-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.completion-dialog footer {
+ justify-content: flex-end;
+}
+
+.completion-counts {
+ flex-wrap: wrap;
+}
+
+.completion-counts span {
+ padding: 0.45rem 0.65rem;
+ border-radius: 999px;
+ background: var(--panel-soft);
+}
+
+.check-row input {
+ width: auto;
+}
+
+.check-row span {
+ display: grid;
+}
+
+.share-management {
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+}
+
+.share-create-form {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
+ gap: 0.8rem;
+ margin-top: 1rem;
+}
+
+.share-create-form label,
+.share-edit-form label {
+ display: grid;
+ gap: 0.35rem;
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+.share-create-form button {
+ align-self: end;
+}
+
+.share-intro {
+ max-width: 58rem;
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.76rem;
+}
+
+.share-show-list {
+ display: grid;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+.share-show-card {
+ padding: 0.85rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-medium);
+ background: var(--panel-soft);
+}
+
+.share-show-card > header,
+.share-row summary {
+ display: grid;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.share-show-card > header {
+ grid-template-columns: minmax(0, 1fr) auto;
+}
+
+.share-show-card > header > div,
+.share-row summary > span:nth-child(2) {
+ display: grid;
+ min-width: 0;
+}
+
+.share-show-card > header a {
+ color: var(--text);
+ font-weight: 760;
+}
+
+.share-show-card small,
+.share-row small {
+ color: var(--text-muted);
+ font-size: 0.68rem;
+}
+
+.share-list {
+ display: grid;
+ gap: 0.4rem;
+ margin-top: 0.75rem;
+}
+
+.share-row {
+ border-top: 1px solid var(--line);
+}
+
+.share-row summary {
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ min-height: 3.25rem;
+ cursor: pointer;
+ list-style: none;
+}
+
+.share-row summary::-webkit-details-marker {
+ display: none;
+}
+
+.share-row-icon {
+ display: grid;
+ width: 2rem;
+ height: 2rem;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--accent-soft);
+ color: var(--accent-text);
+}
+
+.share-row summary > span:nth-child(3) {
+ padding: 0.2rem 0.45rem;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ color: var(--accent-text);
+ font-size: 0.65rem;
+ font-weight: 700;
+}
+
+.share-row summary > span.locked {
+ background: color-mix(in srgb, var(--danger) 12%, var(--panel));
+ color: var(--danger);
+}
+
+.share-edit-area {
+ display: grid;
+ gap: 0.75rem;
+ padding: 0.75rem 0 0.25rem 2.75rem;
+}
+
+.share-edit-area > p {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.74rem;
+}
+
+.share-edit-form {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.65rem;
+}
+
+.share-edit-form input,
+.share-edit-form select {
+ width: 100%;
+}
+
+.share-result {
+ display: grid;
+ gap: 0.25rem;
+}
+
+.share-result a {
+ overflow-wrap: anywhere;
+}
+
+.guest-shell {
+ min-height: 100dvh;
+}
+
+.share-page {
+ width: min(58rem, calc(100% - 2rem));
+ margin: 0 auto;
+ padding: clamp(2rem, 6vw, 5rem) 0;
+}
+
+.share-gate {
+ width: min(30rem, 100%);
+ margin: 8vh auto 0;
+}
+
+.share-heading {
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.share-heading h1 {
+ margin: 0.25rem 0;
+ font-size: clamp(2rem, 6vw, 4rem);
+}
+
+.shared-rundown,
+.historical-rundown {
+ display: grid;
+ gap: 1rem;
+ margin-top: 1.25rem;
+}
+
+.shared-section > header,
+.historical-section > header,
+.shared-usage-heading,
+.historical-usage > header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.shared-section h2,
+.shared-usage h3,
+.historical-section h2,
+.historical-usage h3 {
+ margin: 0;
+}
+
+.shared-usage,
+.historical-usage {
+ margin-top: 0.75rem;
+ padding: 1rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-medium);
+ background: var(--panel-soft);
+}
+
+.shared-usage img {
+ max-width: min(100%, 32rem);
+ max-height: 22rem;
+ margin-top: 0.8rem;
+ border-radius: var(--radius-medium);
+ object-fit: contain;
+}
+
+.shared-copy,
+.historical-usage section,
+.historical-usage details {
+ margin-top: 0.8rem;
+}
+
+.historical-show-page {
+ width: min(72rem, 100%);
+}
+
+.historical-usage p {
+ white-space: pre-wrap;
+}
+
+@media (max-width: 42rem) {
+ .share-create-form,
+ .share-edit-form {
+ grid-template-columns: 1fr;
+ }
+
+ .share-row summary {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ }
+
+ .share-row summary > .app-icon:last-child {
+ display: none;
+ }
+
+ .share-edit-area {
+ padding-left: 0;
+ }
+
+ .completion-dialog footer,
+ .shared-section > header,
+ .historical-section > header,
+ .shared-usage-heading,
+ .historical-usage > header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
+
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #0b1220;
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index 68ff24a..942d2f4 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -1,3 +1,4 @@
+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';
@@ -14,16 +15,32 @@ const publicPaths = new Set([
export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
+ const sharePath = event.url.pathname.startsWith('/freigabe/');
+ const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies);
- if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) {
+ if (
+ !presentationPath &&
+ !sharePath &&
+ !publicPaths.has(event.url.pathname) &&
+ !event.locals.user
+ ) {
redirect(303, hasUsers() ? '/login' : '/setup');
}
- const response = await resolve(event);
+ const sharePageNonce = sharePath && !shareMediaPath ? randomBytes(16).toString('base64') : null;
+ const response = await resolve(
+ event,
+ sharePageNonce
+ ? {
+ transformPageChunk: ({ html }) =>
+ html.replaceAll('
+
+
+
+
+
Gastzugänge
+
Lesefreigaben
+
+ Aktualisiert sich bis zum Abschluss
+
+
+ Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
+ wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
+