From 466f1e40af3e0e914c5e7f5eeb96432495dc4084 Mon Sep 17 00:00:00 2001 From: Thomas Adamek Date: Fri, 25 Sep 2026 07:44:52 +0200 Subject: [PATCH] =?UTF-8?q?Workspaces,=20=C3=84nderungshistorie,=20Papierk?= =?UTF-8?q?orb,=20Nutzungs=C3=BCbersicht,=20Hilfe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6, 5.1–5.4 (Details in AUSBAUSTUFEN.md). Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +- .gitignore | 3 + AGENTEN-GUIDE.md | 15 +- AUSBAUSTUFEN.md | 499 +++++- Dockerfile | 2 +- README.md | 173 +- REPOMAP.md | 198 ++- internal/auth/cookies.go | 6 +- internal/entry/diff.go | 117 ++ internal/entry/diff_test.go | 73 + internal/entry/entry.go | 33 +- internal/export/export.go | 85 +- internal/export/export_test.go | 108 +- internal/hilfe/hilfe.go | 110 ++ internal/hilfe/hilfe_test.go | 110 ++ internal/hilfe/seiten/begriffe.md | 88 + internal/hilfe/seiten/mcp-einrichten.md | 127 ++ internal/hilfe/seiten/mit-agenten-arbeiten.md | 151 ++ internal/hilfe/seiten/plattform-admin.md | 55 + internal/hilfe/seiten/start.md | 45 + internal/hilfe/seiten/weboberflaeche.md | 155 ++ internal/hilfe/seiten/zugaenge.md | 74 + internal/markdown/markdown.go | 148 ++ internal/markdown/markdown_test.go | 90 + internal/mcpserver/mcpserver_test.go | 270 ++- internal/mcpserver/server.go | 15 +- internal/mcpserver/tools.go | 111 +- internal/store/audit.go | 50 +- internal/store/control.go | 224 +++ internal/store/control_test.go | 100 ++ internal/store/entries.go | 389 ++++- internal/store/migrations/001_init.sql | 18 +- .../migrations/004_aenderungshistorie.sql | 43 + internal/store/migrations/005_papierkorb.sql | 41 + .../store/migrations_control/001_init.sql | 33 + internal/store/models.go | 32 + internal/store/nutzung.go | 314 ++++ internal/store/nutzung_test.go | 150 ++ internal/store/papierkorb.go | 209 +++ internal/store/papierkorb_test.go | 296 ++++ internal/store/projects.go | 41 +- internal/store/store.go | 93 +- internal/store/store_test.go | 306 +++- internal/web/context.go | 42 +- internal/web/handlers_admin.go | 124 +- internal/web/handlers_auth.go | 37 +- internal/web/handlers_entries.go | 545 +++++- internal/web/handlers_hilfe.go | 119 ++ internal/web/handlers_nutzung.go | 86 + internal/web/handlers_papierkorb.go | 183 ++ internal/web/handlers_workspaces.go | 243 +++ internal/web/middleware.go | 149 +- internal/web/server.go | 98 +- internal/web/web_test.go | 1495 ++++++++++++++++- internal/workspace/workspace.go | 105 ++ internal/workspace/workspace_test.go | 82 + main.go | 195 ++- main_test.go | 123 ++ scripts/backup.sh | 79 + static/autocomplete.js | 198 +++ static/confirm.js | 8 +- static/graph.js | 380 +++++ static/img/munin_logo.svg | 2 +- static/img/munin_logo_dark.svg | 2 +- static/img/munin_logo_lean.svg | 2 +- static/img/munin_logo_transparent.svg | 2 +- static/preview.js | 86 + static/style.css | 317 +++- templates/audit.html | 4 +- templates/edit.html | 28 +- templates/entries.html | 47 +- templates/entry-diff.html | 48 + templates/entry-versions.html | 67 + templates/entry.html | 6 +- templates/graph.html | 20 + templates/hilfe.html | 25 + templates/keys.html | 8 +- templates/login.html | 8 +- templates/nutzung.html | 165 ++ templates/papierkorb.html | 116 ++ templates/partials.html | 52 +- templates/projects.html | 23 +- templates/workspace-edit.html | 54 + templates/workspaces.html | 80 + 84 files changed, 10054 insertions(+), 605 deletions(-) create mode 100644 internal/entry/diff.go create mode 100644 internal/entry/diff_test.go create mode 100644 internal/hilfe/hilfe.go create mode 100644 internal/hilfe/hilfe_test.go create mode 100644 internal/hilfe/seiten/begriffe.md create mode 100644 internal/hilfe/seiten/mcp-einrichten.md create mode 100644 internal/hilfe/seiten/mit-agenten-arbeiten.md create mode 100644 internal/hilfe/seiten/plattform-admin.md create mode 100644 internal/hilfe/seiten/start.md create mode 100644 internal/hilfe/seiten/weboberflaeche.md create mode 100644 internal/hilfe/seiten/zugaenge.md create mode 100644 internal/markdown/markdown.go create mode 100644 internal/markdown/markdown_test.go create mode 100644 internal/store/control.go create mode 100644 internal/store/control_test.go create mode 100644 internal/store/migrations/004_aenderungshistorie.sql create mode 100644 internal/store/migrations/005_papierkorb.sql create mode 100644 internal/store/migrations_control/001_init.sql create mode 100644 internal/store/nutzung.go create mode 100644 internal/store/nutzung_test.go create mode 100644 internal/store/papierkorb.go create mode 100644 internal/store/papierkorb_test.go create mode 100644 internal/web/handlers_hilfe.go create mode 100644 internal/web/handlers_nutzung.go create mode 100644 internal/web/handlers_papierkorb.go create mode 100644 internal/web/handlers_workspaces.go create mode 100644 internal/workspace/workspace.go create mode 100644 internal/workspace/workspace_test.go create mode 100644 main_test.go create mode 100755 scripts/backup.sh create mode 100644 static/autocomplete.js create mode 100644 static/graph.js create mode 100644 static/preview.js create mode 100644 templates/entry-diff.html create mode 100644 templates/entry-versions.html create mode 100644 templates/graph.html create mode 100644 templates/hilfe.html create mode 100644 templates/nutzung.html create mode 100644 templates/papierkorb.html create mode 100644 templates/workspace-edit.html create mode 100644 templates/workspaces.html diff --git a/.env.example b/.env.example index ea7fd39..4d917b4 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,10 @@ ADMIN_TOKEN= # ── Server ──────────────────────────────────────────────────────────────────── -# Pfad zur SQLite-Datenbank. Der Ordner wird beim Start angelegt. -DB_PATH=data/munin.db +# Datenverzeichnis: enthält die zentrale Kontroll-Datenbank (control.db) und +# den Ordner workspaces/ mit einer SQLite-Datei je Workspace (Abteilung). +# Wird beim Start angelegt. +DATA_DIR=data # Port, auf dem der Server lauscht. PORT=8080 diff --git a/.gitignore b/.gitignore index 96e1878..492d974 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ munin # Datenbank und alles darunter — gehört nicht ins Repository data/ +# Ziel von scripts/backup.sh, falls lokal ohne eigenes BACKUP_DIR aufgerufen +backups/ + # Geheimnisse — NIEMALS einchecken .env diff --git a/AGENTEN-GUIDE.md b/AGENTEN-GUIDE.md index 3b8c4e9..b13093b 100644 --- a/AGENTEN-GUIDE.md +++ b/AGENTEN-GUIDE.md @@ -3,6 +3,10 @@ Wie man KI-Werkzeuge so einrichtet und anspricht, dass sie den Munin tatsächlich benutzen. +> Dieselben Inhalte stehen für Nutzer auch in der Weboberfläche unter +> `/hilfe/mit-agenten-arbeiten` (Quelle: `internal/hilfe/seiten/`). Wer hier +> etwas ändert, sollte dort nachziehen. + --- ## 1. Verbinden @@ -184,8 +188,15 @@ die Einträge auch durchsieht. - **Suche ist wortbasiert.** „wie deployen wir" findet „Release-Prozess" nicht. Im Zweifel `list` für den Überblick. -- **Kein Zurück.** `forget` löscht endgültig, alte Fassungen werden nicht - aufgehoben. Veraltetes lieber mit `remember` richtigstellen. +- **`forget` legt in den Papierkorb.** Der Eintrag ist für Agenten sofort + unsichtbar, ein Mensch kann ihn 30 Tage lang in der Weboberfläche + zurückholen. Agenten können weder wiederherstellen noch den Papierkorb + leeren. Änderungen mit `remember` sind trotzdem der bessere Weg: Die + vorherige Fassung bleibt im Verlauf (Weboberfläche → *Verlauf ansehen*), + und der Eintrag bleibt auffindbar. +- **`remember` auf einen Bezeichner im Papierkorb** holt den Eintrag zurück + und überschreibt ihn; der gelöschte Stand wandert in den Verlauf. Die + Antwort sagt das ausdrücklich. - **Was drinsteht, landet im Kontext aller.** Wer schreiben darf, schreibt indirekt in die Prompts der anderen — Herkunft und Autor stehen deshalb an jedem Eintrag. diff --git a/AUSBAUSTUFEN.md b/AUSBAUSTUFEN.md index f8f85a6..cb42d18 100644 --- a/AUSBAUSTUFEN.md +++ b/AUSBAUSTUFEN.md @@ -54,7 +54,12 @@ bliebe also erhalten. ### 1.2 Eine SQLite-Datei je Projekt -**Stand heute:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen +> **Nachtrag:** Mit 4.4 gibt es inzwischen eine Datei je *Workspace* und eine +> zentrale Kontroll-Datenbank für die Zuordnung Key → Workspace. Die hier +> besprochene Aufteilung je *Projekt* bleibt aufgeschoben. Die Messwerte unten +> stammen aus der Zeit mit nur einer Datei und gelten jetzt je Workspace-Datei. + +**Stand damals:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen zum Lesen, eine zum Schreiben. **Gemessen:** @@ -275,17 +280,24 @@ Bestätigen, das die Frist zurücksetzt. mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich ausgebauter als das, was wir bräuchten — aber die Richtung stimmt. -### 3.6 Änderungshistorie +### 3.6 Änderungshistorie — ✅ umgesetzt -`entries.version` zählt hoch, aber alte Fassungen werden nicht aufgehoben. -Überschreibt ein Agent etwas Richtiges mit etwas Falschem, ist das Richtige weg. +`entries.version` zählt hoch, aber alte Fassungen wurden nicht aufgehoben. +Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg. -**Lösung:** Tabelle `entry_versions` mit dem vorherigen Inhalt bei jedem -Schreibvorgang. Kostet Platz, gibt aber ein Zurück — und macht die Frage „wer hat -das wann geändert" beantwortbar. +**Umgesetzt:** Tabelle `entry_versions` +(`migrations/004_aenderungshistorie.sql`). `entries` bleibt die aktuelle +Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht +beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen +Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird. +Läuft in derselben Transaktion wie die Änderung selbst, sowohl beim normalen +Schreiben (`PutEntry`) als auch bei der Sammelbearbeitung +(`BulkUpdateTags`, siehe 5). Löschen eines Eintrags nimmt seine Historie über +`ON DELETE CASCADE` mit — konsistent mit Tags und Verweisen. -**Alternative ohne Aufwand:** der Markdown-Export in einem Git-Repository. Dann -liegt die Historie dort, mit `git log` und `git diff`. +Die Alternative "Markdown-Export in einem Git-Repository" bleibt weiterhin +möglich, ersetzt das hier aber nicht: Der Export läuft nur, wenn `EXPORT_DIR` +gesetzt ist, während die Historie in der Datenbank immer mitläuft. ### 3.7 Verwandte Einträge @@ -337,6 +349,73 @@ nach einem Neustart weg. Als Netz gegen eine Schleife reicht das. Sobald eine verbindliche Zusage gebraucht wird — oder mehr als ein Prozess läuft —, gehört der Zähler in die Datenbank. +### 3.9 Papierkorb statt endgültigem Löschen — ✅ umgesetzt + +`forget` löscht heute sofort und endgültig, samt aller Fassungen (3.6, +`ON DELETE CASCADE`). Das gilt auch, wenn ein Agent es aufruft. Überschreiben +lässt sich über den Verlauf reparieren, Löschen nicht. Damit ist Löschen der +schlimmste Fall, den ein fehlgeleiteter Agent heute anrichten kann. + +**Lösung:** Löschen setzt nur eine Markierung (`deleted_at`, `deleted_by`). +Markierte Einträge verschwinden aus Suche, Liste, Graph und MCP-Antworten, +bleiben aber samt Verlauf in der Datenbank. Eine Seite *Papierkorb* je Projekt +zeigt sie. Menschen mit Schreibrecht können sie dort wiederherstellen oder +endgültig löschen. Nach einer Frist (Vorschlag 30 Tage) räumt die tägliche +Aufgabe sie endgültig weg, im selben Lauf wie das Protokoll (siehe 4.4, +WP12). + +**Zu bedenken:** +- Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt. + Schreibt jemand mit demselben Bezeichner neu, muss klar sein, was passiert. + Vorschlag: Das stellt den Eintrag wieder her und überschreibt ihn, wobei der + alte Stand wie gewohnt in den Verlauf wandert. +- Jede Abfrage auf `entries` braucht die Bedingung `deleted_at IS NULL`. Das ist + dieselbe Art Risiko, die 4.4 für Workspaces bewusst vermeiden wollte. Hier ist + es vertretbar, weil ein vergessenes `WHERE` nur einen gelöschten Eintrag des + *eigenen* Projekts zeigt. Trotzdem gehört ein Test dazu, der jeden lesenden + Weg (Suche, Liste, `get`, `recall`, Graph, Rückverweise, Export) prüft. + +**Aufwand:** mittel. + +**✅ Umgesetzt 2026-09-24:** + +- **Schema:** `migrations/005_papierkorb.sql`: `deleted_at` (NULL = normal), + `deleted_by` und ein Teilindex nur über den Papierkorb. +- **Store:** `internal/store/papierkorb.go`. `DeleteEntry` gibt es nicht mehr, + an seine Stelle tritt `MoveEntryToTrash`. Der neue Name sorgt dafür, dass + jeder alte Aufrufer beim Kompilieren auffällt. Endgültig löschen können nur + `PurgeFromTrash` und `PurgeTrash`, und beide fassen nur an, was schon im + Papierkorb liegt. +- **Lesewege:** Alle tragen `nichtImPapierkorb`. `ListTags` schließt die + Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex + aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen. + `TestPapierkorbUnsichtbar` prüft jeden Weg einzeln, `TestForgetMovesToTrash` + dasselbe aus Sicht eines Agenten (`get`, `recall`, `list`). +- **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag + zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf + (`Entry.RestoredFromTrash`). Web und `remember` sagen das dem Schreiber, das + Protokoll vermerkt `entry_restore`. Ausnahme: Wer eine gelesene Fassung + mitschickt (`ExpectedVersion`), bekommt einen Konflikt. Das ist der Fall + „Formular offen, währenddessen hat ein Agent gelöscht“, und eine Löschung + soll nicht unbemerkt rückgängig werden. +- **MCP:** `forget` legt in den Papierkorb und sagt das in Beschreibung und + Antwort. Wiederherstellen oder endgültig löschen gibt es über MCP bewusst + nicht. `remember` erkennt „angelegt“ jetzt an der Fassung nach dem Schreiben + statt über eine Nachfrage vorher, die den Papierkorb nicht gesehen hätte. +- **Oberfläche:** `/p/{project}/trash`, nur mit Schreibrecht, auch das Ansehen: + Gelöscht wird oft, was nicht jeder sehen soll. Es gibt Wiederherstellen und + endgültiges Löschen je Zeile oder für eine Auswahl, dazu einen Filter nach + löschendem Zugang mit *Alle … wiederherstellen* für den Fall „Agent hat zu + gründlich aufgeräumt“. `confirm.js` liest die Rückfrage jetzt auch am + gedrückten Knopf (`event.submitter`). +- **Frist:** `store.TrashRetention` = 30 Tage. `papierkorbLeerenAlle` in + `main.go` läuft im selben täglichen Takt wie das Protokoll-Aufräumen und + schreibt je Eintrag eine Protokollzeile mit Herkunft `system`. + Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie + dort mitziehen. +- **Protokoll:** Neue Aktionen `entry_restore` und `entry_purge`. + `entry_delete` bedeutet jetzt „in den Papierkorb“ (Detail sagt es). + --- ## Teil 4 — Betrieb @@ -388,32 +467,378 @@ Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf **Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an Personen statt an Keys. -### 4.4 Mehrere Workspaces +### 4.4 Mehrere Workspaces — ✅ umgesetzt -Die Spalte `workspace_id` steckt von Anfang an in allen Tabellen, es gibt aber nur -einen und keine Oberfläche dafür. Nötig, sobald Abteilungen sich gegenseitig -nicht sehen sollen. +**Ausgangslage:** Die Spalte `workspace_id` stand von Anfang an in allen Tabellen, +es gab aber nur einen Workspace und keine Oberfläche dafür. Nötig, sobald +Abteilungen sich gegenseitig nicht sehen sollen. + +**Entscheidung:** Abteilungen dürfen sich strukturell nie sehen — nicht nur "in +der Praxis meistens nicht". Eine gemeinsame Tabelle mit Filterung über +`WHERE workspace_id = ?` hängt davon ab, dass diese Bedingung in jeder +gegenwärtigen und jeder künftigen Abfrage korrekt steht — bei einem einzelnen +Entwickler ohne Review durch Dritte ein zu hohes Risiko für ein einziges +vergessenes `WHERE`. + +Gleichzeitig muss der Betrieb zentral bleiben: ein Deployment für +anfangs 10, später bis zu 40 Teams (1–5 Projekte je Team) statt eines eigenen +Deployments pro Abteilung, das niemand von Hand pflegen kann. Eigene Backups je +Team sind Pflicht. + +**Konzept:** eine dünne Kontroll-Datenbank (Workspaces, Key-Hash → +Workspace-Routing) plus eine eigene SQLite-Datei je Workspace mit dem heutigen, +unveränderten Schema. Trennung ist damit strukturell (eigene Datei), nicht durch +Disziplin in jeder Abfrage erzwungen. Backups werden dadurch nebenbei +trivial — eine Datei je Team lässt sich einzeln sichern. + +Bei 10–40 Workspaces ist das unproblematisch — deutlich unterhalb der in 1.2 +genannten Schwelle, ab der Verbindungsverwaltung mit Verdrängung nötig würde +(die galt für *Projekte*, nicht für Workspaces, und für eine viel höhere Zahl). + +**Arbeitspakete:** + +1. ✅ Kontroll-Schema: `workspaces` + `key_index` (hash → workspace_id) — + `internal/store/migrations_control/001_init.sql` +2. ✅ `ControlStore`: kleines Store-Objekt für die Kontroll-DB — + `internal/store/control.go` +3. ✅ Key-Routing zweistufig: `ControlStore.ResolveKeyWorkspace` (hash → + workspace_id) davor, danach die heutige `ResolveKeyHash`-Logik unverändert + auf der passenden Workspace-Datei — sowohl beim Web-Login + (`handlers_auth.go`) als auch bei MCP-Aufrufen (`APIAuth` in + `middleware.go`). `CreateAPIKey`/`RevokeAPIKey` pflegen `key_index` im + selben Zug. +4. ✅ Verbindungscache je Workspace: `internal/workspace.Manager` öffnet + Workspace-Dateien bei Bedarf und hält sie offen (`map[workspaceID]*Workspace`, + ein `sync.Mutex`). Bei 10–40 Workspaces keine Verdrängung nötig. +5. ✅ Workspace-Auflösung pro Request ersetzt das feste `WorkspaceID` in + `web.Config`: Das Session-Subject trägt die Workspace-ID direkt + (`key::` bzw. `platform:`, siehe + `sessionFromSubject` in `middleware.go`) — signiert, also fälschungssicher, + und ohne Umweg über die Kontroll-DB bei jedem Request. Für den + Plattform-Admin (ein globales `ADMIN_TOKEN`, siehe Entscheidung oben) kommt + die Workspace-Wahl über `/admin/workspaces` dazu + (`handlers_workspaces.go`) — eine frische Admin-Sitzung ohne gewählten + Workspace kommt an keine Projektseite, `requireWorkspace` in + `middleware.go` erzwingt das. MCP-Werkzeuge lesen ihren Workspace aus dem + Context (`web.WorkspaceFromContext`, siehe `mcpserver/tools.go`) statt aus + einer festen Handler-Instanz — ein `mcpserver.Handler` bedient dadurch + beliebig viele Workspaces gleichzeitig. +6. ✅ Migrationen laufen für die Kontroll-DB und beim Öffnen jeder + Workspace-Datei (`store.Open`/`store.OpenControl`, `main.go` legt + `DATA_DIR/control.db` und `DATA_DIR/workspaces/.db` an) +7. Datenmigration für eine bereits befüllte Bestandsinstallation (einmaliges + Skript) — bislang nicht nötig, es läuft noch keine produktive Instanz +8. ✅ Workspace-Verwaltung, vollständig: `/admin/workspaces` zeigt alle + Workspaces (neueste zuerst, mit Anlegedatum und einer + "Aktuell"-Markierung), legt neue an (springt danach direkt zur + Projektverwaltung, kein Umweg über die Auswahl) und verlinkt je Zeile auf + `/admin/workspaces/{id}/edit` zum Umbenennen. Dort auch die Gefahrenzone: + Löschen entfernt erst die Kontroll-Zeile (samt `key_index` über + `ON DELETE CASCADE`), dann die Workspace-Datei von der Platte + (`workspace.Manager.Delete`) — mit Bestätigung durch Eintippen des Namens, + wie beim Löschen eines Projekts. Betrifft das den Workspace der eigenen + Sitzung, wird die Sitzung auf "kein Workspace gewählt" zurückgesetzt statt + den Admin auszusperren. Dazu ein Workspace-Umschalter in der Kopfleiste + (nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace + auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die + Workspace-Umschalter von Slack/Linear/Notion (`templates/partials.html`, + `.arbeitsbereich*` in `static/style.css`). +9. ✅ `scripts/backup.sh`: sichert Kontroll-DB und jede Workspace-Datei + einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner, + optionale Aufräumfrist über `RETENTION_DAYS`. README verlinkt es + samt Cron-Beispiel. +10. ✅ Tests: `internal/store/control_test.go` (`ControlStore`/Routing), + `internal/workspace/workspace_test.go` (Manager cached, trennt Dateien, + schließt sauber), `internal/web/web_test.go` + (`TestWorkspaceIsolation` — zwei gleichzeitig offene Workspace-Dateien im + selben Prozess kommen sich nicht in die Quere) +11. ✅ README, `.env.example`, `Dockerfile`: `DB_PATH` → `DATA_DIR` + +**Stand:** Alle Arbeitspakete umgesetzt bis auf eines: eine Datenmigration für +eine bereits befüllte Bestandsinstallation, falls die je nötig wird (7, +bislang läuft keine produktive Instanz). Mehrere Workspaces gleichzeitig in +einem Prozess, strukturell getrennt, mit Oberfläche zum Anlegen und Wechseln +und eigenem Backup-Skript — fertig und end-to-end geprüft (Tests plus +manueller Durchlauf gegen den laufenden Server bzw. das Skript). + +**Nachgezogen** (beim Abgleich der Doku mit dem Code am 2026-09-24 gefunden +und am selben Tag behoben): + +12. ✅ Wiederkehrende Aufgaben über alle Workspaces. Vorher bekamen + `pruneAuditRegularly` und `exportRegularly` nur den ältesten Workspace. + Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1) + griff dort nicht. Jetzt gehen beide in jedem Durchgang über + `fuerJedenWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus + der Kontroll-Datenbank, also sind auch nach dem Start angelegte Workspaces + dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber + gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`. +13. ✅ Export je Workspace. Vorher war der Exporter beim Start fest an den Store + des ältesten Workspace gebunden (*Jetzt exportieren* in einem anderen + Workspace schrieb nichts), und die Zielordner hießen nur + `EXPORT_DIR/`, sodass sich Workspaces mit gleichem + Projekt-Bezeichner gegenseitig überschrieben und Dateien wegräumten. Jetzt + kennt `export.Exporter` keine Datenbank mehr, der Aufrufer gibt den Store + je Lauf mit (`ExportWorkspace`). Die Ordner heißen + `EXPORT_DIR///`, dazu `_workspace.md` mit dem Namen. + Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner + erzeugt. `./munin -export` exportiert alle Workspaces und endet mit + Fehlercode, wenn einer scheitert. Tests: `TestExportTrenntWorkspaces`, + `TestExportKnopfImZweitenWorkspace`, `main_test.go`. + +Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr +an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden. ### 4.5 Kennzahlen Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht. +### 4.6 Nutzungsübersicht je Projekt — ✅ umgesetzt + +Anders als 4.5 nicht für ein Überwachungssystem, sondern für Menschen in der +Oberfläche. Die Hilfe (*Mit Agenten arbeiten*) rät, zu prüfen, ob +Aktualisierungen gegenüber Neuanlagen zunehmen. Das lässt sich heute nur +mühsam aus dem Protokoll ablesen. + +**Inhalt einer kleinen Übersichtsseite je Projekt:** +- Schreibvorgänge der letzten 30 Tage, aufgeteilt in Neuanlagen und + Aktualisierungen, getrennt nach Mensch und Agent +- Anteil kuratierter Einträge +- Einträge ohne Schlagwort, Verweise auf nicht vorhandene Einträge +- zuletzt aktive Zugänge + +Für den Plattform-Admin dieselben Zahlen als Spalten in `/admin/projects`. So +fällt auf, welches Team Munin gar nicht benutzt. + +**Voraussetzung — ✅ erledigt (2026-09-24):** Das Protokoll vermerkt bei +`entry_write` im Feld `detail`, ob der Eintrag `angelegt` oder `aktualisiert` +wurde (`store.EntryWriteDetail`, Konstanten `DetailEntryCreated` und +`DetailEntryUpdated` in `internal/store/audit.go`), sowohl bei `remember` als +auch in der Weboberfläche. Maßgeblich ist die Fassung nach dem Schreiben +(1 = angelegt), das stimmt auch bei gleichzeitigem Schreiben. Die +Sammelbearbeitung schreibt weiterhin ihren eigenen Text und zählt als +Aktualisierung. Schreibvorgänge vor diesem Datum lassen sich nicht +unterscheiden. + +**Aufwand:** gering bis mittel, die Daten liegen im Protokoll und in `entries`. + +**✅ Umgesetzt 2026-09-24:** + +- **Seite:** `/p/{project}/usage` (`handlers_nutzung.go`), für jede Rolle. + Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle + sieht. +- **Zahlen:** `store.UsageSummaries` (`internal/store/nutzung.go`) liefert + Schreibvorgänge der letzten 30 Tage (`UsageWindow`) aus dem Protokoll und den + Bestand aus `entries`, für ein Projekt oder alle, in zwei Abfragen. Es gibt + keine eigene Zähltabelle, die auseinanderlaufen könnte. +- **Mensch oder Agent:** Unterschieden wird nach Weg: Web = Mensch, MCP = + Agent. Ein Mensch, der selbst über MCP schreibt, zählt als Agent. +- **Neu oder aktualisiert:** Maßgeblich ist `detail`. `angelegt` ist neu; + alles andere außer leer ist eine Aktualisierung („aktualisiert“, + „wiederhergestellt: …“, „Sammelbearbeitung: …“). Leer heißt „ohne Angabe“: + Das sind Zeilen aus der Zeit vor der Unterscheidung, die nach 30 Tagen von + selbst herausfallen. +- **Der entscheidende Wert:** der Anteil der Aktualisierungen, mit einem + Hinweis zur Deutung. Eine feste Schwelle „gut/schlecht“ gibt es bewusst + nicht, denn ein junges Projekt legt naturgemäß vor allem an. +- **Weitere Abschnitte:** Verweise ins Leere (meistgenannte zuerst, mit + Schreibrecht „anlegen“ über `/new?slug=…`), Einträge ohne Schlagwort, die + fünf zuletzt aktiven Zugänge. +- **Admin:** `/admin/projects` hat die Spalten *Schreibvorgänge* (30 Tage, mit + „keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl + kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt. + +### 4.7 Ungenutzte Zugänge sichtbar machen + +`last_used_at` wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem +Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben, +oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang. + +**Lösung, in zwei Stufen:** +1. Unter *Zugänge* Keys markieren, die seit mehr als 90 Tagen (oder nie) + benutzt wurden, mit einem Knopf zum Zurückziehen. Gering. +2. Optional ein Ablaufdatum beim Anlegen. Abgelaufene Keys gelten wie + zurückgezogene. Braucht eine Migration (`expires_at`) und eine Prüfung in + `ResolveKeyHash`. Mittel. + +### 4.8 Admin je Workspace + +Heute legt nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40 +Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle +Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen. + +**Lösung:** Eine Rolle „Workspace-Admin“, die im eigenen Workspace Projekte +anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch +das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt). + +**Abhängigkeit:** Hängt eng mit 4.3 (Keycloak) zusammen. Mit Personen statt Keys +wäre „Workspace-Admin“ einfach eine Gruppenmitgliedschaft. Ohne 4.3 bräuchte es +eine eigene Key-Art ohne Projektbindung. Beides zusammen entscheiden, nicht +4.8 vorziehen und später umbauen. + +**Aufwand:** hoch. + --- ## Teil 5 — Oberfläche | | Nutzen | Aufwand | |---|---|---| -| Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) | -| Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel | -| Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch | -| Vergleich zweier Fassungen | setzt 3.6 voraus | mittel | -| Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering | +| ✅ Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) | +| ✅ Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel | +| ✅ Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch | +| ✅ Vergleich zweier Fassungen | setzt 3.6 voraus | mittel | +| ✅ Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering | +| ✅ Eingebaute Hilfe unter `/hilfe` | Onboarding ohne Umweg über das Repository | mittel | +| ✅ Fassung wiederherstellen (5.1) | Fehler eines Agenten mit einem Klick zurücknehmen | gering | +| ✅ Echte Server-Adresse auf der Zugangsseite (5.2) | Konfiguration direkt kopierbar | gering | +| ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | gering | +| ✅ Hilfe an Ort und Stelle verlinken (5.4) | Hilfe dort, wo man hängt | gering | Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist der erste. +### 5.1 Fassung wiederherstellen — ✅ umgesetzt + +Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus +dem Vergleich kopieren und von Hand einfügen. **Lösung:** Knopf *Diese Fassung +wiederherstellen* in der Versionsansicht (nur mit Schreibrecht). Technisch ist +das ein gewöhnliches `PutEntry` mit Titel, Inhalt und Schlagworten der alten +Fassung und `ExpectedVersion` der aktuellen. Der aktuelle Stand wandert dabei +selbst in den Verlauf, es geht nichts verloren. Das Protokoll sollte vermerken, +dass es eine Wiederherstellung war und von welcher Fassung. + +**✅ Umgesetzt 2026-09-24:** `POST /p/{project}/e/{slug}/versions/{version}/restore` +(`handleEntryRestore`, `internal/web/handlers_entries.go`), Knopf auf der +Vergleichsseite und je Zeile im Verlauf, nur mit Schreibrecht. Das Formular +schickt die beim Anzeigen aktuelle Fassung als `current_version` mit; fehlt sie, +wird abgelehnt (400) statt ohne Konfliktprüfung zu schreiben, bei einem Konflikt +kommt die Verlaufsseite mit Hinweis (409). Die Markierung *kuratiert* bleibt, +wie sie ist — sie beschreibt den Eintrag, nicht einen Textstand. Im Protokoll +steht `wiederhergestellt: Fassung n` (`store.EntryRestoreDetail`); für die +Zählung in 4.6 zählt das als Aktualisierung (`detail LIKE 'wiederhergestellt%'`). + +### 5.2 Echte Server-Adresse auf der Zugangsseite — ✅ umgesetzt + +`keys.html` zeigt in den Konfigurationsblöcken noch `https://DEIN-SERVER/mcp`. +Die Hilfe setzt die Adresse schon ein (`serverURL` in +`internal/web/handlers_hilfe.go`). Dieselbe Funktion für `keyListData` +verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen. + +**✅ Umgesetzt 2026-09-24:** `keyListData.ServerURL`, gesetzt in +`renderKeyList`; `keys.html` nutzt es in allen drei Konfigurationsblöcken. + +### 5.3 Mehr Markdown in Einträgen — ✅ umgesetzt + +`entry.RenderHTML` nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in +Einträgen deshalb als Text, in der Hilfe (`internal/hilfe`) dagegen als +Tabelle. **Lösung:** `extension.GFM` einschalten (Tabellen, Durchstreichen, +Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die +Sicherheitsannahme in `RenderHTML` ändert sich nicht. Danach den Hinweis auf +der Hilfeseite *Weboberfläche* entfernen und Hilfe und Einträge über denselben +Renderer laufen lassen. + +**✅ Umgesetzt 2026-09-24:** Neues Paket `internal/markdown` — ein +goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und +`hilfe.Render` rufen nur noch dort hinein. Einziger Unterschied: +`RenderMitAnkern` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht — +Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche" +soll nicht mit einer id der Seite selbst zusammenstoßen können. +`markdown_test.go` prüft, dass auch mit GFM weder Roh-HTML noch +`javascript:`-Links durchkommen. Die Hilfe beschreibt jetzt Tabellen, +Checklisten, Durchstreichen und automatische Links. + +### 5.4 Hilfe an Ort und Stelle verlinken — ✅ umgesetzt + +Kleine „?“-Links von einer Seite zur passenden Hilfe, zum Beispiel *Zugänge* → +`/hilfe/zugaenge`, Editor → Markdown-Abschnitt, leere Eintragsliste → +*Agent anbinden*. Dazu Überschrift-Anker in den Hilfeseiten +(`parser.WithAutoHeadingID`), damit Links auf einen Abschnitt zeigen können. + +**✅ Umgesetzt 2026-09-24:** Partial `hilfelink` in `partials.html`, ein +kleines „?" neben Überschriften und dem Inhaltsfeld des Editors: Einträge, +Editor (Markdown-Abschnitt), Verlauf, Vergleich, Zugänge, Protokoll (je nach +Ansicht Projekt- oder Admin-Hilfe), Workspaces, Projekte. Die leere +Eintragsliste verlinkt auf *Agent anbinden* und *Mit Agenten arbeiten*. Die +Hilfe öffnet in einem neuen Tab, damit beim Nachschlagen im Editor kein +ungespeicherter Text verloren geht. + +Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute +einfach fallen („zugnge-verwalten") —, sondern `ankerIDs` in +`internal/markdown` über `store.Slugify` („zugaenge-verwalten"), dieselbe +Regel wie für Eintragsbezeichner. `TestHilfelinksZeigenAufVorhandeneAbschnitte` +liest alle Templates, sucht jedes Hilfe-Ziel und prüft, dass Seite und +Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so +im Test, welcher Link dadurch ins Leere zeigen würde. + +**✅ Sammelbearbeitung umgesetzt:** `entries.html` zeigt bei Schreibrecht ein +Auswahlkästchen je Eintrag plus eine Sammelleiste (Schlagworte hinzufügen/ +entfernen, kommagetrennt). `Store.BulkUpdateTags` (`internal/store/entries.go`) +prüft je ID, dass sie wirklich zum aufrufenden Projekt gehört — eine +untergeschobene ID aus einem fremden Projekt wird stillschweigend übersprungen, +nicht verändert. Nur tatsächlich geänderte Einträge zählen als Treffer und +zählen ihre `version` hoch; der Suchindex wird für sie nachgezogen. Kein +JavaScript nötig — ein einziges Formular um die ganze Liste. + +**✅ Fassungsvergleich umgesetzt:** Baut auf 3.6 auf. `/p/{project}/e/{slug}/versions` +zeigt den Verlauf, `/versions/{version}` vergleicht eine abgelöste Fassung mit +ihrer Nachfolgerin — entweder der nächsten abgelösten oder, wenn es keine mehr +gibt, der aktuell gültigen. Der Zeilenvergleich selbst +(`internal/entry/diff.go`, `DiffLines`) ist eine klassische +LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für +außergewöhnlich lange Einträge (`grobDiff` oberhalb von vier Millionen +Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern. + +**✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte +JavaScript im Projekt (`static/preview.js`, ~90 Zeilen) neben `confirm.js`. +Reiter "Schreiben"/"Vorschau" über dem Inhaltsfeld in `edit.html`; ein Klick +auf "Vorschau" schickt den aktuellen Textarea-Inhalt per `fetch` an +`POST /p/{project}/preview` (`handleEntryPreview`, +`internal/web/handlers_entries.go`) und zeigt das Ergebnis an. Bewusst kein +Markdown-Parsing im Browser: Der Endpunkt ruft denselben `entry.RenderHTML` +auf wie die Detailansicht, Vorschau und späteres Ergebnis können also nicht +auseinanderlaufen — und es ist keine externe JS-Bibliothek nötig, die an der +Content-Security-Policy (`script-src 'self'`) vorbeigemusst hätte. Ohne +JavaScript bleibt einfach der Reiter "Schreiben" aktiv, das Formular +funktioniert unverändert. Kein Live-Rendern beim Tippen, nur auf Klick — +hält die Zahl der Aufrufe klein. + +**✅ Autovervollständigung umgesetzt:** `static/autocomplete.js` erkennt +beim Tippen ein noch nicht geschlossenes `[[`, fragt +`GET /p/{project}/entries/suggest?q=…` (`handleEntrySuggest`, +`internal/web/handlers_entries.go`) und zeigt die Treffer als Liste unter dem +Feld — bewusst nicht an der Schreibmarke ausgerichtet, das bräuchte einen +Spiegel-Trick zur Pixel-Positionierung im Textfeld, den die Liste unter dem +Feld erspart. Tastatur (Pfeiltasten, Enter/Tab, Escape) und Maus bedienbar. +Die Suche selbst dupliziert keine Logik: Mit Suchbegriff läuft dieselbe +BM25-Volltextsuche wie bei `recall` (MCP) und der Projektsuche +(`search.Registry`), ohne Suchbegriff kommen die zuletzt geänderten +Einträge. Ausgewählt wird der Bezeichner (slug), nicht der Titel — das ist, +was `[[…]]` erwartet. + +**✅ Graph-Ansicht umgesetzt:** `/p/{project}/graph` (Seite) und +`/p/{project}/graph/data` (JSON, `handleEntryGraphData`). Datenseitig neu ist +nur `Store.LinkGraph` (`internal/store/entries.go`) — eine Abfrage über den +bereits vorhandenen, indizierten `entry_links`. Verweise auf einen +Bezeichner ohne Eintrag erzeugen einen eigenen, gestrichelten Knoten +(`Exists: false`) statt stillschweigend zu verschwinden — sichtbar macht, +wohin schon verwiesen wird, was aber noch fehlt. + +Begrenzung wie in der Aufwands-/Risikoabschätzung besprochen: +`graphNodeLimit = 150` — bei mehr Einträgen bleiben die am stärksten +verknüpften übrig (nach Grad sortiert), die Antwort trägt `truncated: true` +und die Gesamtzahl, die Seite zeigt einen Hinweis. Das hält sowohl die +Kräftesimulation (`static/graph.js`, O(Knoten²) je Durchlauf) als auch die +SVG-Darstellung in einem Bereich, der im Browser flüssig bleibt. + +Kein externes Diagramm-Werkzeug: Die Kräftesimulation (Abstoßung zwischen +allen Knotenpaaren, Anziehung entlang der Kanten, schwache Zentrierung) ist +selbst geschrieben, läuft einmal beim Laden durch und rendert dann reines +SVG. Texte werden ausschließlich über `textContent` gesetzt, nie +`innerHTML` — Eintragstitel kommen von Menschen und Agenten und sind damit +nicht vertrauenswürdig. Zoom (Mausrad) und Pan (Ziehen) über eine simple +Transform-Gruppe, keine Abhängigkeit auf eine Bibliothek. + --- ## Empfohlene Reihenfolge @@ -428,12 +853,38 @@ sichtbarer Gewinn: Als Nächstes: -1. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung. - Das Zugriffsprotokoll und `/p//audit` liefern jetzt die Daten dafür: - Wie viele `remember`-Aufrufe legen neu an, wie viele aktualisieren? -2. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) — - beide ändern den MCP-Vertrag und sollten bewusst entschieden werden, nicht - nebenbei. +*Stand der Priorisierung: 2026-09-24.* + +**Vor dem zweiten Workspace** + +1. ~~**Offene Reste aus 4.4** (WP12 und WP13): Protokoll aufräumen und Export + für alle Workspaces.~~ — erledigt 2026-09-24, zusammen mit der + Unterscheidung angelegt/aktualisiert im Protokoll (Voraussetzung für 4.6). + +**Klein, sofort spürbar für die Teams** + +2. ~~**Fassung wiederherstellen** (5.1) und **Server-Adresse auf der + Zugangsseite** (5.2). Zusammen ein kleines Paket.~~ — erledigt 2026-09-24. +3. ~~**Mehr Markdown** (5.3) und **Hilfe an Ort und Stelle** (5.4).~~ — + erledigt 2026-09-24. + +**Mittel, wichtig bei vielen Teams** + +4. ~~**Papierkorb** (3.9): schließt den einzigen Schaden, der sich heute nicht + rückgängig machen lässt.~~ — erledigt 2026-09-24. +5. ~~**Nutzungsübersicht** (4.6): zeigt, ob die Teams Munin wirklich benutzen, + und liefert die Zahlen für Punkt 7.~~ — erledigt 2026-09-24. +6. **Ungenutzte Zugänge** (4.7), zunächst nur Stufe 1. + +**Bewusst entscheiden, nicht nebenbei** + +7. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung. + Das Zugriffsprotokoll und 4.6 liefern die Daten dafür: Wie viele + `remember`-Aufrufe legen neu an, wie viele aktualisieren? +8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) — + beide ändern den MCP-Vertrag. +9. **Admin je Workspace** (4.8) zusammen mit **Keycloak** (4.3). Wird dringend, + sobald das Anlegen von Projekten spürbar auf den Plattform-Admin wartet. Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es diff --git a/Dockerfile b/Dockerfile index dea9f8e..d64a7ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ COPY --from=builder /app/munin . EXPOSE 8080 -ENV DB_PATH=/data/munin.db +ENV DATA_DIR=/data ENV PORT=8080 # Im Container steht praktisch immer ein Reverse-Proxy mit HTTPS davor. diff --git a/README.md b/README.md index 1a46ba4..dce1411 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ Unterschied ist ein Feld am Eintrag, kein zweites System. ``` KI-Werkzeug ──MCP──┐ - ├──▶ Projekt ──▶ Einträge (SQLite) -Browser ──HTTP─────┘ + ├──▶ Workspace ──▶ Projekt ──▶ Einträge +Browser ──HTTP─────┘ (eine SQLite-Datei je Workspace) ``` --- @@ -52,9 +52,12 @@ Dann `http://localhost:8080` öffnen und mit dem `ADMIN_TOKEN` anmelden. Der weitere Weg: -1. **Projekt anlegen** unter *Projekte* -2. **Zugang erzeugen** — der Key wird genau einmal angezeigt -3. **MCP-Client einrichten** (die Anleitung erscheint direkt neben dem neuen Key) +1. **Workspace wählen** — nach der Anmeldung landet der Plattform-Admin unter + *Workspaces*. Beim ersten Start legt der Server den Workspace „Intern“ selbst + an; weitere (etwa einer je Abteilung) lassen sich dort anlegen. +2. **Projekt anlegen** unter *Projekte* +3. **Zugang erzeugen** — der Key wird genau einmal angezeigt +4. **MCP-Client einrichten** (die Anleitung erscheint direkt neben dem neuen Key) --- @@ -62,11 +65,13 @@ Der weitere Weg: | Begriff | Bedeutung | |---|---| -| **Projekt** | Die Einheit der Abschottung. Eigene Einträge, eigene Zugänge, eigener MCP-Endpunkt. Projekte sehen sich gegenseitig nicht. | +| **Workspace** | Ein Bereich für eine Abteilung oder ein Team, mit einer eigenen Datenbankdatei. Enthält beliebig viele Projekte. Workspaces sind strukturell getrennt: Keine Abfrage kann über eine Workspace-Grenze hinweg lesen, weil die Daten nicht in derselben Datei liegen. | +| **Projekt** | Die Einheit der Abschottung innerhalb eines Workspace. Eigene Einträge, eigene Zugänge, eigener MCP-Server. Projekte sehen sich gegenseitig nicht. | | **Eintrag** | Ein Stück Wissen: Bezeichner, Titel, Markdown-Inhalt, Schlagworte. Von Menschen oder Agenten geschrieben. | | **Bezeichner** (slug) | Kurzer, dauerhafter Name eines Eintrags (`deployment-prozess`). Der Schlüssel gegen Dubletten: Zweimal derselbe Bezeichner aktualisiert, statt eine zweite Fassung danebenzustellen. | | **API-Key** | Zugang zu genau einem Projekt, mit Namen und Rolle. Wird nur als Prüfsumme gespeichert. | -| **Plattform-Admin** | Der `ADMIN_TOKEN`. Legt Projekte an, kommt an jedes Projekt. | +| **Plattform-Admin** | Der `ADMIN_TOKEN`. Legt Workspaces und Projekte an und kommt an jedes Projekt. Arbeitet immer in genau einem gewählten Workspace und wechselt über den Umschalter in der Kopfleiste. | +| **Fassung** (version) | Zählt bei jeder Änderung eines Eintrags hoch. Die abgelösten Fassungen bleiben erhalten und lassen sich in der Weboberfläche vergleichen. | ### Warum Bezeichner statt Ordner @@ -84,7 +89,7 @@ der Detailansicht wie auch in der MCP-Antwort. ## MCP einrichten Nach dem Erzeugen eines Keys zeigt die Weboberfläche den passenden Abschnitt an. -Der Key bestimmt das Projekt — ein zusätzlicher Parameter ist nicht nötig. Wer mit +Der Key bestimmt Workspace und Projekt — ein zusätzlicher Parameter ist nicht nötig. Wer mit mehreren Projekten arbeitet, trägt mehrere Server mit je eigenem Key ein. Alle Clients sprechen denselben Endpunkt `…/mcp` über Streamable HTTP an. Die @@ -204,7 +209,7 @@ Wie man Agenten so anspricht, dass sie den Speicher auch nutzen: | `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. | | `list` | read | Überblick: zuletzt geändert, alle Schlagworte. | | `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. | -| `forget` | write | Löscht einen Eintrag. | +| `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). | Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das Vorgehen beschreibt — vor allem: *vor* dem Schreiben erst `recall` aufrufen. Das @@ -252,14 +257,54 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh | Weg | Zweck | |---|---| | `/login` | Anmeldung mit API-Key oder Admin-Token | -| `/admin/projects` | Projekte anlegen und auflisten (nur Plattform-Admin) | +| `/admin/workspaces` | Workspaces auflisten, anlegen und wählen (nur Plattform-Admin) | +| `/admin/workspaces/{id}/edit` | Workspace umbenennen oder löschen | +| `/admin/projects` | Projekte des gewählten Workspace anlegen und auflisten, mit Schreibvorgängen der letzten 30 Tage je Projekt; Markdown-Export anstoßen | | `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen | -| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern | +| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace | +| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, Schlagworte für viele Einträge auf einmal ändern | +| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor) | | `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen | +| `/p/{projekt}/e/{bezeichner}/edit` | Eintrag bearbeiten, mit Vorschau | +| `/p/{projekt}/e/{bezeichner}/versions` | Alle Fassungen eines Eintrags | +| `/p/{projekt}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen | +| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph | | `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) | +| `/p/{projekt}/usage` | Nutzung: Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Bestand, Verweise ins Leere, aktive Zugänge | | `/p/{projekt}/audit` | Protokoll des Projekts | +| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) | +| `/hilfe` | Eingebaute Onboarding-Hilfe für alle Rollen: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Die Seite für Plattform-Admins sieht nur der Plattform-Admin. | | `/healthz` | Healthcheck, ohne Anmeldung | +### Funktionen beim Bearbeiten und Lesen + +- **Vorschau:** Der Reiter *Vorschau* über dem Textfeld zeigt das Ergebnis. Der + Server rendert es mit demselben Code wie die Detailansicht, deshalb kann die + Vorschau nicht vom späteren Ergebnis abweichen. +- **Autovervollständigung für Verweise:** Nach `[[` schlägt das Textfeld passende + Einträge vor, mit derselben Suche wie `recall`. Auswählen mit Pfeiltasten und + Enter/Tab oder mit der Maus. Eingefügt wird der Bezeichner, nicht der Titel. +- **Änderungshistorie:** Vor jeder Änderung wird die bisherige Fassung gesichert, + auch bei Änderungen durch Agenten. Über *Verlauf ansehen* in der Detailansicht lässt sich jede abgelöste + Fassung zeilenweise mit ihrer Nachfolgerin vergleichen und mit Schreibrecht per + *Wiederherstellen* zurückholen — der bisherige Stand bleibt dabei selbst im + Verlauf. +- **Papierkorb:** Löschen, ob in der Oberfläche oder per `forget`, legt einen + Eintrag nur in den Papierkorb des Projekts. Dort bleibt er samt Verlauf 30 + Tage und lässt sich von Menschen mit Schreibrecht wiederherstellen oder + sofort endgültig löschen. Danach räumt die tägliche Aufgabe ihn ab. +- **Graph:** Zeigt, welche Einträge aufeinander verweisen. Verweise auf + Bezeichner, zu denen es noch keinen Eintrag gibt, erscheinen als gestrichelte + Knoten. Bei mehr als 150 Einträgen bleiben die am stärksten verknüpften + sichtbar, die Seite weist darauf hin. Zoomen mit dem Mausrad, verschieben durch + Ziehen. +- **Schlagworte für viele Einträge:** Mit Schreibrecht hat die Eintragsliste + Auswahlkästchen. Für die ausgewählten Einträge lassen sich Schlagworte auf + einmal hinzufügen oder entfernen. + +Vorschau, Autovervollständigung und Graph brauchen JavaScript. Alles andere +funktioniert auch ohne. + ### Rollen | Rolle | Darf | @@ -276,7 +321,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh |---|---|---| | `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. | | `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 16 Zeichen. | -| `DB_PATH` | `data/munin.db` | Pfad zur SQLite-Datenbank | +| `DATA_DIR` | `data` | Datenverzeichnis: Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace | | `PORT` | `8080` | Port des Servers | | `COOKIE_SECURE` | automatisch | `Secure`-Flag der Cookies. Ohne Angabe: an, außer bei localhost. | | `ENABLE_HSTS` | `false` | HSTS-Header senden. Nur bei gültigem Zertifikat. | @@ -297,6 +342,7 @@ besser als unsicherer Betrieb. | Schutz | Wo | |---|---| +| Workspaces in getrennten Datenbankdateien | `internal/workspace/workspace.go`, `internal/store/control.go` | | Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` | | API-Keys nur als SHA-256-Prüfsumme gespeichert | `internal/auth/keys.go` | | Zurückgezogener Key beendet laufende Sitzungen sofort | `internal/web/middleware.go` | @@ -312,6 +358,9 @@ besser als unsicherer Betrieb. Sie ist **strukturell**, nicht eine Frage der richtigen Filterbedingung: +- Jeder Workspace liegt in einer **eigenen Datenbankdatei**. Welche Datei zu + einem Key gehört, steht in der zentralen Kontroll-Datenbank. Ein Key führt + deshalb nur in genau eine Datei. - Jedes Projekt hat einen **eigenen MCP-Server**, dessen Werkzeuge fest auf dieses Projekt verdrahtet sind - Jedes Projekt hat einen **eigenen Suchindex** — auch die BM25-Gewichtung wird @@ -347,8 +396,8 @@ trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jed Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist eine Berechtigungsstufe weniger den Umweg wert. - **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor. -- **Ein Workspace.** Die Struktur für mehrere ist im Schema angelegt - (`workspace_id`), aber nicht in der Oberfläche. +- **Ein Admin-Token für alle Workspaces.** Es gibt keinen Admin je Workspace. + Wer den `ADMIN_TOKEN` hat, kommt an jeden Workspace. - **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche Zusage ist es nicht. @@ -389,25 +438,52 @@ gleich alle aussperren. ### Datensicherung -**Datenbank.** Alles steckt in einer Datei. Weil SQLite im WAL-Modus läuft, sollte -sie nicht im laufenden Betrieb kopiert werden: +**Datenbank.** Jeder Workspace (Abteilung) liegt in einer eigenen Datei unter +`DATA_DIR/workspaces/`, dazu kommt die zentrale Kontroll-Datenbank +`DATA_DIR/control.db`. Weil SQLite im WAL-Modus läuft, sollte im laufenden +Betrieb nicht einfach kopiert werden — `scripts/backup.sh` sichert beides +mit `sqlite3 .backup`, in einem zeitgestempelten Ordner je Lauf: ```bash -sqlite3 /pfad/munin.db ".backup '/sicherung/wissen-$(date +%F).db'" +DATA_DIR=/pfad/data BACKUP_DIR=/pfad/sicherungen scripts/backup.sh ``` +Für Cron, täglich um 3 Uhr mit 14 Tagen Aufbewahrung: + +```cron +0 3 * * * DATA_DIR=/srv/munin/data BACKUP_DIR=/srv/backups RETENTION_DAYS=14 \ + /srv/munin/scripts/backup.sh >> /var/log/munin-backup.log 2>&1 +``` + +Da jede Abteilung ihre eigene Datei hat, lässt sich eine einzelne davon auch +gezielt sichern oder wiederherstellen, ohne die übrigen anzufassen: Server +stoppen, die gewünschte `.db`-Datei aus einem Sicherungsordner zurückkopieren, +Server wieder starten. + **Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand -täglich als Markdown-Dateien dorthin — ein Ordner je Projekt, eine Datei je -Eintrag, dazu eine `_index.md` als Übersicht: +aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace, +darin ein Ordner je Projekt, eine Datei je Eintrag und eine `_index.md` als +Übersicht: ``` export/ - plattform/ - _index.md - deployment.md - server-zugang.md + mfk3x9a2-1a2b3c4d/ ← Workspace-ID + _workspace.md ← Name des Workspace + plattform/ + _index.md + deployment.md + server-zugang.md ``` +Die Workspace-Ebene ist nötig, weil Projekt-Bezeichner nur innerhalb eines +Workspace eindeutig sind. Der Ordner heißt nach der ID und nicht nach dem Namen, +damit ein Umbenennen keinen neuen Ordner erzeugt. Welcher Workspace dahinter +steckt, steht in `_workspace.md`. + +> **Umstieg von älteren Fassungen:** Früher lagen die Projektordner direkt unter +> `EXPORT_DIR/`. Diese alten Ordner werden nicht mehr angefasst und können nach +> einem erfolgreichen Export gelöscht werden. + Jede Datei trägt alle Felder im Kopf (Bezeichner, Schlagworte, Herkunft, Autor, Fassung, Zeitstempel), sodass sich der Bestand daraus auch wieder einlesen ließe. @@ -416,10 +492,16 @@ keine Datei an und erzeugt deshalb auch keinen Diff. ```bash # Auf Knopfdruck: "Jetzt exportieren" unter /admin/projects -# Für Cron oder ein Sicherungsskript: +# (exportiert nur den gerade gewählten Workspace) + +# Für Cron oder ein Sicherungsskript, alle Workspaces: ./munin -export ``` +Scheitert der Export eines Workspace, laufen die übrigen trotzdem durch. +`./munin -export` endet dann aber mit einem Fehlercode, damit ein Cronjob die +Sicherung nicht für vollständig hält. + Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien wirken nicht zurück und gehen beim nächsten Lauf verloren. @@ -430,6 +512,15 @@ Vorgänge werden 90 Tage aufbewahrt und danach automatisch gelöscht aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die Voraussetzung dafür, es überhaupt führen zu dürfen. +Jeder Workspace führt sein eigenes Protokoll in seiner eigenen Datei. +`/admin/audit` zeigt das Protokoll des gerade gewählten Workspace, nicht alle +auf einmal. Das tägliche Aufräumen läuft über alle Workspaces, auch über die, +die erst nach dem Serverstart angelegt wurden. + +Bei Schreibvorgängen an Einträgen steht in der Spalte *Details*, ob der Eintrag +**angelegt** oder **aktualisiert** wurde. Daran lässt sich ablesen, ob Agenten +vorhandene Einträge fortschreiben oder immer neue anlegen. + --- ## Entwicklung @@ -447,15 +538,33 @@ gofmt -l . # Formatierung prüfen main.go Konfiguration, Verdrahtung, Serverstart internal/ store/ SQLite: Schema, Migrationen, alle Abfragen - entry/ Fachlogik: Markdown, Verweise, Textausschnitte + (Workspace-Dateien und Kontroll-Datenbank) + workspace/ Öffnet Workspace-Dateien bei Bedarf und hält sie offen + entry/ Fachlogik: Verweise, Textausschnitte, Zeilenvergleich + markdown/ Markdown nach HTML — ein Renderer für Einträge und Hilfe search/ BM25-Volltextindex, einer je Projekt auth/ Sitzungen, CSRF, Cookies, Keys, Rate-Limiting export/ Markdown-Export als Sicherung web/ Routen, Middleware, Handler mcpserver/ MCP-Werkzeuge + hilfe/ Eingebaute Hilfeseiten (Markdown in seiten/) templates/ static/ eingebettet ins Binary +scripts/backup.sh Sicherung aller Datenbankdateien ``` +Eine Übersicht über jede einzelne Datei steht in [REPOMAP.md](REPOMAP.md). + +**Hilfeseiten pflegen:** Die Hilfe unter `/hilfe` sind Markdown-Dateien in +`internal/hilfe/seiten/`, die mit ins Binary gebaut werden. Ändert sich eine +Funktion, gehört die Anpassung der Hilfe in denselben Commit. Eine neue Seite +braucht eine Datei *und* einen Eintrag in der Liste `seiten` in +`internal/hilfe/hilfe.go`. Ein Test prüft, dass beides zusammenpasst und dass +alle Links auf `/hilfe/…` zu existierenden Seiten führen. Seiten mit +`NurAdmin: true` sieht nur der Plattform-Admin; allgemeine Seiten dürfen +nicht auf sie verlinken (auch das prüft ein Test). In den Seiten +werden `{{SERVER}}` und `{{PROJEKT}}` beim Anzeigen durch die Adresse des +Servers und den Bezeichner des eigenen Projekts ersetzt. + Die Richtung der Abhängigkeiten zeigt immer nach innen: `web` und `mcpserver` kennen `store`, aber `store` kennt keinen von beiden. @@ -470,9 +579,17 @@ Datumsfunktionen, keine `INSERT OR REPLACE`), damit ein Umstieg auf PostgreSQL offenbleibt. Die Regeln stehen als Kommentar in `internal/store/migrations/001_init.sql`. -**Schema ändern:** eine neue Datei `003_….sql` in `migrations/` anlegen. Sie wird -beim nächsten Start automatisch angewandt und vermerkt; bestehende Migrationen -werden nie verändert. +Es gibt zwei Schemata mit je eigenem Migrationsordner: + +| Ordner | Gilt für | +|---|---| +| `internal/store/migrations/` | jede Workspace-Datei (Projekte, Einträge, Keys, Protokoll) | +| `internal/store/migrations_control/` | die Kontroll-Datenbank `control.db` (Workspaces, Zuordnung Key → Workspace) | + +**Schema ändern:** im passenden Ordner eine neue Datei mit der nächsten Nummer +anlegen, für Workspace-Dateien derzeit `005_….sql`. Sie wird automatisch +angewandt und vermerkt: für die Kontroll-Datenbank beim Start, für eine +Workspace-Datei beim ersten Öffnen. Bestehende Migrationen werden nie verändert. --- diff --git a/REPOMAP.md b/REPOMAP.md index 60236af..04cd240 100644 --- a/REPOMAP.md +++ b/REPOMAP.md @@ -6,11 +6,15 @@ munin/ ├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die │ Pflichtangaben (SESSION_SECRET, ADMIN_TOKEN inkl. -│ Mindestlänge), öffnet die Datenbank, setzt die +│ Mindestlänge), öffnet Kontroll-Datenbank und +│ Workspace-Verwaltung (legt beim ersten Start den +│ Workspace "Intern" an), setzt die │ Bestandteile zusammen und startet den Server. │ Enthält außerdem die Server-Timeouts (gegen │ Slowloris), die Body-Größenlimits und die -│ tägliche Aufräumaufgabe fürs Protokoll. +│ täglichen Aufgaben (Protokoll aufräumen, +│ Export), die über fuerJedenWorkspace alle +│ Workspaces erreichen. Tests: main_test.go. │ ├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten. │ @@ -37,11 +41,18 @@ munin/ ├── internal/ │ │ │ ├── store/ ── Die einzige Stelle, die SQL kennt ────────────── -│ │ ├── store.go ← Öffnet die Datenbank, setzt die nötigen PRAGMAs -│ │ │ (WAL, busy_timeout, foreign_keys) und wendet die -│ │ │ Migrationen an. Erklärt auch, warum der Treiber -│ │ │ modernc.org/sqlite ist: Er ist reines Go und -│ │ │ erlaubt damit ein statisches Binary. +│ │ ├── store.go ← Öffnet eine Workspace-Datei (Open), setzt die +│ │ │ nötigen PRAGMAs (WAL, busy_timeout, foreign_keys) +│ │ │ und wendet die Migrationen an. Erklärt auch, warum +│ │ │ der Treiber modernc.org/sqlite ist: Er ist reines +│ │ │ Go und erlaubt damit ein statisches Binary. +│ │ │ +│ │ ├── control.go ← Die zentrale Kontroll-Datenbank (OpenControl): +│ │ │ Workspaces anlegen, umbenennen, löschen, dazu die +│ │ │ Zuordnung Key-Prüfsumme → Workspace +│ │ │ (ResolveKeyWorkspace). Das ist der erste Schritt +│ │ │ jeder Anmeldung: Erst danach ist klar, welche +│ │ │ Workspace-Datei den Key kennt. │ │ │ │ │ ├── models.go ← Die Datentypen (Project, APIKey, Entry, …), die │ │ │ Fehlerwerte (ErrNotFound, ErrConflict, @@ -50,8 +61,8 @@ munin/ │ │ │ AUTOINCREMENT — das hält den Umstieg auf eine │ │ │ andere Datenbank offen. │ │ │ -│ │ ├── projects.go ← Workspace und Projekte: anlegen, lesen, ändern, -│ │ │ löschen. Enthält ValidSlug und Slugify sowie +│ │ ├── projects.go ← Projekte: anlegen, lesen, ändern, löschen. +│ │ │ Enthält ValidSlug und Slugify sowie │ │ │ SetProjectWriteLimit für die eigene Obergrenze │ │ │ eines Projekts. │ │ │ @@ -66,7 +77,33 @@ munin/ │ │ │ Mechanismus gegen Dubletten. Enthält das │ │ │ optimistische Sperren (ExpectedVersion) und das │ │ │ Nachladen der Schlagworte in einer Abfrage statt -│ │ │ einer pro Eintrag. +│ │ │ einer pro Eintrag. Außerdem: +│ │ │ · snapshotEntryVersion sichert vor jeder Änderung +│ │ │ die bisherige Fassung, in derselben Transaktion; +│ │ │ EntryVersions/EntryVersionByNumber lesen sie +│ │ │ · BulkUpdateTags ändert Schlagworte vieler +│ │ │ Einträge auf einmal und überspringt IDs aus +│ │ │ fremden Projekten +│ │ │ · LinkGraph liefert Knoten und Kanten für die +│ │ │ Graph-Ansicht +│ │ │ Jede lesende Abfrage trägt nichtImPapierkorb +│ │ │ (deleted_at IS NULL) — außer PutEntry, das +│ │ │ einen Eintrag im Papierkorb bewusst sieht und +│ │ │ ihn beim Überschreiben zurückholt. +│ │ │ +│ │ ├── nutzung.go ← Kennzahlen der Nutzungsübersicht: UsageSummaries +│ │ │ (Schreibvorgänge der letzten 30 Tage aus dem +│ │ │ Protokoll, Bestand aus entries — für ein oder +│ │ │ alle Projekte in zwei Abfragen), dazu +│ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys. +│ │ │ Keine eigene Zähltabelle. +│ │ │ +│ │ ├── papierkorb.go ← Papierkorb: MoveEntryToTrash (statt DELETE), +│ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash +│ │ │ (nur was schon im Papierkorb liegt) und +│ │ │ PurgeTrash für die tägliche Aufgabe. Die Frist +│ │ │ TrashRetention (30 Tage) steht hier, damit +│ │ │ Oberfläche und Aufräumen dieselbe Zahl nutzen. │ │ │ │ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie │ │ │ beim Rendern. Das ersetzt die frühere @@ -75,9 +112,15 @@ munin/ │ │ ├── audit.go ← Protokoll: schreiben, lesen, aufräumen. Die │ │ │ Aufbewahrungsfrist ist nicht nur eine Frage des │ │ │ Plattenplatzes — das Protokoll enthält Adressen -│ │ │ und Zugangsnamen. +│ │ │ und Zugangsnamen. EntryWriteDetail vermerkt bei +│ │ │ Schreibvorgängen "angelegt" oder "aktualisiert". │ │ │ -│ │ └── migrations/ +│ │ ├── migrations_control/ +│ │ │ └── 001_init.sql ← Schema der Kontroll-Datenbank: workspaces und +│ │ │ key_index (Key-Prüfsumme → Workspace). Bewusst +│ │ │ dünn — Inhalte liegen nie hier. +│ │ │ +│ │ └── migrations/ ← Schema jeder Workspace-Datei │ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln │ │ │ für portables SQL, an die sich alle weiteren │ │ │ Migrationen halten sollen. @@ -89,11 +132,26 @@ munin/ │ │ und bei den Rückverweisen kippte durch ein │ │ ORDER BY die Verknüpfungsreihenfolge │ │ (3,1 ms statt 9 µs). -│ │ └── 003_schreibbudget_je_projekt.sql +│ │ ├── 003_schreibbudget_je_projekt.sql │ │ ← Spalte write_limit_hourly an projects. Vorgabe 0 │ │ heißt "keine eigene Angabe" — dann gilt der │ │ Standard aus WRITE_LIMIT_HOURLY. So muss die │ │ Migration keine Daten anfassen. +│ │ ├── 004_aenderungshistorie.sql +│ │ ← Tabelle entry_versions für abgelöste Fassungen. +│ │ Wird mit dem Eintrag gelöscht (ON DELETE CASCADE). +│ │ └── 005_papierkorb.sql +│ │ ← deleted_at/deleted_by an entries und ein +│ │ Teilindex nur über den Papierkorb. Löschen setzt +│ │ nur noch die Markierung; CASCADE greift erst +│ │ beim endgültigen Löschen. +│ │ +│ ├── workspace/ ── Welche Datei gehört zu welcher Abteilung? ────── +│ │ └── workspace.go ← Manager öffnet Workspace-Dateien bei Bedarf und +│ │ hält sie offen, jeweils mit eigenem Suchindex. +│ │ Datenbank und Index gehören zusammen, damit kein +│ │ Index versehentlich an der falschen Datei hängt. +│ │ Delete schließt eine Datei und entfernt sie. │ │ │ ├── auth/ ── Wer ist das? ──────────────────────────────────── │ │ ├── doc.go ← Erklärt die beiden Wege hinein (API-Key im Header, @@ -132,13 +190,32 @@ munin/ │ │ Header ignoriert — er ist frei fälschbar. │ │ │ ├── entry/ ── Wie sieht ein Eintrag aus? ───────────────────── -│ │ └── entry.go ← Markdown nach HTML (goldmark, ohne Roh-HTML), -│ │ Auflösen der [[…]]-Verweise, Textausschnitte für -│ │ Trefferlisten samt sicherer Hervorhebung. +│ │ ├── entry.go ← Markdown nach HTML (über internal/markdown), +│ │ │ Auflösen der [[…]]-Verweise, Textausschnitte für +│ │ │ Trefferlisten samt sicherer Hervorhebung. +│ │ │ +│ │ └── diff.go ← Zeilenvergleich zweier Fassungen (DiffLines) als +│ │ LCS ohne externe Abhängigkeit. Bei sehr langen +│ │ Einträgen weicht er auf einen groben Vergleich +│ │ aus (grobDiff), damit Speicher und Rechenzeit +│ │ begrenzt bleiben. +│ │ +│ ├── markdown/ ── Markdown nach HTML, eine Regel für alles ─────── +│ │ └── markdown.go ← Der gemeinsame goldmark-Renderer für Einträge und +│ │ Hilfe: GFM (Tabellen, Checklisten, +│ │ Durchstreichen, automatische Links), Roh-HTML +│ │ gesperrt. Nur die Hilfe bekommt Überschrift- +│ │ Anker (RenderMitAnkern) — mit gefalteten +│ │ Umlauten über store.Slugify, damit die +│ │ "?"-Links der Oberfläche darauf zeigen können. │ │ │ ├── export/ ── Sicherung als Markdown ───────────────────────── -│ │ └── export.go ← Schreibt jedes Projekt als Ordner voller +│ │ └── export.go ← Schreibt jeden Workspace als Ordner +│ │ (/, mit _workspace.md) und darin +│ │ jedes Projekt als Ordner voller │ │ Markdown-Dateien, mit allen Feldern im Dateikopf. +│ │ Kennt selbst keine Datenbank: Der Aufrufer gibt +│ │ die Workspace-Datei je Lauf mit. │ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne │ │ inhaltliche Änderungen fasst keine Datei an und │ │ erzeugt deshalb auch keinen Diff. Verwaiste @@ -157,6 +234,15 @@ munin/ │ │ die Abschottung strukturell, und die BM25-Werte │ │ werden nicht über Projektgrenzen hinweg verzerrt. │ │ +│ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ─────────────────── +│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel) und +│ │ │ das Rendern mit Überschrift-Ankern. Erklärt, +│ │ │ warum die Hilfe im Binary steckt statt als +│ │ │ Einträge in der Datenbank: Sie muss zur +│ │ │ laufenden Fassung des Programms passen. +│ │ └── seiten/*.md ← Die Inhalte, je Seite eine Datei. {{SERVER}} und +│ │ {{PROJEKT}} werden beim Anzeigen ersetzt. +│ │ │ ├── web/ ── HTTP: Browser und API ────────────────────────── │ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die │ │ │ Ablage im Request-Context. CanAccessProject ist @@ -164,6 +250,10 @@ munin/ │ │ │ │ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"), │ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung. +│ │ │ Die Sitzung trägt die Workspace-ID signiert mit +│ │ │ (sessionFromSubject); requireWorkspace hält einen +│ │ │ Plattform-Admin ohne gewählten Workspace von den +│ │ │ Projektseiten fern. │ │ │ Dazu die allgemeinen Schutzschichten │ │ │ SecurityHeaders und LimitBody sowie APIAuth für │ │ │ den MCP-Endpunkt. @@ -173,9 +263,31 @@ munin/ │ │ │ sonst gäbe es bei einem Fehler eine halbe Seite │ │ │ mit Status 200). │ │ │ -│ │ ├── handlers_auth.go ← Anmelden, Abmelden, Startseite. +│ │ ├── handlers_auth.go ← Anmelden, Abmelden, Startseite. Der +│ │ │ Plattform-Admin ohne gewählten Workspace landet +│ │ │ auf /admin/workspaces. │ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten. -│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht. Die +│ │ │ Dazu Vorschau (preview), Vorschläge für [[…]] +│ │ │ (suggest), Fassungen und Vergleich, Graph-Daten +│ │ │ und Schlagworte für viele Einträge (bulk-tags). +│ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage), +│ │ │ für jede Rolle. Die Admin-Projektliste nutzt +│ │ │ dieselben Zahlen (projectListItems). +│ │ ├── handlers_papierkorb.go +│ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen, +│ │ │ endgültig löschen, Filter nach löschendem Zugang. +│ │ │ Alles nur mit Schreibrecht, und bewusst nur hier +│ │ │ — über MCP gibt es kein Wiederherstellen. +│ │ ├── handlers_hilfe.go ← Zeigt die eingebauten Hilfeseiten unter /hilfe +│ │ │ für jede Sitzung. Setzt vorher Server-Adresse +│ │ │ und Projekt-Bezeichner in die Beispiele ein +│ │ │ (serverURL, mit geprüftem Host-Header). +│ │ ├── handlers_workspaces.go +│ │ │ ← Workspaces auflisten, anlegen, wählen, umbenennen, +│ │ │ löschen (nur Plattform-Admin). Löscht jemand den +│ │ │ Workspace der eigenen Sitzung, wird die Auswahl +│ │ │ zurückgesetzt, statt ihn auszusperren. +│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht, Export. Die │ │ Bearbeitungsseite je Projekt (Name, │ │ Beschreibung, Schreibbudget, Löschen) liegt │ │ hier — bewusst eine Seite je Projekt statt @@ -201,13 +313,33 @@ munin/ ├── static/ │ ├── style.css ← Das gesamte Aussehen, ohne Framework. Farben als │ │ Variablen ganz oben, helle und dunkle Darstellung. -│ └── confirm.js ← Bestätigungsabfrage vor dem Löschen. Liest das -│ Attribut data-confirm, statt onclick="" im HTML zu -│ verwenden — Inline-JavaScript verbietet die -│ Content-Security-Policy. +│ ├── confirm.js ← Bestätigungsabfrage vor dem Löschen. Liest das +│ │ Attribut data-confirm, statt onclick="" im HTML zu +│ │ verwenden — Inline-JavaScript verbietet die +│ │ Content-Security-Policy. +│ ├── preview.js ← Reiter "Schreiben"/"Vorschau" im Editor. Holt das +│ │ HTML vom Server, statt Markdown im Browser zu +│ │ rendern. Ohne JavaScript bleibt das Formular +│ │ unverändert nutzbar. +│ ├── autocomplete.js ← Vorschläge nach "[[" im Textfeld, per Tastatur +│ │ und Maus bedienbar. +│ ├── graph.js ← Selbst geschriebene Kräftesimulation und +│ │ SVG-Darstellung für die Graph-Ansicht. Texte nur +│ │ über textContent, nie innerHTML. +│ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne +│ Hintergrund). Die Anmeldeseite wählt je nach +│ Farbschema die passende. │ -└── data/ ← Datenbank (nicht im Repository, als Volume gemountet) - └── munin.db +├── scripts/ +│ └── backup.sh ← Sichert control.db und jede Workspace-Datei +│ einzeln über sqlite3 .backup in einen +│ zeitgestempelten Ordner; RETENTION_DAYS räumt +│ alte Sicherungen auf. +│ +└── data/ ← Daten (nicht im Repository, als Volume gemountet) + ├── control.db ← Kontroll-Datenbank: Workspaces, Key-Zuordnung + └── workspaces/ + └── .db ← Eine Datei je Workspace ``` ## Tests @@ -220,19 +352,29 @@ werden. | Datei | Schwerpunkt | |---|---| | `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For | -| `internal/store/store_test.go` | Migrationen, Slug-Prüfung, Abschottung, Dublettenvermeidung, optimistisches Sperren, Verweise, Protokoll | +| `internal/store/nutzung_test.go` | Zählung nach Art und Weg samt Zeitfenster, Bestand ohne Papierkorb, Verweise ins Leere, aktive Zugänge | +| `internal/store/papierkorb_test.go` | **Jeder lesende Weg blendet den Papierkorb aus** (Einzelabruf, Liste mit Filtern, Suchindex/Export, Zählen, Schlagworte, Rückverweise, Graph, Sammelbearbeitung); Wiederherstellen, Zurückholen durch Überschreiben, Konflikt, endgültiges Löschen, Frist | +| `internal/store/store_test.go` | Migrationen, Slug-Prüfung, Abschottung, Dublettenvermeidung, optimistisches Sperren, Verweise, Protokoll, Änderungshistorie | +| `internal/store/control_test.go` | Kontroll-Datenbank: Standard-Workspace bleibt stabil, Auflistung, Zuordnung Key → Workspace | +| `internal/workspace/workspace_test.go` | Manager hält Dateien offen, trennt Workspaces, schließt sauber | | `internal/entry/entry_test.go` | HTML-Escaping, Verweisauflösung, Textausschnitte, sichere Hervorhebung | +| `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe | +| `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen | | `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen | | `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe | -| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, Rollen, Header | +| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse | | `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** | +| `internal/hilfe/hilfe_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML | ## Datenfluss: Agent ruft Wissen ab ``` KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall", … } } → web/middleware.go (SecurityHeaders, LimitBody) - → web/middleware.go (APIAuth) Key → Prüfsumme → Lookup → Projekt + Rolle + → web/middleware.go (APIAuth) Key → Prüfsumme + → store/control.go (ResolveKeyWorkspace) → Workspace + → workspace/workspace.go (Get) → dessen Datei und Suchindex + → store/keys.go (ResolveKeyHash) → Projekt + Rolle in den Context; Fehlversuche in die Sperre → mcpserver/server.go (ServeHTTP) Projekt aus dem Context → dessen MCP-Server → mcpserver/tools.go (recall) diff --git a/internal/auth/cookies.go b/internal/auth/cookies.go index 19bec44..a806e6c 100644 --- a/internal/auth/cookies.go +++ b/internal/auth/cookies.go @@ -67,7 +67,7 @@ func (p CookiePolicy) Secure(r *http.Request) bool { if p.explicit != nil { return *p.explicit } - return !isLocalhost(r) + return !IsLocalhost(r) } // Apply setzt Pfad und alle Sicherheits-Flags eines Cookies. @@ -85,13 +85,13 @@ func (p CookiePolicy) Apply(c *http.Cookie, r *http.Request) { c.SameSite = http.SameSiteLaxMode } -// isLocalhost prüft, ob der Request an localhost gerichtet war. +// IsLocalhost prüft, ob der Request an localhost gerichtet war. // // Ausgewertet wird r.Host (die vom Client angefragte Adresse), nicht r.RemoteAddr: // Wer über http://localhost:8080 entwickelt, soll das lockere Verhalten bekommen; // wer über eine echte Domain zugreift, das strenge — auch wenn der Request // zufällig von derselben Maschine kommt. -func isLocalhost(r *http.Request) bool { +func IsLocalhost(r *http.Request) bool { host := r.Host // Den Port abtrennen, falls einer dranhängt ("localhost:8080" → "localhost"). diff --git a/internal/entry/diff.go b/internal/entry/diff.go new file mode 100644 index 0000000..9a4f0f9 --- /dev/null +++ b/internal/entry/diff.go @@ -0,0 +1,117 @@ +package entry + +import "strings" + +// DiffOp sagt, was mit einer Zeile im Vergleich zweier Fassungen passiert ist. +type DiffOp int + +const ( + DiffEqual DiffOp = iota + DiffRemove + DiffAdd +) + +// DiffLine ist eine Zeile im Ergebnis von DiffLines. +type DiffLine struct { + Op DiffOp + Text string +} + +// maxDiffCells begrenzt die Größe der Vergleichstabelle (Zeilen_alt × +// Zeilen_neu). Jenseits davon bräuchte eine genaue Zeilenzuordnung mehr +// Speicher, als für eine einzelne Vergleichsseite sinnvoll ist — dann liefert +// DiffLines nur noch "alles Alte weg, alles Neue dazu" statt einer +// Zeile-für-Zeile-Zuordnung. Bei zwei mittelgroßen Wiki-Einträgen (je ein paar +// hundert Zeilen) wird diese Grenze nicht erreicht. +const maxDiffCells = 4_000_000 + +// DiffLines vergleicht zwei Fassungen eines Eintrags zeilenweise. +// +// Klassischer Ansatz über die längste gemeinsame Teilfolge (LCS): Zeilen, die +// in beiden Fassungen in derselben Reihenfolge vorkommen, gelten als +// unverändert; alles dazwischen als entfernt bzw. hinzugefügt. Ein +// verschobener, aber unveränderter Absatz wird dabei nicht zwingend als +// "gleich" erkannt, wenn sich in seiner Umgebung etwas geändert hat — für die +// Frage "was hat sich bei diesem Schreibvorgang geändert" reicht das. +func DiffLines(alt, neu string) []DiffLine { + altZeilen := splitLines(alt) + neuZeilen := splitLines(neu) + + if len(altZeilen)*len(neuZeilen) > maxDiffCells { + return grobDiff(altZeilen, neuZeilen) + } + return lcsDiff(altZeilen, neuZeilen) +} + +func splitLines(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +// grobDiff dient als Rückfallebene für außergewöhnlich lange Einträge: keine +// Zeilenzuordnung, nur der komplette alte Text als entfernt und der +// komplette neue als hinzugefügt. +func grobDiff(altZeilen, neuZeilen []string) []DiffLine { + lines := make([]DiffLine, 0, len(altZeilen)+len(neuZeilen)) + for _, z := range altZeilen { + lines = append(lines, DiffLine{Op: DiffRemove, Text: z}) + } + for _, z := range neuZeilen { + lines = append(lines, DiffLine{Op: DiffAdd, Text: z}) + } + return lines +} + +// lcsDiff berechnet die Zeilenzuordnung über eine klassische LCS-Tabelle. +// +// dp[i][j] ist die Länge der längsten gemeinsamen Teilfolge von a[i:] und +// b[j:], rückwärts aufgebaut. Der anschließende Vorwärtslauf liest daraus die +// eigentliche Zuordnung ab: Stimmen die Zeilen überein, sind sie Teil der +// gemeinsamen Folge; sonst wird der Seite gefolgt, die die längere +// Teilfolge verspricht. +func lcsDiff(a, b []string) []DiffLine { + n, m := len(a), len(b) + + dp := make([][]int, n+1) + for i := range dp { + dp[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + switch { + case a[i] == b[j]: + dp[i][j] = dp[i+1][j+1] + 1 + case dp[i+1][j] >= dp[i][j+1]: + dp[i][j] = dp[i+1][j] + default: + dp[i][j] = dp[i][j+1] + } + } + } + + var lines []DiffLine + i, j := 0, 0 + for i < n && j < m { + switch { + case a[i] == b[j]: + lines = append(lines, DiffLine{Op: DiffEqual, Text: a[i]}) + i++ + j++ + case dp[i+1][j] >= dp[i][j+1]: + lines = append(lines, DiffLine{Op: DiffRemove, Text: a[i]}) + i++ + default: + lines = append(lines, DiffLine{Op: DiffAdd, Text: b[j]}) + j++ + } + } + for ; i < n; i++ { + lines = append(lines, DiffLine{Op: DiffRemove, Text: a[i]}) + } + for ; j < m; j++ { + lines = append(lines, DiffLine{Op: DiffAdd, Text: b[j]}) + } + return lines +} diff --git a/internal/entry/diff_test.go b/internal/entry/diff_test.go new file mode 100644 index 0000000..d898fec --- /dev/null +++ b/internal/entry/diff_test.go @@ -0,0 +1,73 @@ +package entry + +import "testing" + +func formatDiff(lines []DiffLine) string { + out := "" + for _, l := range lines { + switch l.Op { + case DiffEqual: + out += " " + l.Text + "\n" + case DiffRemove: + out += "- " + l.Text + "\n" + case DiffAdd: + out += "+ " + l.Text + "\n" + } + } + return out +} + +func TestDiffLinesIdentical(t *testing.T) { + lines := DiffLines("a\nb\nc", "a\nb\nc") + for _, l := range lines { + if l.Op != DiffEqual { + t.Errorf("Op = %v bei identischem Text, erwartet DiffEqual", l.Op) + } + } + if len(lines) != 3 { + t.Errorf("%d Zeilen, erwartet 3", len(lines)) + } +} + +func TestDiffLinesChangeInMiddle(t *testing.T) { + alt := "Zeile 1\nZeile 2\nZeile 3" + neu := "Zeile 1\nGeändert\nZeile 3" + + got := formatDiff(DiffLines(alt, neu)) + erwartet := " Zeile 1\n- Zeile 2\n+ Geändert\n Zeile 3\n" + if got != erwartet { + t.Errorf("DiffLines =\n%s\nerwartet:\n%s", got, erwartet) + } +} + +func TestDiffLinesPureAddition(t *testing.T) { + lines := DiffLines("a\nb", "a\nb\nc") + if len(lines) != 3 || lines[2].Op != DiffAdd || lines[2].Text != "c" { + t.Errorf("DiffLines = %+v, erwartet [gleich gleich +c]", lines) + } +} + +func TestDiffLinesEmptyOldContent(t *testing.T) { + lines := DiffLines("", "neu") + if len(lines) != 1 || lines[0].Op != DiffAdd || lines[0].Text != "neu" { + t.Errorf("DiffLines(\"\", \"neu\") = %+v, erwartet [+neu]", lines) + } +} + +func TestDiffLinesFallsBackForHugeInput(t *testing.T) { + // Erzwingt den grobDiff-Pfad ohne wirklich riesige Strings zu bauen: die + // Grenze fällt auf Zeilenanzahl, nicht Textlänge. + altGross := make([]byte, 0, 2200*2) + neuGross := make([]byte, 0, 2200*2) + for i := 0; i < 2200; i++ { + altGross = append(altGross, 'a', '\n') + neuGross = append(neuGross, 'b', '\n') + } + + lines := DiffLines(string(altGross), string(neuGross)) + for _, l := range lines { + if l.Op == DiffEqual { + t.Fatal("grobDiff sollte keine DiffEqual-Zeilen liefern") + } + } +} diff --git a/internal/entry/entry.go b/internal/entry/entry.go index 88cbe23..ff32f6b 100644 --- a/internal/entry/entry.go +++ b/internal/entry/entry.go @@ -8,39 +8,34 @@ package entry import ( - "bytes" "fmt" "html" "strings" - "github.com/yuin/goldmark" - + "munin/internal/markdown" "munin/internal/store" ) // RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML. // -// # Sicherheitshinweis -// -// goldmark lässt in der Standardeinstellung eingebettetes Roh-HTML NICHT durch — -// aus im Markdown wird ein Kommentar, kein Skript. -// Genau darauf verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne -// weiteres Escaping einsetzt. -// -// Diese Einstellung darf deshalb nie auf "unsafe" gestellt werden. Als zweite -// Verteidigungslinie steht die Content-Security-Policy (siehe internal/web). -func RenderHTML(markdown string, linkBase string) (string, error) { +// Das eigentliche Umwandeln übernimmt internal/markdown — derselbe Renderer +// wie für die Hilfe, damit dort beschriebenes Markdown (Tabellen, Checklisten, +// Durchstreichen) im Eintrag genauso aussieht. Dort steht auch der +// Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf +// verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres +// Escaping einsetzt. +func RenderHTML(quelle string, linkBase string) (string, error) { // Verweise vor dem Rendern in Markdown-Links umschreiben. Danach wäre es zu // spät: goldmark hätte die eckigen Klammern dann schon als Text behandelt. - vorbereitet := store.ReplaceLinks(markdown, func(slug string) string { + // + // (Der Parameter heißt "quelle" statt "markdown", weil "markdown" jetzt der + // Name des importierten Pakets ist — sonst wäre das Paket in dieser + // Funktion verdeckt.) + vorbereitet := store.ReplaceLinks(quelle, func(slug string) string { return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug) }) - var puffer bytes.Buffer - if err := goldmark.Convert([]byte(vorbereitet), &puffer); err != nil { - return "", fmt.Errorf("Inhalt konnte nicht dargestellt werden: %w", err) - } - return puffer.String(), nil + return markdown.Render(vorbereitet) } // RenderPlain gibt den Inhalt als reinen Text zurück — Verweise werden auf ihren diff --git a/internal/export/export.go b/internal/export/export.go index 32dd6c1..542b6df 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -38,14 +38,35 @@ import ( ) // Exporter schreibt Projekte in ein Zielverzeichnis. +// +// # Aufbau des Zielverzeichnisses +// +// / +// / +// _workspace.md Name des Workspace, damit man den Ordner zuordnen kann +// / +// _index.md +// .md +// +// Die Ebene je Workspace ist nötig, weil Projekt-Bezeichner nur innerhalb eines +// Workspace eindeutig sind. Ohne sie schrieben zwei Abteilungen mit einem +// Projekt "plattform" in denselben Ordner — und das Aufräumen verwaister +// Dateien löschte bei jedem Lauf die Dateien der jeweils anderen. +// +// Benannt wird der Ordner nach der ID, nicht nach dem Namen: Ein Workspace +// lässt sich umbenennen, und dann entstünde ein neuer Ordner neben dem alten. +// +// Der Exporter kennt keine Datenbank. Welche Workspace-Datei gerade exportiert +// wird, gibt der Aufrufer bei jedem Aufruf mit — so kann ein einzelner +// Exporter alle Workspaces bedienen, statt beim Start an einen gebunden zu +// sein. type Exporter struct { - store *store.Store - root string + root string } // New erstellt einen Exporter, der unter root schreibt. -func New(s *store.Store, root string) *Exporter { - return &Exporter{store: s, root: root} +func New(root string) *Exporter { + return &Exporter{root: root} } // Stats fasst zusammen, was ein Lauf bewirkt hat. @@ -54,14 +75,23 @@ type Stats struct { Removed int // entfernte Dateien gelöschter Einträge } -// ExportProject schreibt alle Einträge eines Projekts nach //. +// ExportProject schreibt alle Einträge eines Projekts nach +// ///. s muss die Workspace-Datei sein, zu +// der das Projekt gehört. // // Der Ablauf ist bewusst schlicht: alles schreiben, danach aufräumen, was nicht // mehr dazugehört. Ein Abgleich wäre sparsamer, aber deutlich fehleranfälliger — // und der Export läuft selten genug, dass das nicht ins Gewicht fällt. -func (e *Exporter) ExportProject(ctx context.Context, project store.Project) (Stats, error) { +func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project store.Project) (Stats, error) { var stats Stats + // Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim + // Projekt-Bezeichner direkt darunter. IDs aus store.NewID bestehen nur aus + // Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese Prüfung. + if !store.ValidSlug(project.WorkspaceID) { + return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID) + } + // Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe // store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier // noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts @@ -71,12 +101,12 @@ func (e *Exporter) ExportProject(ctx context.Context, project store.Project) (St return stats, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug) } - ziel := filepath.Join(e.root, project.Slug) + ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug) if err := os.MkdirAll(ziel, 0750); err != nil { return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) } - eintraege, err := e.store.AllEntries(ctx, project.ID) + eintraege, err := s.AllEntries(ctx, project.ID) if err != nil { return stats, err } @@ -114,17 +144,33 @@ func (e *Exporter) ExportProject(ctx context.Context, project store.Project) (St return stats, nil } -// ExportAll exportiert alle Projekte eines Workspace. -func (e *Exporter) ExportAll(ctx context.Context, workspaceID string) (Stats, error) { +// ExportWorkspace exportiert alle Projekte eines Workspace. s muss die +// Workspace-Datei von ws sein. +func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store.Workspace) (Stats, error) { var gesamt Stats - projekte, err := e.store.ListProjects(ctx, workspaceID) + if !store.ValidSlug(ws.ID) { + return gesamt, fmt.Errorf("ungültige Workspace-ID %q", ws.ID) + } + ordner := filepath.Join(e.root, ws.ID) + if err := os.MkdirAll(ordner, 0750); err != nil { + return gesamt, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) + } + + // Ohne diese Datei wäre der Ordner nur eine ID — niemand wüsste beim + // Wiederherstellen, welche Abteilung darin steckt. + if err := schreibeDatei(filepath.Join(ordner, workspaceDateiname), rendereWorkspace(ws)); err != nil { + return gesamt, err + } + gesamt.Written++ + + projekte, err := s.ListProjects(ctx, ws.ID) if err != nil { return gesamt, err } for _, p := range projekte { - stats, err := e.ExportProject(ctx, p) + stats, err := e.ExportProject(ctx, s, p) if err != nil { return gesamt, fmt.Errorf("Projekt %q: %w", p.Slug, err) } @@ -138,6 +184,21 @@ func (e *Exporter) ExportAll(ctx context.Context, workspaceID string) (Stats, er // indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird. const indexDateiname = "_index.md" +// workspaceDateiname nennt den Workspace, zu dem ein ID-Ordner gehört. +const workspaceDateiname = "_workspace.md" + +// rendereWorkspace baut den Inhalt von _workspace.md. Wie beim Index ohne +// Zeitstempel des Laufs, damit ein unveränderter Workspace keinen Diff erzeugt. +func rendereWorkspace(ws store.Workspace) string { + var b strings.Builder + b.WriteString("---\n") + schreibeFeld(&b, "id", ws.ID) + schreibeFeld(&b, "name", ws.Name) + b.WriteString("---\n\n") + b.WriteString("# Workspace " + ws.Name + "\n") + return b.String() +} + // schreibeDatei legt eine Datei an — nur, wenn sich der Inhalt geändert hat. // // # Warum der Vergleich? diff --git a/internal/export/export_test.go b/internal/export/export_test.go index 86cbbf5..8cc4843 100644 --- a/internal/export/export_test.go +++ b/internal/export/export_test.go @@ -21,17 +21,20 @@ func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) { t.Cleanup(func() { db.Close() }) ctx := context.Background() - ws, err := db.EnsureWorkspace(ctx, "Test") - if err != nil { - t.Fatal(err) - } - projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform-Team", "Wissen des Teams") + // Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr — die lebt in + // der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für + // den Test reicht eine beliebige ID. + workspaceID := store.NewID() + projekt, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams") if err != nil { t.Fatal(err) } - ziel := t.TempDir() - return New(db, ziel), db, projekt, ziel + // Der Exporter schreibt unter //. Zurückgegeben wird + // gleich der Workspace-Ordner, damit die Tests Pfade wie + // ziel/plattform/eintrag.md prüfen können, ohne die ID zu kennen. + root := t.TempDir() + return New(root), db, projekt, filepath.Join(root, workspaceID) } // lies gibt den Inhalt einer exportierten Datei zurück. @@ -57,7 +60,7 @@ func TestExportWritesEntries(t *testing.T) { t.Fatal(err) } - stats, err := e.ExportProject(ctx, projekt) + stats, err := e.ExportProject(ctx, db, projekt) if err != nil { t.Fatalf("Export: %v", err) } @@ -107,7 +110,7 @@ func TestExportEscapesYAML(t *testing.T) { t.Fatal(err) } - if _, err := e.ExportProject(ctx, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } @@ -138,18 +141,19 @@ func TestExportRemovesOrphans(t *testing.T) { db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "geht", Title: "Geht", Content: "x", Source: store.SourceHuman}) - if _, err := e.ExportProject(ctx, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(ziel, "plattform", "geht.md")); err != nil { t.Fatal("die Datei wurde gar nicht erst angelegt") } - if err := db.DeleteEntry(ctx, projekt.ID, "geht"); err != nil { + // Ein Eintrag im Papierkorb gehört nicht mehr in den Export. + if err := db.MoveEntryToTrash(ctx, projekt.ID, "geht", "test"); err != nil { t.Fatal(err) } - stats, err := e.ExportProject(ctx, projekt) + stats, err := e.ExportProject(ctx, db, projekt) if err != nil { t.Fatal(err) } @@ -167,7 +171,7 @@ func TestExportRemovesOrphans(t *testing.T) { // TestExportLeavesForeignFilesAlone prüft, dass eigene Dateien im Zielordner // nicht angetastet werden. func TestExportLeavesForeignFilesAlone(t *testing.T) { - e, _, projekt, ziel := aufbau(t) + e, db, projekt, ziel := aufbau(t) ctx := context.Background() ordner := filepath.Join(ziel, "plattform") @@ -178,7 +182,7 @@ func TestExportLeavesForeignFilesAlone(t *testing.T) { os.WriteFile(filepath.Join(ordner, "notizen.txt"), []byte("privat"), 0640) os.MkdirAll(filepath.Join(ordner, "anhaenge"), 0750) - if _, err := e.ExportProject(ctx, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } @@ -199,7 +203,7 @@ func TestExportIsStable(t *testing.T) { db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag", Content: "Inhalt", Source: store.SourceHuman}) - if _, err := e.ExportProject(ctx, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } @@ -209,7 +213,7 @@ func TestExportIsStable(t *testing.T) { t.Fatal(err) } - if _, err := e.ExportProject(ctx, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } @@ -234,7 +238,7 @@ func TestExportProducesNoDiffWhenUnchanged(t *testing.T) { db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag", Content: "Inhalt", Tags: []string{"ops"}, Source: store.SourceHuman}) - if _, err := e.ExportProject(ctx, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } @@ -246,7 +250,7 @@ func TestExportProducesNoDiffWhenUnchanged(t *testing.T) { vorher[d.Name()] = lies(t, ordner, d.Name()) } - if _, err := e.ExportProject(ctx, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } @@ -268,7 +272,7 @@ func TestExportIndex(t *testing.T) { db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "Beta", Content: "x", Source: store.SourceAgent}) - if _, err := e.ExportProject(ctx, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } @@ -299,8 +303,9 @@ func TestExportAllProjects(t *testing.T) { e, db, projektA, ziel := aufbau(t) ctx := context.Background() - ws, _ := db.EnsureWorkspace(ctx, "Test") - projektB, err := db.CreateProject(ctx, ws.ID, "anderes", "Anderes Team", "") + // Beide Projekte liegen in derselben Workspace-Datei — dieselbe + // workspace_id wie projektA reicht, eine eigene Workspace ist nicht nötig. + projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "anderes", "Anderes Team", "") if err != nil { t.Fatal(err) } @@ -310,7 +315,8 @@ func TestExportAllProjects(t *testing.T) { db.PutEntry(ctx, store.EntryWrite{ProjectID: projektB.ID, Slug: "b-wissen", Title: "B", Content: "gehört zu B", Source: store.SourceHuman}) - if _, err := e.ExportAll(ctx, ws.ID); err != nil { + ws := store.Workspace{ID: projektA.WorkspaceID, Name: "Intern"} + if _, err := e.ExportWorkspace(ctx, db, ws); err != nil { t.Fatal(err) } @@ -328,9 +334,9 @@ func TestExportAllProjects(t *testing.T) { } func TestExportEmptyProject(t *testing.T) { - e, _, projekt, ziel := aufbau(t) + e, db, projekt, ziel := aufbau(t) - stats, err := e.ExportProject(context.Background(), projekt) + stats, err := e.ExportProject(context.Background(), db, projekt) if err != nil { t.Fatalf("ein leeres Projekt führte zu einem Fehler: %v", err) } @@ -342,3 +348,57 @@ func TestExportEmptyProject(t *testing.T) { t.Error("auch bei einem leeren Projekt sollte eine Übersicht entstehen") } } + +// TestExportTrenntWorkspaces: Zwei Workspaces mit einem Projekt gleichen +// Bezeichners dürfen sich im Export weder überschreiben noch gegenseitig +// Dateien wegräumen. Vor der Ebene je Workspace passierte genau das. +func TestExportTrenntWorkspaces(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + e := New(root) + + // Zwei getrennte Workspace-Dateien, wie in echt. + type workspace struct { + ws store.Workspace + db *store.Store + } + var beide []workspace + for _, name := range []string{"Vertrieb", "Technik"} { + db, err := store.Open(filepath.Join(t.TempDir(), name+".db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + ws := store.Workspace{ID: store.NewID(), Name: name} + projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform", "") + if err != nil { + t.Fatal(err) + } + if _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "nur-" + strings.ToLower(name), + Title: name, Content: "gehört zu " + name, Source: store.SourceHuman}); err != nil { + t.Fatal(err) + } + beide = append(beide, workspace{ws, db}) + } + + // Zweimal hintereinander: Beim zweiten Lauf würde das Aufräumen die + // Dateien des anderen Workspace löschen, wenn sie im selben Ordner lägen. + for lauf := 0; lauf < 2; lauf++ { + for _, w := range beide { + if _, err := e.ExportWorkspace(ctx, w.db, w.ws); err != nil { + t.Fatal(err) + } + } + } + + for _, w := range beide { + datei := "nur-" + strings.ToLower(w.ws.Name) + ".md" + if inhalt := lies(t, root, w.ws.ID, "plattform", datei); !strings.Contains(inhalt, "gehört zu "+w.ws.Name) { + t.Errorf("Eintrag von %s fehlt oder ist falsch", w.ws.Name) + } + if inhalt := lies(t, root, w.ws.ID, workspaceDateiname); !strings.Contains(inhalt, w.ws.Name) { + t.Errorf("_workspace.md nennt %s nicht", w.ws.Name) + } + } +} diff --git a/internal/hilfe/hilfe.go b/internal/hilfe/hilfe.go new file mode 100644 index 0000000..97190b2 --- /dev/null +++ b/internal/hilfe/hilfe.go @@ -0,0 +1,110 @@ +// Package hilfe enthält die eingebaute Onboarding-Dokumentation der +// Weboberfläche: wie man Munin einrichtet, benutzt und was die Begriffe +// bedeuten. +// +// # Warum eingebettet und nicht als Einträge in einem Projekt? +// +// Die Hilfe beschreibt das Programm selbst. Sie muss also zu genau der Fassung +// passen, die gerade läuft — ändert sich eine Funktion, ändert sich im selben +// Commit auch ihre Beschreibung. Als Einträge in der Datenbank würde sie +// unbemerkt veralten, und in jedem neuen Workspace müsste sie erst jemand +// anlegen. +// +// Die Seiten sind Markdown-Dateien im Ordner seiten/. //go:embed packt sie +// beim Kompilieren ins Binary, genau wie templates/ und static/. +package hilfe + +import ( + "embed" + "fmt" + + "munin/internal/markdown" +) + +//go:embed seiten/*.md +var seitenFS embed.FS + +// Seite beschreibt eine Hilfeseite. +type Seite struct { + Slug string // Teil der Adresse: /hilfe/ + Titel string // Überschrift und Name in der Navigation + Kurz string // ein Satz für die Übersicht + + // NurAdmin blendet die Seite für alle aus, die nicht als Plattform-Admin + // angemeldet sind: Sie steht dann weder in der Navigation, noch lässt sie + // sich direkt aufrufen. Geheim ist darin nichts — aber für ein Team mit + // Projekt-Key beschreibt sie nur Knöpfe, die es gar nicht sieht. + NurAdmin bool +} + +// seiten legt Reihenfolge und Inhalt der Navigation fest. Die Datei zu einer +// Seite heißt immer seiten/.md — so gibt es nur eine Stelle, an der ein +// Name steht, und der Test in hilfe_test.go prüft, dass beides zusammenpasst. +var seiten = []Seite{ + {Slug: "start", Titel: "Willkommen", Kurz: "Was Munin ist und wo man anfängt."}, + {Slug: "begriffe", Titel: "Begriffe", Kurz: "Workspace, Projekt, Eintrag, Bezeichner, Rollen — was sie bedeuten."}, + {Slug: "mcp-einrichten", Titel: "Agent anbinden", Kurz: "Claude Code, Cline oder OpenCode mit Munin verbinden."}, + {Slug: "mit-agenten-arbeiten", Titel: "Mit Agenten arbeiten", Kurz: "Wie man Agenten anspricht, damit sie das Gedächtnis wirklich nutzen."}, + {Slug: "weboberflaeche", Titel: "Weboberfläche", Kurz: "Einträge im Browser suchen, schreiben, vergleichen und verknüpfen."}, + {Slug: "zugaenge", Titel: "Zugänge und Sicherheit", Kurz: "Keys, Rollen, Protokoll und was man beim Teilen von Wissen bedenken sollte."}, + {Slug: "plattform-admin", Titel: "Für Plattform-Admins", Kurz: "Workspaces und Projekte anlegen, Schreibbudget, Export.", NurAdmin: true}, +} + +// Alle gibt alle Seiten in der Reihenfolge der Navigation zurück, auch die +// nur für Admins. Für die Anzeige ist Sichtbar die richtige Funktion. +// +// Zurückgegeben wird eine Kopie: Wer die Liste verändert, soll damit nicht aus +// Versehen die Navigation aller anderen Anfragen verändern. +func Alle() []Seite { + kopie := make([]Seite, len(seiten)) + copy(kopie, seiten) + return kopie +} + +// Sichtbar gibt die Seiten zurück, die jemand sehen darf: alle für einen +// Plattform-Admin, sonst alle außer den NurAdmin-Seiten. +func Sichtbar(istAdmin bool) []Seite { + var ergebnis []Seite + for _, s := range seiten { + if s.NurAdmin && !istAdmin { + continue + } + ergebnis = append(ergebnis, s) + } + return ergebnis +} + +// Finde sucht eine Seite über ihren Slug. +func Finde(slug string) (Seite, bool) { + for _, s := range seiten { + if s.Slug == slug { + return s, true + } + } + return Seite{}, false +} + +// Markdown liefert den Rohtext einer Seite. +func (s Seite) Markdown() (string, error) { + inhalt, err := seitenFS.ReadFile("seiten/" + s.Slug + ".md") + if err != nil { + return "", fmt.Errorf("Hilfeseite %q fehlt: %w", s.Slug, err) + } + return string(inhalt), nil +} + +// Render wandelt eine Hilfeseite in HTML. +// +// Es ist derselbe Renderer wie für Einträge (internal/markdown), damit die +// Hilfe nichts zeigt, was im eigenen Eintrag dann anders aussieht. Einziger +// Unterschied: Überschriften bekommen hier eine id, damit die Oberfläche mit +// "?"-Links direkt auf einen Abschnitt zeigen kann, etwa +// /hilfe/weboberflaeche#markdown-im-inhalt. +// +// Roh-HTML bleibt auch hier gesperrt. Die Seiten stammen zwar aus dem +// Repository und nicht von Nutzern, aber die Platzhalter (siehe +// web/handlers_hilfe.go) setzen Werte aus der Anfrage ein — und dann soll +// daraus auf keinen Fall HTML werden können. +func Render(quelle string) (string, error) { + return markdown.RenderMitAnkern(quelle) +} diff --git a/internal/hilfe/hilfe_test.go b/internal/hilfe/hilfe_test.go new file mode 100644 index 0000000..97d60aa --- /dev/null +++ b/internal/hilfe/hilfe_test.go @@ -0,0 +1,110 @@ +package hilfe + +import ( + "io/fs" + "regexp" + "strings" + "testing" +) + +// TestJedeSeiteHatEineDatei stellt sicher, dass die Liste in hilfe.go und die +// Dateien in seiten/ zusammenpassen — in beide Richtungen. Eine Seite ohne +// Datei gäbe einen Fehler beim Aufruf, eine Datei ohne Listeneintrag wäre +// unerreichbar und würde unbemerkt veralten. +func TestJedeSeiteHatEineDatei(t *testing.T) { + gelistet := map[string]bool{} + for _, s := range Alle() { + gelistet[s.Slug] = true + if _, err := s.Markdown(); err != nil { + t.Errorf("Seite %q: %v", s.Slug, err) + } + } + + dateien, err := fs.Glob(seitenFS, "seiten/*.md") + if err != nil { + t.Fatal(err) + } + for _, d := range dateien { + slug := strings.TrimSuffix(strings.TrimPrefix(d, "seiten/"), ".md") + if !gelistet[slug] { + t.Errorf("Datei %s steht nicht in der Seitenliste", d) + } + } +} + +// TestInterneLinksFuehrenZuSeiten prüft jeden Link der Form (/hilfe/…) in +// allen Seiten. Wird eine Seite umbenannt, fällt ein vergessener Verweis hier +// auf statt erst als 404 beim Lesen. +func TestInterneLinksFuehrenZuSeiten(t *testing.T) { + link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`) + + for _, s := range Alle() { + md, err := s.Markdown() + if err != nil { + t.Fatal(err) + } + for _, treffer := range link.FindAllStringSubmatch(md, -1) { + if _, ok := Finde(treffer[1]); !ok { + t.Errorf("Seite %q verlinkt auf unbekannte Hilfeseite %q", s.Slug, treffer[1]) + } + } + } +} + +func TestFindeUnbekannteSeite(t *testing.T) { + if _, ok := Finde("gibt-es-nicht"); ok { + t.Error("Finde meldet eine Seite, die es nicht gibt") + } +} + +func TestRenderTabellenUndKeinRohHTML(t *testing.T) { + html, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n\n") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(html, "") { + t.Error("Tabellen werden nicht als Tabelle dargestellt") + } + if strings.Contains(html, " im Markdown wird ein Kommentar, kein Skript. +// Außerdem entfernt es gefährliche Link-Ziele wie "javascript:". Die +// Weboberfläche verlässt sich darauf, wenn sie das Ergebnis ohne weiteres +// Escaping einsetzt. +// +// Deshalb darf hier nie html.WithUnsafe() dazukommen. Als zweite +// Verteidigungslinie steht die Content-Security-Policy (siehe internal/web). +package markdown + +import ( + "bytes" + "fmt" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + + "munin/internal/store" +) + +// neuerRenderer baut einen goldmark-Renderer mit den Erweiterungen, die überall +// gelten. Zusätzliche Parser-Optionen (etwa Überschrift-Anker) kommen als +// Parameter dazu. +// +// Das "..." vor dem Typ heißt in Go "beliebig viele Werte dieses Typs" +// (variadische Funktion) — man kann neuerRenderer() also auch ganz ohne +// Zusatzoptionen aufrufen. +func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown { + return goldmark.New( + // GFM ("GitHub Flavored Markdown") bündelt vier Erweiterungen: + // Tabellen, ~~Durchstreichen~~, Checklisten (- [ ] / - [x]) und + // automatische Links für nackte Adressen wie https://example.org. + // Das ist, was die meisten aus GitHub oder GitLab gewohnt sind — und was + // Agenten ohnehin schreiben. + goldmark.WithExtensions(extension.GFM), + goldmark.WithParserOptions(zusatz...), + ) +} + +// Die beiden Renderer werden einmal beim Programmstart gebaut und dann für +// jede Anfrage wiederverwendet. goldmark erlaubt das ausdrücklich: Ein +// Renderer hat keinen Zustand, der sich zwischen zwei Aufrufen ändert, und ist +// damit auch bei gleichzeitigen Anfragen sicher. +var ( + // ohneAnker ist für Einträge. Überschriften bekommen hier bewusst KEINE + // id-Attribute: Eintragstexte kommen von Menschen und Agenten, und eine + // Überschrift wie "## suche" ergäbe id="suche" — die könnte mit einer id + // der Seite selbst zusammenstoßen, auf die sich das JavaScript verlässt. + ohneAnker = neuerRenderer() + + // mitAnker ist für die Hilfe. Jede Überschrift bekommt eine id aus ihrem + // Text ("## Markdown im Inhalt" → id="markdown-im-inhalt"), damit Links + // aus der Oberfläche direkt auf einen Abschnitt zeigen können + // (AUSBAUSTUFEN.md 5.4). Die Hilfeseiten liegen im Repository, ihre + // Überschriften sind also bekannt und unbedenklich. + mitAnker = neuerRenderer(parser.WithAutoHeadingID()) +) + +// Render wandelt Markdown in HTML, ohne Überschrift-Anker. Für Einträge. +func Render(quelle string) (string, error) { + return umwandeln(ohneAnker, quelle) +} + +// RenderMitAnkern wandelt Markdown in HTML und gibt jeder Überschrift eine id. +// Für die Hilfe. +func RenderMitAnkern(quelle string) (string, error) { + // Jede Seite bekommt ihren eigenen Anker-Generator. Er merkt sich, welche + // ids schon vergeben sind — und das darf nicht in die nächste Seite + // hinüberwirken (und bei gleichzeitigen Anfragen schon gar nicht). + kontext := parser.NewContext(parser.WithIDs(&ankerIDs{vergeben: map[string]bool{}})) + return umwandeln(mitAnker, quelle, parser.WithContext(kontext)) +} + +// umwandeln ist der gemeinsame Teil beider Funktionen. +func umwandeln(renderer goldmark.Markdown, quelle string, optionen ...parser.ParseOption) (string, error) { + // bytes.Buffer ist ein wachsender Speicher, in den goldmark das Ergebnis + // schreibt. Das & übergibt einen Zeiger darauf, damit goldmark genau diesen + // Puffer füllt und nicht eine Kopie davon. + var puffer bytes.Buffer + if err := renderer.Convert([]byte(quelle), &puffer, optionen...); err != nil { + return "", fmt.Errorf("Markdown konnte nicht dargestellt werden: %w", err) + } + return puffer.String(), nil +} + +// ankerIDs erzeugt die id-Attribute der Überschriften in der Hilfe. +// +// # Warum nicht goldmarks eingebaute Anker? +// +// goldmark lässt alle Zeichen außerhalb von a–z und 0–9 einfach weg. Aus +// "## Zugänge verwalten" würde so id="zugnge-verwalten" — unschön, und wer +// einen Link darauf schreibt, rät garantiert falsch. store.Slugify faltet +// Umlaute dagegen so, wie man es erwartet (ä → ae, ß → ss), und ist dieselbe +// Regel, nach der auch Eintragsbezeichner aus Titeln entstehen. Eine Regel +// für beides ist leichter zu merken als zwei. +// +// # Wie goldmark das benutzt +// +// goldmark erwartet hier ein Interface (parser.IDs) mit zwei Methoden: +// Generate und Put. In Go muss man ein Interface nicht ausdrücklich +// "implementieren" — es reicht, dass der Typ Methoden mit genau diesen Namen +// und Signaturen hat. Der Compiler prüft das an der Stelle, wo &ankerIDs{…} +// an parser.WithIDs übergeben wird. +type ankerIDs struct { + vergeben map[string]bool +} + +// Generate bildet aus dem Text einer Überschrift eine id. Kommt dieselbe +// Überschrift zweimal vor, bekommt die zweite ein "-1" angehängt, die dritte +// ein "-2" — ids müssen auf einer Seite eindeutig sein. +func (a *ankerIDs) Generate(text []byte, _ ast.NodeKind) []byte { + basis := store.Slugify(string(text)) + if basis == "" { + // Eine Überschrift nur aus Sonderzeichen ("## ???") — dann eben + // ein neutraler Name, eindeutig gemacht wie jeder andere. + basis = "abschnitt" + } + + id := basis + for nummer := 1; a.vergeben[id]; nummer++ { + id = fmt.Sprintf("%s-%d", basis, nummer) + } + a.vergeben[id] = true + return []byte(id) +} + +// Put merkt sich eine von Hand vergebene id, damit Generate sie nicht noch +// einmal erzeugt. (Von Hand vergebene ids nutzen die Hilfeseiten derzeit +// nicht, goldmark verlangt die Methode aber.) +func (a *ankerIDs) Put(id []byte) { + a.vergeben[string(id)] = true +} diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go new file mode 100644 index 0000000..1aa2843 --- /dev/null +++ b/internal/markdown/markdown_test.go @@ -0,0 +1,90 @@ +package markdown + +import ( + "strings" + "testing" +) + +// TestGFMErweiterungen: Tabellen, Durchstreichen, Checklisten und automatische +// Links funktionieren — in Einträgen genauso wie in der Hilfe (AUSBAUSTUFEN.md 5.3). +func TestGFMErweiterungen(t *testing.T) { + faelle := []struct { + name, quelle, erwartet string + }{ + {"Tabelle", "| a | b |\n|---|---|\n| 1 | 2 |", "
"}, + {"Durchstreichen", "~~veraltet~~", "veraltet"}, + {"Checkliste offen", "- [ ] offen", ``}, + {"Checkliste erledigt", "- [x] erledigt", ``}, + {"automatischer Link", "siehe https://example.org", ``}, + } + + // Beide Renderer prüfen: Sie sollen sich nur in den Ankern unterscheiden. + renderer := map[string]func(string) (string, error){ + "Render": Render, + "RenderMitAnkern": RenderMitAnkern, + } + + for rName, render := range renderer { + for _, f := range faelle { + html, err := render(f.quelle) + if err != nil { + t.Fatalf("%s/%s: %v", rName, f.name, err) + } + if !strings.Contains(html, f.erwartet) { + t.Errorf("%s/%s: %q fehlt in %q", rName, f.name, f.erwartet, html) + } + } + } +} + +// TestKeinRohHTML ist der wichtigste Test dieses Pakets: Die Weboberfläche setzt +// das Ergebnis ohne weiteres Escaping in die Seite. Das geht nur, solange hier +// weder Skripte noch gefährliche Link-Ziele durchkommen — auch nicht mit den +// GFM-Erweiterungen. +func TestKeinRohHTML(t *testing.T) { + boese := []string{ + "", + ``, + "[klick](javascript:alert(1))", + "| a |\n|---|\n| |", + "- [ ] ", + } + for _, quelle := range boese { + for _, render := range []func(string) (string, error){Render, RenderMitAnkern} { + html, _ := render(quelle) + if strings.Contains(html, " 0 { + return fmt.Errorf("%w: der Eintrag wurde inzwischen gelöscht und liegt im Papierkorb", ErrConflict) + } if w.ExpectedVersion > 0 && w.ExpectedVersion != version { return fmt.Errorf("%w (gelesen: %d, aktuell: %d)", ErrConflict, w.ExpectedVersion, version) } + // Die bisherige Fassung sichern, bevor sie überschrieben wird — + // die Schlagworte in entry_tags gehören noch zum alten Stand, + // replaceTags läuft erst weiter unten. Bei einem Eintrag aus dem + // Papierkorb ist das der gelöschte Stand: Er geht also auch beim + // Zurückholen durch Überschreiben nicht verloren. + if err := snapshotEntryVersion(ctx, tx, w.ProjectID, id); err != nil { + return err + } + version++ + // deleted_at = NULL holt einen Eintrag aus dem Papierkorb zurück. + // Für einen normalen Eintrag ändert es nichts — er war ja schon NULL. if _, err := tx.ExecContext(ctx, - `UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ? + `UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?, + deleted_at = NULL, deleted_by = '' WHERE id = ?`, w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, jetzt, id, ); err != nil { @@ -179,6 +222,8 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { Version: version, CreatedAt: parseTime(createdAt), UpdatedAt: parseTime(jetzt), + + RestoredFromTrash: imPapierkorb, } return nil }) @@ -189,6 +234,114 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { return ergebnis, nil } +// snapshotEntryVersion sichert den aktuellen Stand eines Eintrags in +// entry_versions, bevor er überschrieben wird — die einzige Stelle, an der +// diese Tabelle beschrieben wird (siehe migrations/004_aenderungshistorie.sql). +// +// Läuft immer als Teil der Transaktion, die auch die eigentliche Änderung +// vornimmt: Entweder werden alte Fassung und neue Fassung beide geschrieben, +// oder keine von beiden. +func snapshotEntryVersion(ctx context.Context, tx *sql.Tx, projectID, entryID string) error { + var version int + var title, content, source, author string + var curated int + err := tx.QueryRowContext(ctx, + `SELECT version, title, content, source, author, curated FROM entries WHERE id = ?`, entryID, + ).Scan(&version, &title, &content, &source, &author, &curated) + if err != nil { + return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err) + } + + rows, err := tx.QueryContext(ctx, `SELECT tag FROM entry_tags WHERE entry_id = ? ORDER BY tag`, entryID) + if err != nil { + return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err) + } + var tags []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + rows.Close() + return err + } + tags = append(tags, tag) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + if _, err := tx.ExecContext(ctx, + `INSERT INTO entry_versions (id, entry_id, project_id, version, title, content, tags, author, source, curated, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + NewID(), entryID, projectID, version, title, content, strings.Join(tags, ","), author, source, curated, NowString(), + ); err != nil { + return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err) + } + return nil +} + +// scanEntryVersion liest eine Zeile aus entry_versions. +func scanEntryVersion(scan func(...any) error) (EntryVersion, error) { + var v EntryVersion + var tags string + var curated int + var createdAt string + + if err := scan(&v.ID, &v.EntryID, &v.Version, &v.Title, &v.Content, &tags, + &v.Author, &v.Source, &curated, &createdAt); err != nil { + return EntryVersion{}, err + } + + if tags != "" { + v.Tags = strings.Split(tags, ",") + } + v.Curated = curated == 1 + v.CreatedAt = parseTime(createdAt) + return v, nil +} + +const entryVersionColumns = `id, entry_id, version, title, content, tags, author, source, curated, created_at` + +// EntryVersions gibt die abgelösten Fassungen eines Eintrags zurück, neueste +// zuerst. Die aktuelle Fassung steht nicht darin — die liefert EntryBySlug. +func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersion, error) { + rows, err := s.lesen.QueryContext(ctx, + `SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? ORDER BY version DESC`, + entryID, + ) + if err != nil { + return nil, fmt.Errorf("Änderungshistorie konnte nicht gelesen werden: %w", err) + } + defer rows.Close() + + var versionen []EntryVersion + for rows.Next() { + v, err := scanEntryVersion(rows.Scan) + if err != nil { + return nil, err + } + versionen = append(versionen, v) + } + return versionen, rows.Err() +} + +// EntryVersionByNumber liest eine einzelne abgelöste Fassung. +func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, version int) (EntryVersion, error) { + row := s.lesen.QueryRowContext(ctx, + `SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? AND version = ?`, + entryID, version, + ) + v, err := scanEntryVersion(row.Scan) + if errors.Is(err, sql.ErrNoRows) { + return EntryVersion{}, ErrNotFound + } + if err != nil { + return EntryVersion{}, fmt.Errorf("Fassung konnte nicht gelesen werden: %w", err) + } + return v, nil +} + // replaceTags ersetzt die Schlagworte eines Eintrags. // // project_id wird mitgeschrieben, obwohl es schon am Eintrag steht — siehe die @@ -229,7 +382,7 @@ func replaceLinks(ctx context.Context, tx *sql.Tx, projectID, entryID string, li // EntryBySlug liest einen Eintrag samt Schlagworten. func (s *Store) EntryBySlug(ctx context.Context, projectID, slug string) (Entry, error) { row := s.lesen.QueryRowContext(ctx, - `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ?`, + `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, projectID, slug, ) @@ -267,7 +420,7 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter // Die Abfrage wird stückweise zusammengesetzt. Wichtig: Werte gehen immer als // Parameter (?) in die Abfrage, nie per String-Verkettung — sonst wäre das // eine SQL-Injection-Lücke. - abfrage := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ?` + abfrage := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + nichtImPapierkorb werte := []any{projectID} if f.Tag != "" { @@ -305,10 +458,12 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter } // AllEntries gibt alle Einträge eines Projekts zurück — für den Aufbau des -// Suchindex beim Start und für den Export. +// Suchindex beim Start und für den Export. Einträge im Papierkorb gehören +// nicht dazu: Sie sollen weder gefunden werden noch im Export wieder +// auftauchen. func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, error) { rows, err := s.lesen.QueryContext(ctx, - `SELECT `+entryColumns+` FROM entries WHERE project_id = ? ORDER BY slug`, + `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+nichtImPapierkorb+` ORDER BY slug`, projectID, ) if err != nil { @@ -331,22 +486,11 @@ func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, erro return eintraege, s.attachTagsToSlice(ctx, eintraege) } -// DeleteEntry löscht einen Eintrag samt Schlagworten und Verweisen (per CASCADE). -func (s *Store) DeleteEntry(ctx context.Context, projectID, slug string) error { - res, err := s.schreiben.ExecContext(ctx, - `DELETE FROM entries WHERE project_id = ? AND slug = ?`, projectID, slug, - ) - if err != nil { - return fmt.Errorf("Eintrag konnte nicht gelöscht werden: %w", err) - } - return requireOneRow(res) -} - -// CountEntries zählt die Einträge eines Projekts. +// CountEntries zählt die Einträge eines Projekts (ohne Papierkorb). func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error) { var n int err := s.lesen.QueryRowContext(ctx, - `SELECT COUNT(*) FROM entries WHERE project_id = ?`, projectID, + `SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID, ).Scan(&n) if err != nil { return 0, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err) @@ -363,13 +507,20 @@ func (s *Store) ListTags(ctx context.Context, projectID string) ([]TagCount, err // hängt jetzt an der Zahl der Schlagworte, nicht an der Zahl der Einträge. // Siehe migrations/002 — diese Abfrage läuft bei jedem Seitenaufruf, weil // die Schlagworte in der Seitenleiste stehen. + // + // Die Schlagworte gelöschter Einträge bleiben in entry_tags stehen — sie + // werden beim Wiederherstellen gebraucht. Damit sie hier nicht mitgezählt + // werden, schließt die Unterabfrage die Einträge im Papierkorb aus. Die + // läuft über den kleinen Teilindex idx_entries_papierkorb (migrations/005) + // und kostet deshalb praktisch nichts, egal wie groß das Projekt ist. rows, err := s.lesen.QueryContext(ctx, `SELECT tag, COUNT(*) AS anzahl FROM entry_tags WHERE project_id = ? + AND entry_id NOT IN (SELECT id FROM entries WHERE project_id = ? AND deleted_at IS NOT NULL) GROUP BY tag ORDER BY anzahl DESC, tag`, - projectID, + projectID, projectID, ) if err != nil { return nil, fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err) @@ -443,6 +594,121 @@ func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error { return rows.Err() } +// BulkUpdateTags fügt einer Auswahl von Einträgen Schlagworte hinzu und/oder +// entfernt welche — eine Sammelbearbeitung, ohne jeden Eintrag einzeln öffnen +// zu müssen. +// +// Läuft in einer Transaktion, damit entweder alle betroffenen Einträge +// aktualisiert werden oder keiner. IDs, die nicht zu projectID gehören, werden +// stillschweigend übersprungen — sonst könnte eine untergeschobene ID über +// dieses eine Formular Einträge eines fremden Projekts verändern. +// +// Gibt die geänderten Einträge zurück, damit der Aufrufer den Suchindex +// nachziehen kann (siehe search.Registry.Update) — ein zweiter Lesezugriff +// nach der Transaktion wäre unnötig. +func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, add, remove []string) ([]Entry, error) { + add = NormalizeTags(add) + remove = NormalizeTags(remove) + entferneMenge := make(map[string]bool, len(remove)) + for _, t := range remove { + entferneMenge[t] = true + } + + var geaendert []Entry + jetzt := NowString() + + err := s.tx(ctx, func(tx *sql.Tx) error { + for _, id := range entryIDs { + var vorhandenID string + err := tx.QueryRowContext(ctx, + `SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb, id, projectID, + ).Scan(&vorhandenID) + if errors.Is(err, sql.ErrNoRows) { + continue // gehört nicht zu diesem Projekt, existiert nicht mehr oder liegt im Papierkorb + } + if err != nil { + return fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err) + } + + rows, err := tx.QueryContext(ctx, `SELECT tag FROM entry_tags WHERE entry_id = ? ORDER BY tag`, id) + if err != nil { + return fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err) + } + var bisherige []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + rows.Close() + return err + } + bisherige = append(bisherige, tag) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + neu := NormalizeTags(append(bisherige, add...)) + gefiltert := neu[:0] + for _, t := range neu { + if !entferneMenge[t] { + gefiltert = append(gefiltert, t) + } + } + + // Nur schreiben, wenn sich wirklich etwas ändert — sonst würde ein + // Sammelaufruf über eine gemischte Auswahl auch die Einträge + // "berühren", bei denen add/remove gar nichts bewirkt haben. + if stringSlicesEqual(bisherige, gefiltert) { + continue + } + + // Wie in PutEntry: die alte Fassung sichern, bevor die + // Schlagworte ersetzt werden — entry_tags trägt an dieser + // Stelle noch den vorherigen Stand. + if err := snapshotEntryVersion(ctx, tx, projectID, id); err != nil { + return err + } + + if err := replaceTags(ctx, tx, projectID, id, gefiltert); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `UPDATE entries SET version = version + 1, updated_at = ? WHERE id = ?`, jetzt, id, + ); err != nil { + return fmt.Errorf("Eintrag konnte nicht aktualisiert werden: %w", err) + } + + row := tx.QueryRowContext(ctx, `SELECT `+entryColumns+` FROM entries WHERE id = ?`, id) + e, err := scanEntry(row.Scan) + if err != nil { + return fmt.Errorf("Eintrag konnte nicht neu gelesen werden: %w", err) + } + e.Tags = gefiltert + geaendert = append(geaendert, e) + } + return nil + }) + if err != nil { + return nil, err + } + return geaendert, nil +} + +// stringSlicesEqual vergleicht zwei (bereits sortierte) Zeichenketten-Listen. +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // ── Verweise ───────────────────────────────────────────────────────────────── // Backlink ist ein Eintrag, der auf einen anderen verweist. @@ -469,7 +735,7 @@ func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backli `SELECT e.slug, e.title FROM entry_links l JOIN entries e ON e.id = l.from_entry_id - WHERE l.project_id = ? AND l.to_slug = ?`, + WHERE l.project_id = ? AND l.to_slug = ? AND e.deleted_at IS NULL`, projectID, slug, ) if err != nil { @@ -493,6 +759,85 @@ func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backli return links, nil } +// GraphNode ist ein Eintrag in der Graph-Ansicht der Verweise. +type GraphNode struct { + Slug string + Title string + Exists bool // false = Ziel eines Verweises, aber noch kein Eintrag dazu angelegt +} + +// GraphEdge ist ein einzelner Verweis zwischen zwei Bezeichnern. +type GraphEdge struct { + From string + To string +} + +// LinkGraph gibt alle Einträge eines Projekts als Knoten und alle +// [[…]]-Verweise dazwischen als Kanten zurück — die Grundlage der +// Graph-Ansicht (siehe internal/web/handlers_entries.go). +// +// Verweise auf einen Bezeichner, zu dem es (noch) keinen Eintrag gibt, +// erzeugen trotzdem einen Knoten — mit Exists = false. Das macht in der +// Graph-Ansicht sichtbar, wohin schon verwiesen wird, was aber noch fehlt, +// statt solche Verweise stillschweigend zu unterschlagen. +func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, []GraphEdge, error) { + entryRows, err := s.lesen.QueryContext(ctx, + `SELECT slug, title FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID, + ) + if err != nil { + return nil, nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err) + } + knoten := make(map[string]*GraphNode) + for entryRows.Next() { + var slug, title string + if err := entryRows.Scan(&slug, &title); err != nil { + entryRows.Close() + return nil, nil, err + } + knoten[slug] = &GraphNode{Slug: slug, Title: title, Exists: true} + } + if err := entryRows.Err(); err != nil { + entryRows.Close() + return nil, nil, err + } + entryRows.Close() + + linkRows, err := s.lesen.QueryContext(ctx, + `SELECT e.slug, l.to_slug + FROM entry_links l + JOIN entries e ON e.id = l.from_entry_id + WHERE l.project_id = ? AND e.deleted_at IS NULL`, + projectID, + ) + if err != nil { + return nil, nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err) + } + defer linkRows.Close() + + var kanten []GraphEdge + for linkRows.Next() { + var von, nach string + if err := linkRows.Scan(&von, &nach); err != nil { + return nil, nil, err + } + if _, ok := knoten[nach]; !ok { + knoten[nach] = &GraphNode{Slug: nach, Title: nach, Exists: false} + } + kanten = append(kanten, GraphEdge{From: von, To: nach}) + } + if err := linkRows.Err(); err != nil { + return nil, nil, err + } + + knotenListe := make([]GraphNode, 0, len(knoten)) + for _, k := range knoten { + knotenListe = append(knotenListe, *k) + } + sort.Slice(knotenListe, func(i, j int) bool { return knotenListe[i].Slug < knotenListe[j].Slug }) + + return knotenListe, kanten, nil +} + // boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung. func boolToInt(b bool) int { if b { diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql index 5589752..f3eed0d 100644 --- a/internal/store/migrations/001_init.sql +++ b/internal/store/migrations/001_init.sql @@ -11,20 +11,20 @@ -- * Kein SQLite-spezifisches SQL (kein AUTOINCREMENT, kein INSERT OR REPLACE, -- keine datetime()-Funktionen), damit der Umstieg offen bleibt. --- Ein Workspace ist die oberste Klammer. Vorerst gibt es genau einen; die Spalte --- existiert von Anfang an, damit später Abteilungstrennung möglich ist, ohne --- jede Tabelle anfassen zu müssen. -CREATE TABLE workspaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at TEXT NOT NULL -); +-- Diese Datei enthält genau einen Workspace (eine Abteilung). Die +-- Workspace-Verwaltung selbst (Tabelle "workspaces") lebt in einer eigenen, +-- zentralen Kontroll-Datenbank — siehe internal/store/migrations_control und +-- AUSBAUSTUFEN.md 4.4. "workspace_id" steht hier trotzdem an jeder Zeile, damit +-- der Bezug beim Lesen einer einzelnen Datei erhalten bleibt, auch wenn er +-- innerhalb der Datei immer denselben Wert hat. Es gibt bewusst keinen +-- Fremdschlüssel auf eine "workspaces"-Tabelle — die existiert in dieser Datei +-- nicht. -- Ein Projekt ist die Einheit der Abschottung: eigene Einträge, eigene Keys, -- eigener MCP-Server. Ein Agent, der an Projekt A arbeitet, sieht Projekt B nicht. CREATE TABLE projects ( id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', diff --git a/internal/store/migrations/004_aenderungshistorie.sql b/internal/store/migrations/004_aenderungshistorie.sql new file mode 100644 index 0000000..bf01241 --- /dev/null +++ b/internal/store/migrations/004_aenderungshistorie.sql @@ -0,0 +1,43 @@ +-- Änderungshistorie: die überschriebene Fassung eines Eintrags geht nicht +-- mehr verloren. +-- +-- ── Warum? ─────────────────────────────────────────────────────────────────── +-- +-- entries.version zählt bei jeder Änderung hoch, aber der alte Inhalt wird +-- nirgends aufgehoben. Überschreibt ein Agent etwas Richtiges mit etwas +-- Falschem — oder ein Mensch verwechselt sich beim Bearbeiten —, ist das +-- Richtige weg, ohne Wiederherstellungsweg. +-- +-- ── Aufbau ─────────────────────────────────────────────────────────────────── +-- +-- entries bleibt die aktuelle Fassung. entry_versions hält die SUPERSEDED +-- Fassungen: bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht +-- beim erstmaligen Anlegen), wird der bisherige Stand hier abgelegt, bevor er +-- überschrieben wird. version trägt dabei die Nummer, die die Fassung VOR der +-- Änderung hatte — Fassung 1 ist also das, was durch das Schreiben zu Fassung 2 +-- wurde. +-- +-- Schlagworte stehen hier bewusst als kommagetrennter Text statt in einer +-- eigenen Tabelle wie bei entries/entry_tags: Eine historische Fassung wird nie +-- gefiltert oder durchsucht, sie wird nur ganz gelesen — eine Nebentabelle +-- brächte dafür keinen Nutzen, nur eine weitere Stelle, die beim Schreiben +-- mitgepflegt werden müsste. +CREATE TABLE entry_versions ( + id TEXT PRIMARY KEY, + entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + project_id TEXT NOT NULL, + version INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL, + curated INTEGER NOT NULL DEFAULT 0, + -- Zeitpunkt, zu dem diese Fassung abgelöst wurde — nicht, wann sie + -- entstand (das wüssten wir hier nicht mehr, ohne die Kette rückwärts + -- aufzurollen; für die Historie reicht "abgelöst am"). + created_at TEXT NOT NULL, + UNIQUE (entry_id, version) +); + +CREATE INDEX idx_entry_versions_entry ON entry_versions (entry_id, version); diff --git a/internal/store/migrations/005_papierkorb.sql b/internal/store/migrations/005_papierkorb.sql new file mode 100644 index 0000000..fbe3239 --- /dev/null +++ b/internal/store/migrations/005_papierkorb.sql @@ -0,0 +1,41 @@ +-- Papierkorb: Löschen ist nicht mehr sofort endgültig (AUSBAUSTUFEN.md 3.9). +-- +-- ── Warum? ─────────────────────────────────────────────────────────────────── +-- +-- Seit der Änderungshistorie (004) lässt sich jedes Überschreiben reparieren. +-- Löschen nicht: DELETE entfernte den Eintrag samt aller Fassungen (ON DELETE +-- CASCADE). Damit war "forget" der schlimmste Fall, den ein fehlgeleiteter +-- Agent anrichten konnte. +-- +-- ── Aufbau ─────────────────────────────────────────────────────────────────── +-- +-- Löschen setzt jetzt nur noch deleted_at (Zeitpunkt) und deleted_by (Name des +-- Zugangs). Die Zeile bleibt mit Schlagworten, Verweisen und Verlauf stehen. +-- Endgültig entfernt wird sie erst, wenn ein Mensch den Papierkorb leert oder +-- die tägliche Aufgabe sie nach 30 Tagen abräumt — dann greift das bekannte +-- ON DELETE CASCADE. +-- +-- deleted_at ist NULL für jeden normalen Eintrag. NULL statt eines leeren +-- Textes, weil "nicht gelöscht" kein Zeitpunkt ist — und weil sich +-- "deleted_at IS NULL" in jeder Abfrage eindeutig liest. +-- +-- Jede lesende Abfrage auf entries braucht ab jetzt diese Bedingung. In Go +-- steht sie als Konstante (store.nichtImPapierkorb), damit sie sich leicht +-- finden lässt; ein Test prüft jeden lesenden Weg. +-- +-- Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt +-- (UNIQUE (project_id, slug) gilt weiter). Wer denselben Bezeichner neu +-- schreibt, holt den Eintrag zurück und überschreibt ihn — der Stand aus dem +-- Papierkorb wandert dabei in den Verlauf (siehe PutEntry). +ALTER TABLE entries ADD COLUMN deleted_at TEXT; +ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT ''; + +-- Ein Teilindex nur über die Einträge im Papierkorb. Er bleibt winzig, denn +-- dort liegt fast nie etwas, und bedient die drei Fragen, die nur den +-- Papierkorb betreffen: "was liegt im Papierkorb dieses Projekts" (Seite), +-- "was ist älter als die Frist" (tägliches Aufräumen) und "welche +-- Schlagworte gehören zu gelöschten Einträgen" (ListTags). +-- +-- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg +-- dorthin bleibt offen. +CREATE INDEX idx_entries_papierkorb ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/internal/store/migrations_control/001_init.sql b/internal/store/migrations_control/001_init.sql new file mode 100644 index 0000000..8d89d82 --- /dev/null +++ b/internal/store/migrations_control/001_init.sql @@ -0,0 +1,33 @@ +-- Schema der zentralen Kontroll-Datenbank. +-- +-- Diese Datei ist bewusst von den Workspace-Dateien getrennt (siehe +-- internal/store/migrations) und enthält nur, was gebraucht wird, BEVOR der +-- Workspace bekannt ist: die Liste der Workspaces selbst, und den Weg von +-- einem Key-Hash zu seinem Workspace. +-- +-- Alles andere (Projekte, Einträge, Keys im Volltext, Protokoll) lebt in der +-- jeweiligen Workspace-Datei — siehe AUSBAUSTUFEN.md 4.4. + +-- Ein Workspace ist eine Abteilung. Jede hat ihre eigene Datenbankdatei; diese +-- Tabelle ist nur das Verzeichnis darüber. +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- key_index bildet Key-Hash auf Workspace ab — der einzige Grund, warum diese +-- Datenbank vor dem Öffnen einer Workspace-Datei befragt werden muss. +-- +-- Die Zeile hier ist ein Index, keine Wahrheit über den Key selbst: Name, +-- Rolle, Projekt und der Zeitpunkt der letzten Nutzung stehen ausschließlich +-- in der Workspace-Datei. Wird ein Key dort angelegt oder zurückgezogen, muss +-- diese Tabelle im selben Zug nachgezogen werden (siehe ControlStore.IndexKey +-- und ControlStore.RemoveKeyIndex). +CREATE TABLE key_index ( + hash TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_key_index_workspace ON key_index (workspace_id); diff --git a/internal/store/models.go b/internal/store/models.go index a23d3a3..34694fd 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -80,6 +80,38 @@ type Entry struct { Version int CreatedAt time.Time UpdatedAt time.Time + + // RestoredFromTrash setzt nur PutEntry: true, wenn der Schreibvorgang + // einen Eintrag aus dem Papierkorb zurückgeholt und überschrieben hat. + // Aufrufer sagen das dem Schreiber — ein Agent soll merken, dass er einen + // gelöschten Eintrag wiederbelebt hat. Beim Lesen ist der Wert immer false. + RestoredFromTrash bool +} + +// TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite +// braucht: wer ihn wann gelöscht hat, ohne den ganzen Inhalt. +type TrashedEntry struct { + ID string + Slug string + Title string + Version int + DeletedAt time.Time + DeletedBy string // Name des Zugangs, der gelöscht hat +} + +// EntryVersion ist eine abgelöste Fassung eines Eintrags — der Stand, der bei +// einem Schreibvorgang überschrieben wurde (siehe migrations/004). +type EntryVersion struct { + ID string + EntryID string + Version int // Fassungsnummer, die diese Zeile VOR der Ablösung trug + Title string + Content string + Tags []string + Author string + Source string + Curated bool + CreatedAt time.Time // Zeitpunkt der Ablösung, nicht der Entstehung } // Herkunft eines Eintrags. diff --git a/internal/store/nutzung.go b/internal/store/nutzung.go new file mode 100644 index 0000000..18eb55e --- /dev/null +++ b/internal/store/nutzung.go @@ -0,0 +1,314 @@ +package store + +// Nutzungsübersicht je Projekt (AUSBAUSTUFEN.md 4.6). +// +// Die Frage dahinter: Benutzen die Teams Munin wirklich — und benutzen ihre +// Agenten es so, wie es gedacht ist? Ein gutes Zeichen ist, wenn der Anteil +// der Aktualisierungen gegenüber Neuanlagen wächst: Dann finden Agenten +// vorhandenes Wissen und schreiben es fort, statt Dubletten daneben zu legen. +// +// Alle Zahlen kommen aus Daten, die ohnehin da sind — dem Protokoll +// (audit_events) und den Einträgen. Es gibt keine eigene Zähltabelle, die +// mitgepflegt werden müsste und auseinanderlaufen könnte. + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// UsageWindow ist der Zeitraum, über den die Schreibvorgänge gezählt werden. +// Kürzer als die Aufbewahrungsfrist des Protokolls (90 Tage), sonst wären die +// ältesten Tage schon teilweise weggeräumt und die Zahlen zu niedrig. +const UsageWindow = 30 * 24 * time.Hour + +// UsageWindowDays ist derselbe Zeitraum in Tagen, für Texte in der Oberfläche. +const UsageWindowDays = int(UsageWindow / (24 * time.Hour)) + +// WriteCounts zählt Schreibvorgänge eines Zeitraums nach Art. +type WriteCounts struct { + Created int // neu angelegt + Updated int // bestehenden Eintrag geändert (auch Wiederherstellen, Sammelbearbeitung) + Deleted int // in den Papierkorb gelegt + + // Unknown sind Schreibvorgänge ohne Angabe, ob angelegt oder aktualisiert. + // Das Protokoll unterscheidet das erst seit 2026-09-24; ältere Zeilen + // landen hier. Nach 30 Tagen ist dieser Wert von selbst immer 0. + Unknown int +} + +// Writes ist die Zahl aller Schreibvorgänge ohne Löschungen. +func (w WriteCounts) Writes() int { + return w.Created + w.Updated + w.Unknown +} + +// add zählt zwei Werte zusammen — für die Summe aus Menschen und Agenten. +func (w WriteCounts) add(o WriteCounts) WriteCounts { + return WriteCounts{ + Created: w.Created + o.Created, + Updated: w.Updated + o.Updated, + Deleted: w.Deleted + o.Deleted, + Unknown: w.Unknown + o.Unknown, + } +} + +// UsageSummary sind die Kennzahlen eines Projekts. +type UsageSummary struct { + // Schreibvorgänge im Zeitraum UsageWindow, getrennt nach Weg: Die + // Weboberfläche benutzen Menschen, MCP benutzen Agenten. (Ein Mensch, der + // selbst über MCP schreibt, zählt als Agent — aus Sicht von Munin ist er + // in dem Moment auch einer.) + Human WriteCounts + Agent WriteCounts + + // Bestand, ohne Papierkorb. + Entries int + Curated int + Untagged int + LastUpdatedAt time.Time // jüngste Änderung eines Eintrags; Nullzeitpunkt = keine Einträge +} + +// Total ist die Summe aus Menschen und Agenten. +func (u UsageSummary) Total() WriteCounts { + return u.Human.add(u.Agent) +} + +// CuratedPercent ist der Anteil kuratierter Einträge in Prozent (0–100). +func (u UsageSummary) CuratedPercent() int { + return prozent(u.Curated, u.Entries) +} + +// UpdatePercent ist der Anteil der Aktualisierungen an allen Schreibvorgängen +// mit bekannter Art, in Prozent. -1 heißt: Es gab keine solchen +// Schreibvorgänge, ein Anteil ergibt dann keinen Sinn. +func (u UsageSummary) UpdatePercent() int { + t := u.Total() + if t.Created+t.Updated == 0 { + return -1 + } + return prozent(t.Updated, t.Created+t.Updated) +} + +// prozent rechnet teil/ganz in ganze Prozent um, kaufmännisch gerundet. +// Ganzzahlig statt mit Kommazahlen: "37 %" reicht für diese Übersicht, und so +// gibt es keine Rundungsfragen in der Anzeige. +func prozent(teil, ganz int) int { + if ganz == 0 { + return 0 + } + // Die Hälfte des Nenners dazuzählen, bevor ganzzahlig geteilt wird — + // das ist Runden statt Abschneiden. + return (teil*100 + ganz/2) / ganz +} + +// UsageSummaries liefert die Kennzahlen je Projekt, als map von Projekt-ID +// auf Werte. projectID schränkt auf ein Projekt ein; leer heißt: alle Projekte +// dieser Workspace-Datei (für die Admin-Übersicht, in zwei Abfragen statt +// zwei je Projekt). +// +// Projekte ganz ohne Einträge und ohne Schreibvorgänge fehlen in der map. Der +// Aufrufer bekommt für sie beim Nachschlagen den Nullwert — also überall 0, +// und genau das stimmt ja auch. +func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time.Time) (map[string]UsageSummary, error) { + ergebnis := map[string]UsageSummary{} + seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) + + // ── 1. Schreibvorgänge aus dem Protokoll ──────────────────────────────── + // + // Eine Abfrage für alle vier Zahlen: CASE ... THEN 1 ELSE 0 macht aus jeder + // Zeile eine 1 oder 0 je Spalte, SUM zählt sie. Das Feld detail sagt, ob + // angelegt oder aktualisiert wurde (siehe DetailEntryCreated in audit.go). + // Alles, was nicht "angelegt" und nicht leer ist, ist eine Aktualisierung: + // "aktualisiert", "wiederhergestellt: …", "Sammelbearbeitung: …". + abfrage := `SELECT project_id, source, + SUM(CASE WHEN action = ? AND detail = ? THEN 1 ELSE 0 END), + SUM(CASE WHEN action = ? AND detail <> ? AND detail <> '' THEN 1 ELSE 0 END), + SUM(CASE WHEN action = ? AND detail = '' THEN 1 ELSE 0 END), + SUM(CASE WHEN action = ? THEN 1 ELSE 0 END) + FROM audit_events + WHERE ts >= ? AND action IN (?, ?)` + werte := []any{ + ActionEntryWrite, DetailEntryCreated, + ActionEntryWrite, DetailEntryCreated, + ActionEntryWrite, + ActionEntryDelete, + seit, ActionEntryWrite, ActionEntryDelete, + } + if projectID != "" { + abfrage += ` AND project_id = ?` + werte = append(werte, projectID) + } + abfrage += ` GROUP BY project_id, source` + + rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) + if err != nil { + return nil, fmt.Errorf("Nutzung konnte nicht gelesen werden: %w", err) + } + for rows.Next() { + var projekt, quelle string + var z WriteCounts + if err := rows.Scan(&projekt, &quelle, &z.Created, &z.Updated, &z.Unknown, &z.Deleted); err != nil { + rows.Close() + return nil, err + } + + // Werte aus einer map sind in Go Kopien: erst herausholen, ändern, + // dann wieder hineinschreiben. + u := ergebnis[projekt] + switch quelle { + case SourceWeb: + u.Human = u.Human.add(z) + case SourceMCP: + u.Agent = u.Agent.add(z) + } + ergebnis[projekt] = u + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + + // ── 2. Bestand aus den Einträgen ──────────────────────────────────────── + // + // NOT EXISTS fragt je Eintrag, ob es in entry_tags gar keine Zeile für ihn + // gibt. Das läuft über den Primärschlüssel (entry_id, tag) und ist damit + // ein kurzer Blick in den Index je Eintrag. + abfrage = `SELECT project_id, COUNT(*), COALESCE(SUM(curated), 0), + SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) THEN 1 ELSE 0 END), + MAX(updated_at) + FROM entries e + WHERE e.deleted_at IS NULL` + werte = nil + if projectID != "" { + abfrage += ` AND project_id = ?` + werte = append(werte, projectID) + } + abfrage += ` GROUP BY project_id` + + rows, err = s.lesen.QueryContext(ctx, abfrage, werte...) + if err != nil { + return nil, fmt.Errorf("Bestand konnte nicht gelesen werden: %w", err) + } + defer rows.Close() + for rows.Next() { + var projekt string + var zuletzt sql.NullString + var anzahl, kuratiert, ohneSchlagwort int + if err := rows.Scan(&projekt, &anzahl, &kuratiert, &ohneSchlagwort, &zuletzt); err != nil { + return nil, err + } + u := ergebnis[projekt] + u.Entries, u.Curated, u.Untagged = anzahl, kuratiert, ohneSchlagwort + if zuletzt.Valid { + u.LastUpdatedAt = parseTime(zuletzt.String) + } + ergebnis[projekt] = u + } + return ergebnis, rows.Err() +} + +// EntryRef ist ein knapper Verweis auf einen Eintrag: Bezeichner und Titel. +type EntryRef struct { + Slug string + Title string +} + +// UntaggedEntries gibt Einträge ohne Schlagwort zurück, zuletzt geänderte +// zuerst, höchstens limit Stück. +func (s *Store) UntaggedEntries(ctx context.Context, projectID string, limit int) ([]EntryRef, error) { + rows, err := s.lesen.QueryContext(ctx, + `SELECT slug, title FROM entries e + WHERE project_id = ? AND e.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) + ORDER BY updated_at DESC + LIMIT ?`, + projectID, limit, + ) + if err != nil { + return nil, fmt.Errorf("Einträge ohne Schlagwort konnten nicht gelesen werden: %w", err) + } + defer rows.Close() + + var liste []EntryRef + for rows.Next() { + var e EntryRef + if err := rows.Scan(&e.Slug, &e.Title); err != nil { + return nil, err + } + liste = append(liste, e) + } + return liste, rows.Err() +} + +// MissingLink ist ein Bezeichner, auf den verwiesen wird, zu dem es aber +// keinen Eintrag gibt — mit der Zahl der Einträge, die darauf verweisen. +type MissingLink struct { + Slug string + Count int +} + +// MissingLinks gibt alle [[…]]-Verweise ins Leere zurück, meistgenannte zuerst. +// +// Ein Verweis ins Leere ist nicht unbedingt ein Fehler: Oft steht er für +// Wissen, das jemand für wichtig hielt, aber noch niemand aufgeschrieben hat. +// Die meistgenannten sind deshalb gute Kandidaten für den nächsten Eintrag. +// +// Verweise AUS gelöschten Einträgen zählen nicht; Verweise AUF gelöschte +// Einträge dagegen schon — für alle Leser ist das Ziel ja weg. +func (s *Store) MissingLinks(ctx context.Context, projectID string) ([]MissingLink, error) { + rows, err := s.lesen.QueryContext(ctx, + `SELECT l.to_slug, COUNT(*) AS anzahl + FROM entry_links l + JOIN entries e ON e.id = l.from_entry_id + WHERE l.project_id = ? AND e.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM entries z + WHERE z.project_id = l.project_id AND z.slug = l.to_slug AND z.deleted_at IS NULL) + GROUP BY l.to_slug + ORDER BY anzahl DESC, l.to_slug`, + projectID, + ) + if err != nil { + return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err) + } + defer rows.Close() + + var liste []MissingLink + for rows.Next() { + var m MissingLink + if err := rows.Scan(&m.Slug, &m.Count); err != nil { + return nil, err + } + liste = append(liste, m) + } + return liste, rows.Err() +} + +// RecentlyUsedKeys gibt die zuletzt benutzten, nicht zurückgezogenen Keys +// eines Projekts zurück, höchstens limit Stück. Nie benutzte Keys fehlen — +// die gehören in eine andere Frage (AUSBAUSTUFEN.md 4.7). +func (s *Store) RecentlyUsedKeys(ctx context.Context, projectID string, limit int) ([]APIKey, error) { + rows, err := s.lesen.QueryContext(ctx, + `SELECT `+keyColumns+` FROM api_keys + WHERE project_id = ? AND revoked_at = '' AND last_used_at <> '' + ORDER BY last_used_at DESC + LIMIT ?`, + projectID, limit, + ) + if err != nil { + return nil, fmt.Errorf("Keys konnten nicht gelesen werden: %w", err) + } + defer rows.Close() + + var keys []APIKey + for rows.Next() { + k, err := scanKey(rows.Scan) + if err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} diff --git a/internal/store/nutzung_test.go b/internal/store/nutzung_test.go new file mode 100644 index 0000000..51b28f6 --- /dev/null +++ b/internal/store/nutzung_test.go @@ -0,0 +1,150 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und +// Löschungen werden getrennt nach Mensch (Web) und Agent (MCP) gezählt — und +// nur innerhalb des Zeitfensters. +func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + protokoll := func(quelle, aktion, detail string) { + s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: aktion, Source: quelle, Detail: detail}) + } + protokoll(SourceMCP, ActionEntryWrite, DetailEntryCreated) + protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated) + protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated) + protokoll(SourceMCP, ActionEntryDelete, "in den Papierkorb") + protokoll(SourceWeb, ActionEntryWrite, DetailEntryCreated) + protokoll(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung + protokoll(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso + protokoll(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung + protokoll(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht + + // Ein Schreibvorgang außerhalb des Fensters darf nicht mitzählen. + alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano) + if _, err := s.schreiben.ExecContext(ctx, + `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) + VALUES (?, ?, ?, '', 'x', ?, '', ?, '', ?)`, + NewID(), alt, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil { + t.Fatal(err) + } + + alle, err := s.UsageSummaries(ctx, "", time.Now()) + if err != nil { + t.Fatal(err) + } + u := alle[p.ID] + + if (u.Agent != WriteCounts{Created: 1, Updated: 2, Deleted: 1}) { + t.Errorf("Agent = %+v", u.Agent) + } + if (u.Human != WriteCounts{Created: 1, Updated: 2, Unknown: 1}) { + t.Errorf("Mensch = %+v", u.Human) + } + // 4 Aktualisierungen von 6 bekannten Schreibvorgängen = 67 %. + if got := u.UpdatePercent(); got != 67 { + t.Errorf("UpdatePercent = %d, erwartet 67", got) + } + + // Eingeschränkt auf das Projekt kommt dasselbe heraus. + einzeln, _ := s.UsageSummaries(ctx, p.ID, time.Now()) + if einzeln[p.ID] != u { + t.Errorf("mit Projektfilter: %+v, ohne: %+v", einzeln[p.ID], u) + } +} + +// TestUsageSummariesBestand: Einträge, kuratierte und solche ohne Schlagwort — +// ohne Papierkorb. +func TestUsageSummariesBestand(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Tags: []string{"x"}, Curated: true, Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Source: SourceAgent}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Source: SourceAgent}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Curated: true, Source: SourceAgent}) + s.MoveEntryToTrash(ctx, p.ID, "weg", "tom") + + alle, _ := s.UsageSummaries(ctx, "", time.Now()) + u := alle[p.ID] + + if u.Entries != 3 || u.Curated != 1 || u.Untagged != 2 { + t.Errorf("Bestand = %d Einträge, %d kuratiert, %d ohne Schlagwort; erwartet 3, 1, 2", + u.Entries, u.Curated, u.Untagged) + } + if u.CuratedPercent() != 33 { + t.Errorf("CuratedPercent = %d, erwartet 33", u.CuratedPercent()) + } + if u.LastUpdatedAt.IsZero() { + t.Error("LastUpdatedAt fehlt") + } + // Keine Schreibvorgänge im Protokoll → kein sinnvoller Anteil. + if u.UpdatePercent() != -1 { + t.Errorf("UpdatePercent ohne Schreibvorgänge = %d, erwartet -1", u.UpdatePercent()) + } + + ohne, _ := s.UntaggedEntries(ctx, p.ID, 10) + if len(ohne) != 2 { + t.Errorf("UntaggedEntries = %v, erwartet b und c", ohne) + } +} + +// TestMissingLinks: Verweise ins Leere, meistgenannte zuerst. Verweise aus +// dem Papierkorb zählen nicht, Verweise auf gelöschte Einträge schon. +func TestMissingLinks(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Content: "[[fehlt]] [[vorhanden]] [[geloescht]]", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Content: "[[fehlt]]", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "vorhanden", Title: "V", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "geloescht", Title: "G", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "im-papierkorb", Title: "P", Content: "[[nur-von-dort]]", Source: SourceHuman}) + s.MoveEntryToTrash(ctx, p.ID, "geloescht", "tom") + s.MoveEntryToTrash(ctx, p.ID, "im-papierkorb", "tom") + + liste, err := s.MissingLinks(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + erwartet := []MissingLink{{"fehlt", 2}, {"geloescht", 1}} + if len(liste) != len(erwartet) { + t.Fatalf("MissingLinks = %v, erwartet %v", liste, erwartet) + } + for i := range erwartet { + if liste[i] != erwartet[i] { + t.Errorf("[%d] = %v, erwartet %v", i, liste[i], erwartet[i]) + } + } +} + +func TestRecentlyUsedKeys(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + benutzt, _ := s.CreateAPIKey(ctx, p.ID, "benutzt", "hash-1", "write") + s.CreateAPIKey(ctx, p.ID, "nie", "hash-2", "write") + weg, _ := s.CreateAPIKey(ctx, p.ID, "zurueckgezogen", "hash-3", "write") + + jetzt := NowString() + s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, jetzt, benutzt.ID, weg.ID) + s.RevokeAPIKey(ctx, weg.ID) + + keys, err := s.RecentlyUsedKeys(ctx, p.ID, 5) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0].Name != "benutzt" { + t.Errorf("RecentlyUsedKeys = %v, erwartet nur „benutzt“", keys) + } +} diff --git a/internal/store/papierkorb.go b/internal/store/papierkorb.go new file mode 100644 index 0000000..8aa5181 --- /dev/null +++ b/internal/store/papierkorb.go @@ -0,0 +1,209 @@ +package store + +// Papierkorb (AUSBAUSTUFEN.md 3.9, migrations/005_papierkorb.sql). +// +// Der Lebenslauf eines gelöschten Eintrags: +// +// MoveEntryToTrash → deleted_at gesetzt, für alle Lesewege unsichtbar +// RestoreFromTrash → deleted_at wieder NULL, alles wie vorher +// PutEntry → derselbe Bezeichner wird neu geschrieben: zurückgeholt +// und überschrieben, der gelöschte Stand im Verlauf +// PurgeFromTrash → ein Mensch leert den Papierkorb: endgültig weg +// PurgeTrash → die tägliche Aufgabe räumt nach Ablauf der Frist ab +// +// Endgültig löschen können nur die beiden Purge-Funktionen, und beide fassen +// ausschließlich Einträge an, die schon im Papierkorb liegen. Ein normaler +// Eintrag kann also nie in einem Schritt verschwinden. + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// TrashRetention bestimmt, wie lange ein gelöschter Eintrag im Papierkorb +// liegt, bevor die tägliche Aufgabe ihn endgültig entfernt. +// +// 30 Tage: lang genug, dass ein Fehler auch nach Urlaub oder Sprintende noch +// auffällt, kurz genug, dass versehentlich gespeicherte Geheimnisse nicht +// ewig liegen bleiben (die lassen sich außerdem sofort von Hand endgültig +// löschen). Eine Konstante hier statt in main.go, damit die Oberfläche +// dieselbe Zahl anzeigt, nach der auch aufgeräumt wird. +const TrashRetention = 30 * 24 * time.Hour + +// TrashRetentionDays ist dieselbe Frist in Tagen, für Texte in Oberfläche +// und MCP-Beschreibung. +const TrashRetentionDays = int(TrashRetention / (24 * time.Hour)) + +// MoveEntryToTrash legt einen Eintrag in den Papierkorb. +// +// Inhalt, Schlagworte, Verweise und Verlauf bleiben unverändert stehen, auch +// die Fassungsnummer: Löschen ist keine neue Fassung, sondern eine Markierung +// daneben. geloeschtVon ist der Name des Zugangs, der gelöscht hat — die +// Papierkorb-Seite zeigt ihn, damit man sieht, ob ein Agent aufgeräumt hat. +// +// Liegt der Eintrag schon im Papierkorb (oder gibt es ihn nicht), kommt +// ErrNotFound — aus Sicht des Aufrufers gibt es ihn ja nicht mehr. +func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloeschtVon string) error { + res, err := s.schreiben.ExecContext(ctx, + `UPDATE entries SET deleted_at = ?, deleted_by = ? + WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, + NowString(), geloeschtVon, projectID, slug, + ) + if err != nil { + return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err) + } + return requireOneRow(res) +} + +// ListTrash gibt den Papierkorb eines Projekts zurück, zuletzt gelöschte zuerst. +// +// geloeschtVon schränkt auf einen Zugang ein (leer = alle). So lässt sich +// alles, was ein bestimmter Agent gelöscht hat, auf einen Blick sehen und in +// einem Zug zurückholen. +func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) ([]TrashedEntry, error) { + abfrage := `SELECT id, slug, title, version, deleted_at, deleted_by + FROM entries + WHERE project_id = ? AND deleted_at IS NOT NULL` + werte := []any{projectID} + if geloeschtVon != "" { + abfrage += ` AND deleted_by = ?` + werte = append(werte, geloeschtVon) + } + abfrage += ` ORDER BY deleted_at DESC` + + rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) + if err != nil { + return nil, fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err) + } + defer rows.Close() + + var liste []TrashedEntry + for rows.Next() { + var t TrashedEntry + var geloeschtAm string + if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &geloeschtAm, &t.DeletedBy); err != nil { + return nil, err + } + t.DeletedAt = parseTime(geloeschtAm) + liste = append(liste, t) + } + return liste, rows.Err() +} + +// RestoreFromTrash holt Einträge aus dem Papierkorb zurück. +// +// Bezeichner, die nicht (mehr) im Papierkorb dieses Projekts liegen, werden +// stillschweigend übersprungen — genauso wie bei BulkUpdateTags: Eine +// untergeschobene Angabe aus einem fremden Projekt verändert nichts. +// +// Zurückgegeben werden die wiederhergestellten Einträge samt Schlagworten, +// damit der Aufrufer sie wieder in den Suchindex aufnehmen kann. +func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []string) ([]Entry, error) { + var zurueck []Entry + + err := s.tx(ctx, func(tx *sql.Tx) error { + for _, slug := range slugs { + res, err := tx.ExecContext(ctx, + `UPDATE entries SET deleted_at = NULL, deleted_by = '' + WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`, + projectID, slug, + ) + if err != nil { + return fmt.Errorf("Eintrag %q konnte nicht wiederhergestellt werden: %w", slug, err) + } + if n, _ := res.RowsAffected(); n == 0 { + continue + } + + row := tx.QueryRowContext(ctx, + `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ?`, projectID, slug) + e, err := scanEntry(row.Scan) + if err != nil { + return fmt.Errorf("Eintrag %q konnte nicht neu gelesen werden: %w", slug, err) + } + zurueck = append(zurueck, e) + } + return nil + }) + if err != nil { + return nil, err + } + + // Die Schlagworte erst nach der Transaktion nachladen: attachTags liest + // über den Lese-Pool, und der sieht die Änderung erst nach dem Commit. + return zurueck, s.attachTagsToSlice(ctx, zurueck) +} + +// PurgeFromTrash löscht Einträge aus dem Papierkorb endgültig, samt Verlauf, +// Schlagworten und Verweisen (ON DELETE CASCADE). Das lässt sich nicht +// rückgängig machen. +// +// Die Bedingung "deleted_at IS NOT NULL" ist der Schutz dieser Funktion: Ein +// Eintrag, der nicht im Papierkorb liegt, wird nie angefasst — auch nicht, +// wenn jemand seinen Bezeichner ins Formular schmuggelt. +func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []string) (int, error) { + geloescht := 0 + err := s.tx(ctx, func(tx *sql.Tx) error { + for _, slug := range slugs { + res, err := tx.ExecContext(ctx, + `DELETE FROM entries WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`, + projectID, slug, + ) + if err != nil { + return fmt.Errorf("Eintrag %q konnte nicht endgültig gelöscht werden: %w", slug, err) + } + n, _ := res.RowsAffected() + geloescht += int(n) + } + return nil + }) + if err != nil { + return 0, err + } + return geloescht, nil +} + +// PurgeTrash räumt alle Einträge endgültig ab, die länger als bis aelterAls im +// Papierkorb liegen — über alle Projekte dieser Workspace-Datei. Gedacht für +// die tägliche Aufgabe in main.go, die auch das Protokoll aufräumt. +// +// Gibt die Bezeichner der entfernten Einträge je Projekt zurück, damit der +// Aufrufer jeden Fall im Protokoll vermerken kann. +func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]string, error) { + entfernt := map[string][]string{} + + err := s.tx(ctx, func(tx *sql.Tx) error { + // Erst lesen, was gleich verschwindet — nach dem DELETE wäre es weg. + rows, err := tx.QueryContext(ctx, + `SELECT project_id, slug FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls) + if err != nil { + return fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err) + } + for rows.Next() { + var projektID, slug string + if err := rows.Scan(&projektID, &slug); err != nil { + rows.Close() + return err + } + entfernt[projektID] = append(entfernt[projektID], slug) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + if _, err := tx.ExecContext(ctx, + `DELETE FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls, + ); err != nil { + return fmt.Errorf("Papierkorb konnte nicht aufgeräumt werden: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + return entfernt, nil +} diff --git a/internal/store/papierkorb_test.go b/internal/store/papierkorb_test.go new file mode 100644 index 0000000..0f76140 --- /dev/null +++ b/internal/store/papierkorb_test.go @@ -0,0 +1,296 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +// TestPapierkorbUnsichtbar ist der wichtigste Test des Papierkorbs: Jeder +// lesende Weg muss einen gelöschten Eintrag ausblenden. Kommt später eine +// neue Abfrage auf entries dazu, gehört sie hier mit hinein (siehe +// nichtImPapierkorb in entries.go). +// +// Aufbau: "lebt" verweist auf "weg", "weg" verweist auf "lebt". Beide tragen +// das Schlagwort "gemeinsam", "weg" zusätzlich "nur-weg". Dann wird "weg" +// gelöscht. +func TestPapierkorbUnsichtbar(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + lebt, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "Lebt", + Content: "siehe [[weg]]", Tags: []string{"gemeinsam"}, Source: SourceHuman}) + weg, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", + Content: "siehe [[lebt]]", Tags: []string{"gemeinsam", "nur-weg"}, Source: SourceHuman, Curated: true}) + + if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "agent-1"); err != nil { + t.Fatal(err) + } + + // t.Run fasst jeden Weg als eigenen Untertest — schlägt einer fehl, steht + // sein Name in der Ausgabe. + t.Run("EntryBySlug", func(t *testing.T) { + if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) { + t.Errorf("Fehler = %v, erwartet ErrNotFound", err) + } + }) + + t.Run("ListEntries", func(t *testing.T) { + for name, filter := range map[string]EntryFilter{ + "ohne Filter": {}, + "mit Schlagwort": {Tag: "nur-weg"}, + "nur kuratierte": {Curated: true}, + } { + liste, err := s.ListEntries(ctx, p.ID, filter) + if err != nil { + t.Fatal(err) + } + for _, e := range liste { + if e.Slug == "weg" { + t.Errorf("%s: gelöschter Eintrag in der Liste", name) + } + } + } + }) + + t.Run("AllEntries (Suchindex, Export)", func(t *testing.T) { + alle, _ := s.AllEntries(ctx, p.ID) + if len(alle) != 1 || alle[0].Slug != "lebt" { + t.Errorf("AllEntries = %v, erwartet nur „lebt“", alle) + } + }) + + t.Run("CountEntries", func(t *testing.T) { + if n, _ := s.CountEntries(ctx, p.ID); n != 1 { + t.Errorf("%d Einträge gezählt, erwartet 1", n) + } + }) + + t.Run("ListTags", func(t *testing.T) { + tags, _ := s.ListTags(ctx, p.ID) + for _, tc := range tags { + if tc.Tag == "nur-weg" { + t.Error("Schlagwort, das nur ein gelöschter Eintrag trägt, wird noch angezeigt") + } + if tc.Tag == "gemeinsam" && tc.Count != 1 { + t.Errorf("„gemeinsam“ zählt %d, erwartet 1", tc.Count) + } + } + }) + + t.Run("Backlinks", func(t *testing.T) { + // "weg" verweist auf "lebt" — dieser Rückverweis muss verschwinden. + links, _ := s.Backlinks(ctx, p.ID, "lebt") + if len(links) != 0 { + t.Errorf("Rückverweise = %v, erwartet keine", links) + } + }) + + t.Run("LinkGraph", func(t *testing.T) { + knoten, kanten, _ := s.LinkGraph(ctx, p.ID) + for _, k := range knoten { + // "lebt" verweist weiter auf "weg" — der Knoten darf bleiben, aber + // nur als gestrichelter "gibt es nicht". + if k.Slug == "weg" && k.Exists { + t.Error("gelöschter Eintrag erscheint im Graph als vorhanden") + } + } + for _, e := range kanten { + if e.From == "weg" { + t.Error("Verweis eines gelöschten Eintrags erscheint im Graph") + } + } + }) + + t.Run("BulkUpdateTags", func(t *testing.T) { + geaendert, _ := s.BulkUpdateTags(ctx, p.ID, []string{lebt.ID, weg.ID}, []string{"neu"}, nil) + if len(geaendert) != 1 || geaendert[0].Slug != "lebt" { + t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", geaendert) + } + }) +} + +func TestListTrash(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + for _, slug := range []string{"a", "b", "c"} { + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Source: SourceHuman}) + } + s.MoveEntryToTrash(ctx, p.ID, "a", "agent-1") + s.MoveEntryToTrash(ctx, p.ID, "b", "tom") + + alle, _ := s.ListTrash(ctx, p.ID, "") + if len(alle) != 2 { + t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(alle)) + } + + vomAgenten, _ := s.ListTrash(ctx, p.ID, "agent-1") + if len(vomAgenten) != 1 || vomAgenten[0].Slug != "a" || vomAgenten[0].DeletedBy != "agent-1" { + t.Errorf("Filter nach Zugang: %+v", vomAgenten) + } +} + +func TestRestoreFromTrash(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + fremd, _ := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "") + + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "Inhalt", + Tags: []string{"x"}, Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: fremd.ID, Slug: "f", Title: "F", Source: SourceHuman}) + s.MoveEntryToTrash(ctx, p.ID, "e", "tom") + s.MoveEntryToTrash(ctx, fremd.ID, "f", "tom") + + // "f" liegt im Papierkorb eines ANDEREN Projekts — darf nicht mit. + zurueck, err := s.RestoreFromTrash(ctx, p.ID, []string{"e", "f", "gibt-es-nicht"}) + if err != nil { + t.Fatal(err) + } + if len(zurueck) != 1 || zurueck[0].Slug != "e" { + t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", zurueck) + } + if len(zurueck[0].Tags) != 1 || zurueck[0].Tags[0] != "x" { + t.Errorf("Schlagworte nach Wiederherstellen = %v", zurueck[0].Tags) + } + + e, err := s.EntryBySlug(ctx, p.ID, "e") + if err != nil || e.Content != "Inhalt" || e.Version != 1 { + t.Errorf("nach Wiederherstellen: %+v, %v — Inhalt und Fassung müssen unverändert sein", e, err) + } + if liste, _ := s.ListTrash(ctx, fremd.ID, ""); len(liste) != 1 { + t.Error("der Eintrag des fremden Projekts wurde angefasst") + } +} + +// TestPutEntryRevivesFromTrash: Wer einen Bezeichner aus dem Papierkorb neu +// beschreibt, holt den Eintrag zurück und überschreibt ihn. Der gelöschte +// Stand bleibt im Verlauf. +func TestPutEntryRevivesFromTrash(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Alt", Content: "gelöscht", Source: SourceHuman}) + s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1") + + neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "frisch", Source: SourceAgent}) + if err != nil { + t.Fatal(err) + } + if !neu.RestoredFromTrash { + t.Error("RestoredFromTrash nicht gesetzt") + } + if neu.Version != 2 { + t.Errorf("Fassung %d, erwartet 2", neu.Version) + } + + if _, err := s.EntryBySlug(ctx, p.ID, "e"); err != nil { + t.Errorf("Eintrag nach dem Überschreiben nicht sichtbar: %v", err) + } + if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 0 { + t.Error("Eintrag liegt nach dem Überschreiben noch im Papierkorb") + } + alt, err := s.EntryVersionByNumber(ctx, neu.ID, 1) + if err != nil || alt.Content != "gelöscht" { + t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, alt.Content) + } + + // Ein normaler Schreibvorgang danach meldet nichts mehr vom Papierkorb. + weiter, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent}) + if weiter.RestoredFromTrash { + t.Error("RestoredFromTrash bei einem gewöhnlichen Update gesetzt") + } +} + +// TestPutEntryConflictWhenDeletedMeanwhile: Wer einen Stand gelesen hat und +// ihn fortschreiben will, während jemand anderes gelöscht hat, bekommt einen +// Konflikt — die Löschung wird nicht unbemerkt rückgängig gemacht. +func TestPutEntryConflictWhenDeletedMeanwhile(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "1", Source: SourceHuman}) + s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1") + + _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "2", + Source: SourceHuman, ExpectedVersion: 1}) + if !errors.Is(err, ErrConflict) { + t.Errorf("Fehler = %v, erwartet ErrConflict", err) + } + if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 { + t.Error("der Eintrag wurde trotz Konflikt aus dem Papierkorb geholt") + } +} + +// TestPurgeFromTrashOnlyTrashed: Endgültig löschen fasst nie einen Eintrag an, +// der nicht im Papierkorb liegt. +func TestPurgeFromTrashOnlyTrashed(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "T", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman}) + s.MoveEntryToTrash(ctx, p.ID, "weg", "tom") + + n, err := s.PurgeFromTrash(ctx, p.ID, []string{"lebt", "weg"}) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("%d endgültig gelöscht, erwartet 1", n) + } + if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil { + t.Error("ein Eintrag außerhalb des Papierkorbs wurde endgültig gelöscht") + } + + // Der Bezeichner ist danach wieder frei: Neu anlegen beginnt bei Fassung 1. + neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman}) + if err != nil || neu.Version != 1 || neu.RestoredFromTrash { + t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", neu, err) + } +} + +// TestPurgeTrashAfterRetention: Die tägliche Aufgabe entfernt nur, was länger +// als die Frist im Papierkorb liegt. +func TestPurgeTrashAfterRetention(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + for _, slug := range []string{"alt", "frisch", "lebt"} { + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Source: SourceHuman}) + } + s.MoveEntryToTrash(ctx, p.ID, "alt", "tom") + s.MoveEntryToTrash(ctx, p.ID, "frisch", "tom") + + // "alt" künstlich vor 31 Tage zurückdatieren — die Tests sollen nicht + // einen Monat warten. + vorEinemMonat := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano) + if _, err := s.schreiben.ExecContext(ctx, + `UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, vorEinemMonat); err != nil { + t.Fatal(err) + } + + grenze := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano) + entfernt, err := s.PurgeTrash(ctx, grenze) + if err != nil { + t.Fatal(err) + } + if len(entfernt[p.ID]) != 1 || entfernt[p.ID][0] != "alt" { + t.Errorf("entfernt: %v, erwartet nur „alt“", entfernt) + } + + if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 || liste[0].Slug != "frisch" { + t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", liste) + } + if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil { + t.Error("ein normaler Eintrag wurde beim Aufräumen entfernt") + } +} diff --git a/internal/store/projects.go b/internal/store/projects.go index 007e888..13db1c5 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -61,44 +61,11 @@ func Slugify(text string) string { return slug } -// ── Workspace ──────────────────────────────────────────────────────────────── - -// EnsureWorkspace legt beim ersten Start den Standard-Workspace an und gibt ihn -// zurück. Existiert bereits einer, wird dieser verwendet. -// -// Solange es nur einen gibt, braucht niemand ihn zu verwalten — die Struktur ist -// aber vorhanden, falls später nach Abteilungen getrennt werden soll. -func (s *Store) EnsureWorkspace(ctx context.Context, name string) (Workspace, error) { - var w Workspace - var createdAt string - - err := s.lesen.QueryRowContext(ctx, - `SELECT id, name, created_at FROM workspaces ORDER BY created_at LIMIT 1`, - ).Scan(&w.ID, &w.Name, &createdAt) - - if err == nil { - w.CreatedAt = parseTime(createdAt) - return w, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return Workspace{}, fmt.Errorf("Workspace konnte nicht gelesen werden: %w", err) - } - - w = Workspace{ID: NewID(), Name: name} - jetzt := NowString() - - if _, err := s.schreiben.ExecContext(ctx, - `INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)`, - w.ID, w.Name, jetzt, - ); err != nil { - return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err) - } - - w.CreatedAt = parseTime(jetzt) - return w, nil -} - // ── Projekte ───────────────────────────────────────────────────────────────── +// +// Die Workspace-Verwaltung selbst (EnsureDefaultWorkspace, CreateWorkspace, …) +// lebt in control.go — sie arbeitet auf der zentralen Kontroll-Datenbank, nicht +// auf dieser Workspace-Datei. // CreateProject legt ein neues Projekt an. func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, description string) (Project, error) { diff --git a/internal/store/store.go b/internal/store/store.go index 06a993b..38e648f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -35,10 +35,14 @@ import ( _ "modernc.org/sqlite" // meldet sich als Treiber "sqlite" an ) -// migrationsFS enthält die .sql-Dateien, die das Schema aufbauen. +// migrationsFS enthält die .sql-Dateien, die das Schema einer Workspace-Datei +// aufbauen (Projekte, Einträge, Keys, Protokoll — siehe internal/store/migrations). // Durch das Einbetten wandern sie mit ins Binary — beim Start ist kein // zusätzlicher Ordner nötig. // +// Das Schema der zentralen Kontroll-Datenbank (Workspaces, Key-Routing) liegt +// getrennt in migrations_control — siehe control.go. +// //go:embed migrations/*.sql var migrationsFS embed.FS @@ -67,9 +71,24 @@ type Store struct { schreiben *sql.DB // genau eine Verbindung — alles, was verändert } -// Open öffnet die Datenbankdatei, setzt die nötigen Einstellungen und bringt das -// Schema auf den aktuellen Stand. +// Open öffnet die Workspace-Datenbankdatei, setzt die nötigen Einstellungen und +// bringt das Schema auf den aktuellen Stand. +// +// Jede Workspace-Datei bekommt dieselbe Behandlung — unabhängig davon, ob es +// sich um die einzige Abteilung handelt oder eine von vierzig. func Open(path string) (*Store, error) { + lesen, schreiben, err := openPool(path, migrationsFS, "migrations") + if err != nil { + return nil, err + } + return &Store{lesen: lesen, schreiben: schreiben}, nil +} + +// openPool öffnet eine Datenbankdatei mit getrenntem Lese- und Schreibpool und +// bringt ihr Schema auf den aktuellen Stand. Verwendet von Open +// (Workspace-Dateien) und OpenControl (Kontroll-Datenbank) — beide brauchen +// exakt dieselben Einstellungen, nur unterschiedliche Dateien und Migrationen. +func openPool(path string, migrationsFS embed.FS, migrationsDir string) (lesen, schreiben *sql.DB, err error) { // Die Parameter in der URL setzen drei Dinge, ohne die SQLite unter // gleichzeitigen Zugriffen unangenehm wird: // @@ -95,40 +114,39 @@ func Open(path string) (*Store, error) { // statt erst beim ersten Schreibbefehl. Ohne das kann eine Transaktion // mitten drin auf die Sperre laufen — und dort hilft busy_timeout nicht mehr, // weil SQLite den Wartenden nicht zurücksetzen kann, ohne die Transaktion - // abzubrechen. Genau daran scheiterten die 296 Schreibvorgänge oben. - schreiben, err := sql.Open("sqlite", dsn+"&_txlock=immediate") + // abzubrechen. Genau daran scheiterten 296 von 320 gleichzeitigen + // Schreibvorgängen in einer früheren Messung (siehe AUSBAUSTUFEN.md). + schreiben, err = sql.Open("sqlite", dsn+"&_txlock=immediate") if err != nil { - return nil, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err) + return nil, nil, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err) } schreiben.SetMaxOpenConns(1) if err := schreiben.Ping(); err != nil { schreiben.Close() - return nil, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err) + return nil, nil, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err) } - s := &Store{schreiben: schreiben} - if err := s.migrate(); err != nil { + if err := runMigrations(schreiben, migrationsFS, migrationsDir); err != nil { schreiben.Close() - return nil, err + return nil, nil, err } // Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits. - lesen, err := sql.Open("sqlite", dsn) + lesen, err = sql.Open("sqlite", dsn) if err != nil { schreiben.Close() - return nil, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err) + return nil, nil, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err) } lesen.SetMaxOpenConns(maxLeseverbindungen) if err := lesen.Ping(); err != nil { schreiben.Close() lesen.Close() - return nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err) + return nil, nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err) } - s.lesen = lesen - return s, nil + return lesen, schreiben, nil } // maxLeseverbindungen begrenzt die gleichzeitigen Lesezugriffe. @@ -147,20 +165,25 @@ func (s *Store) Close() error { return fehlerLesen } -// migrate wendet alle noch nicht ausgeführten Migrationen an. +// runMigrations wendet alle noch nicht ausgeführten Migrationen aus fsys/dir +// über die gegebene Schreibverbindung an. // // # Wie das funktioniert // -// Die Dateien in migrations/ sind durchnummeriert (001_…, 002_…). Eine Tabelle -// merkt sich, welche davon schon gelaufen sind. Beim Start wird die Liste -// durchgegangen und alles Fehlende ausgeführt — in einer Transaktion pro Datei, -// damit eine halb angewandte Migration nicht möglich ist. +// Die Dateien sind durchnummeriert (001_…, 002_…). Eine Tabelle merkt sich, +// welche davon schon gelaufen sind. Beim Start wird die Liste durchgegangen +// und alles Fehlende ausgeführt — in einer Transaktion pro Datei, damit eine +// halb angewandte Migration nicht möglich ist. // -// Das sind bewusst nur ein paar Zeilen statt einer Bibliothek: Für ein Schema -// dieser Größe ist der Mechanismus leicht zu überblicken, und wer ihn versteht, -// versteht auch, was beim Start mit seiner Datenbank passiert. -func (s *Store) migrate() error { - _, err := s.schreiben.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( +// Das sind bewusst nur ein paar Zeilen statt einer Bibliothek: Für Schemata +// dieser Größe ist der Mechanismus leicht zu überblicken, und wer ihn +// versteht, versteht auch, was beim Start mit seiner Datenbank passiert. +// +// Jede Datenbankdatei (jede Workspace-Datei ebenso wie die Kontroll-Datenbank) +// führt ihre eigene schema_migrations-Tabelle — sie kennt nur die Migrationen +// aus dem für sie zuständigen Verzeichnis. +func runMigrations(schreiben *sql.DB, fsys embed.FS, dir string) error { + _, err := schreiben.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL )`) @@ -168,12 +191,12 @@ func (s *Store) migrate() error { return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err) } - angewandt, err := s.appliedMigrations() + angewandt, err := appliedMigrations(schreiben) if err != nil { return err } - dateien, err := migrationFiles() + dateien, err := migrationFiles(fsys, dir) if err != nil { return err } @@ -183,12 +206,12 @@ func (s *Store) migrate() error { continue } - inhalt, err := migrationsFS.ReadFile("migrations/" + name) + inhalt, err := fsys.ReadFile(dir + "/" + name) if err != nil { return fmt.Errorf("Migration %s konnte nicht gelesen werden: %w", name, err) } - if err := s.applyMigration(name, string(inhalt)); err != nil { + if err := applyMigration(schreiben, name, string(inhalt)); err != nil { return err } } @@ -197,8 +220,8 @@ func (s *Store) migrate() error { } // appliedMigrations liest die Namen der bereits ausgeführten Migrationen. -func (s *Store) appliedMigrations() (map[string]bool, error) { - rows, err := s.schreiben.Query(`SELECT name FROM schema_migrations`) +func appliedMigrations(schreiben *sql.DB) (map[string]bool, error) { + rows, err := schreiben.Query(`SELECT name FROM schema_migrations`) if err != nil { return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err) } @@ -216,8 +239,8 @@ func (s *Store) appliedMigrations() (map[string]bool, error) { } // migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge. -func migrationFiles() ([]string, error) { - eintraege, err := migrationsFS.ReadDir("migrations") +func migrationFiles(fsys embed.FS, dir string) ([]string, error) { + eintraege, err := fsys.ReadDir(dir) if err != nil { return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err) } @@ -236,8 +259,8 @@ func migrationFiles() ([]string, error) { } // applyMigration führt eine einzelne Migration in einer Transaktion aus. -func (s *Store) applyMigration(name, sqlText string) error { - tx, err := s.schreiben.Begin() +func applyMigration(schreiben *sql.DB, name, sqlText string) error { + tx, err := schreiben.Begin() if err != nil { return fmt.Errorf("Transaktion für %s konnte nicht gestartet werden: %w", name, err) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5440592..2de3562 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -28,15 +28,21 @@ func testStore(t *testing.T) *Store { return s } +// testWorkspace liefert eine Workspace-ID für Tests. +// +// Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr (die lebt in der +// zentralen Kontroll-Datenbank, siehe control.go) — für Tests reicht deshalb +// eine beliebige ID, ohne dass eine echte ControlStore-Instanz nötig wäre. +func testWorkspace() Workspace { + return Workspace{ID: NewID(), Name: "Test"} +} + // testProject legt Workspace und Projekt an — die Grundlage fast aller Tests. func testProject(t *testing.T, s *Store) Project { t.Helper() ctx := context.Background() - ws, err := s.EnsureWorkspace(ctx, "Test") - if err != nil { - t.Fatalf("Workspace: %v", err) - } + ws := testWorkspace() p, err := s.CreateProject(ctx, ws.ID, "test", "Testprojekt", "") if err != nil { t.Fatalf("Projekt: %v", err) @@ -80,7 +86,7 @@ func TestMigrationBestandsdaten(t *testing.T) { t.Fatal(err) } ctx := context.Background() - ws, _ := s.EnsureWorkspace(ctx, "M") + ws := testWorkspace() p, _ := s.CreateProject(ctx, ws.ID, "alt", "Alt", "") s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel", Content: "x", Tags: []string{"ops"}, Source: SourceHuman}) @@ -214,29 +220,14 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) { } // ── Workspace und Projekte ─────────────────────────────────────────────────── - -func TestEnsureWorkspaceIsStable(t *testing.T) { - s := testStore(t) - ctx := context.Background() - - erst, err := s.EnsureWorkspace(ctx, "Intern") - if err != nil { - t.Fatal(err) - } - zweit, err := s.EnsureWorkspace(ctx, "Anderer Name") - if err != nil { - t.Fatal(err) - } - - if erst.ID != zweit.ID { - t.Error("EnsureWorkspace hat einen zweiten Workspace angelegt statt den bestehenden zu nehmen") - } -} +// +// Die Stabilität von EnsureDefaultWorkspace wird in control_test.go geprüft — +// die Funktion gehört zur zentralen Kontroll-Datenbank, nicht zu dieser Datei. func TestCreateProjectRejectsBadSlug(t *testing.T) { s := testStore(t) ctx := context.Background() - ws, _ := s.EnsureWorkspace(ctx, "Test") + ws := testWorkspace() for _, slug := range []string{"", "Groß", "mit leer", "../raus", "a/b", strings.Repeat("x", 65)} { if _, err := s.CreateProject(ctx, ws.ID, slug, "X", ""); err == nil { @@ -248,7 +239,7 @@ func TestCreateProjectRejectsBadSlug(t *testing.T) { func TestCreateProjectRejectsDuplicateSlug(t *testing.T) { s := testStore(t) ctx := context.Background() - ws, _ := s.EnsureWorkspace(ctx, "Test") + ws := testWorkspace() if _, err := s.CreateProject(ctx, ws.ID, "doppelt", "Erst", ""); err != nil { t.Fatal(err) @@ -558,7 +549,7 @@ func TestPutEntryValidation(t *testing.T) { func TestEntriesAreProjectScoped(t *testing.T) { s := testStore(t) ctx := context.Background() - ws, _ := s.EnsureWorkspace(ctx, "Test") + ws := testWorkspace() a, _ := s.CreateProject(ctx, ws.ID, "projekt-a", "A", "") b, _ := s.CreateProject(ctx, ws.ID, "projekt-b", "B", "") @@ -626,20 +617,20 @@ func TestListTagsCounts(t *testing.T) { } } -func TestDeleteEntry(t *testing.T) { +func TestMoveEntryToTrash(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman}) - if err := s.DeleteEntry(ctx, p.ID, "weg"); err != nil { + if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); err != nil { t.Fatal(err) } if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) { - t.Error("der Eintrag existiert noch") + t.Error("der Eintrag ist nach dem Löschen noch sichtbar") } - if err := s.DeleteEntry(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) { + if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); !errors.Is(err, ErrNotFound) { t.Error("zweimaliges Löschen meldete keinen Fehler") } } @@ -806,3 +797,258 @@ func TestNormalizeTags(t *testing.T) { t.Errorf("NormalizeTags = %v, erwartet [docs ops]", got) } } + +func TestBulkUpdateTags(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + a, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", + Tags: []string{"alt"}, Source: SourceHuman}) + b, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", + Tags: []string{"alt", "bleibt"}, Source: SourceHuman}) + // Dritter Eintrag bleibt unangetastet — zur Probe, dass BulkUpdateTags nur + // trifft, wer ausgewählt wurde. + c, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", + Tags: []string{"alt"}, Source: SourceHuman}) + + geaendert, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"}) + if err != nil { + t.Fatal(err) + } + if len(geaendert) != 2 { + t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(geaendert)) + } + + neuA, _ := s.EntryBySlug(ctx, p.ID, "a") + if !stringSlicesEqual(neuA.Tags, []string{"neu"}) { + t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags) + } + if neuA.Version != 2 { + t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", neuA.Version) + } + + neuB, _ := s.EntryBySlug(ctx, p.ID, "b") + if !stringSlicesEqual(neuB.Tags, []string{"bleibt", "neu"}) { + t.Errorf("Tags von B = %v, erwartet [bleibt neu]", neuB.Tags) + } + + unveraendertC, _ := s.EntryBySlug(ctx, p.ID, "c") + if !stringSlicesEqual(unveraendertC.Tags, []string{"alt"}) { + t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unveraendertC.Tags) + } + if unveraendertC.Version != c.Version { + t.Error("Version von C wurde erhöht, obwohl C nicht ausgewählt war") + } +} + +func TestBulkUpdateTagsIgnoresForeignProjectIDs(t *testing.T) { + s := testStore(t) + ctx := context.Background() + ws := testWorkspace() + + a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "") + b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "") + + fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "fremd", Title: "Fremd", + Tags: []string{"x"}, Source: SourceHuman}) + + // Aufruf mit dem Projekt A, aber einer ID aus Projekt B — darf nichts + // bewirken, sonst könnte ein untergeschobenes Formularfeld projektübergreifend + // Einträge verändern. + geaendert, err := s.BulkUpdateTags(ctx, a.ID, []string{fremd.ID}, []string{"neu"}, nil) + if err != nil { + t.Fatal(err) + } + if len(geaendert) != 0 { + t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(geaendert)) + } + + unveraendert, _ := s.EntryBySlug(ctx, b.ID, "fremd") + if !stringSlicesEqual(unveraendert.Tags, []string{"x"}) { + t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unveraendert.Tags) + } +} + +// ── Änderungshistorie ──────────────────────────────────────────────────────── + +func TestPutEntryRecordsHistory(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + erste, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Erste Fassung", + Content: "Inhalt 1", Tags: []string{"a"}, Source: SourceHuman, Author: "tom"}) + if err != nil { + t.Fatal(err) + } + + // Beim erstmaligen Anlegen gibt es noch nichts zu sichern. + historie, err := s.EntryVersions(ctx, erste.ID) + if err != nil { + t.Fatal(err) + } + if len(historie) != 0 { + t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(historie)) + } + + if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Zweite Fassung", + Content: "Inhalt 2", Tags: []string{"b"}, Source: SourceHuman, Author: "tom"}); err != nil { + t.Fatal(err) + } + if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Dritte Fassung", + Content: "Inhalt 3", Tags: []string{"c"}, Source: SourceHuman, Author: "tom"}); err != nil { + t.Fatal(err) + } + + historie, err = s.EntryVersions(ctx, erste.ID) + if err != nil { + t.Fatal(err) + } + if len(historie) != 2 { + t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(historie)) + } + // Neueste zuerst. + if historie[0].Version != 2 || historie[0].Title != "Zweite Fassung" { + t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", historie[0].Version, historie[0].Title) + } + if historie[1].Version != 1 || historie[1].Title != "Erste Fassung" { + t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", historie[1].Version, historie[1].Title) + } + if historie[1].Content != "Inhalt 1" || !stringSlicesEqual(historie[1].Tags, []string{"a"}) { + t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", historie[1].Content, historie[1].Tags) + } + + fassung1, err := s.EntryVersionByNumber(ctx, erste.ID, 1) + if err != nil { + t.Fatal(err) + } + if fassung1.Content != "Inhalt 1" { + t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", fassung1.Content) + } + + if _, err := s.EntryVersionByNumber(ctx, erste.ID, 99); !errors.Is(err, ErrNotFound) { + t.Errorf("unbekannte Fassung: err = %v, erwartet ErrNotFound", err) + } +} + +func TestBulkUpdateTagsRecordsHistory(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + e, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", + Content: "Inhalt", Tags: []string{"alt"}, Source: SourceHuman}) + + if _, err := s.BulkUpdateTags(ctx, p.ID, []string{e.ID}, []string{"neu"}, []string{"alt"}); err != nil { + t.Fatal(err) + } + + historie, err := s.EntryVersions(ctx, e.ID) + if err != nil { + t.Fatal(err) + } + if len(historie) != 1 { + t.Fatalf("%d Fassungen, erwartet 1", len(historie)) + } + if !stringSlicesEqual(historie[0].Tags, []string{"alt"}) { + t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", historie[0].Tags) + } +} + +// TestTrashKeepsHistoryPurgeRemovesIt: Der Papierkorb behält den Verlauf, +// erst endgültiges Löschen entfernt ihn (CASCADE). +func TestTrashKeepsHistoryPurgeRemovesIt(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + e, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "1", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "2", Source: SourceHuman}) + + if err := s.MoveEntryToTrash(ctx, p.ID, "e", "tom"); err != nil { + t.Fatal(err) + } + if historie, _ := s.EntryVersions(ctx, e.ID); len(historie) != 1 { + t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(historie)) + } + + if n, err := s.PurgeFromTrash(ctx, p.ID, []string{"e"}); err != nil || n != 1 { + t.Fatalf("PurgeFromTrash: %d, %v", n, err) + } + + historie, err := s.EntryVersions(ctx, e.ID) + if err != nil { + t.Fatal(err) + } + if len(historie) != 0 { + t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(historie)) + } +} + +// ── Graph ──────────────────────────────────────────────────────────────────── + +func TestLinkGraph(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", + Content: "verweist auf [[b]] und [[fehlt]]", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", + Content: "verweist zurück auf [[a]]", Source: SourceHuman}) + // Eintrag ohne jeden Verweis — muss trotzdem als Knoten auftauchen. + s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", + Content: "allein", Source: SourceHuman}) + + knoten, kanten, err := s.LinkGraph(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + + if len(knoten) != 4 { + t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(knoten)) + } + + nachSlug := make(map[string]GraphNode, len(knoten)) + for _, k := range knoten { + nachSlug[k.Slug] = k + } + + if !nachSlug["a"].Exists || !nachSlug["b"].Exists || !nachSlug["c"].Exists { + t.Error("vorhandene Einträge sollten Exists = true tragen") + } + fehlt, ok := nachSlug["fehlt"] + if !ok { + t.Fatal("Verweis auf einen nicht existierenden Bezeichner erzeugte keinen Knoten") + } + if fehlt.Exists { + t.Error("Exists = true für einen Bezeichner ohne Eintrag") + } + + if len(kanten) != 3 { + t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(kanten)) + } +} + +func TestLinkGraphIsProjectScoped(t *testing.T) { + s := testStore(t) + ctx := context.Background() + ws := testWorkspace() + + a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "") + b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "") + + s.PutEntry(ctx, EntryWrite{ProjectID: a.ID, Slug: "geheim", Title: "Geheim", + Content: "nur in A", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "anderes", Title: "Anderes", + Content: "nur in B", Source: SourceHuman}) + + knoten, _, err := s.LinkGraph(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if len(knoten) != 1 || knoten[0].Slug != "geheim" { + t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", knoten) + } +} diff --git a/internal/web/context.go b/internal/web/context.go index 3831f54..ed63487 100644 --- a/internal/web/context.go +++ b/internal/web/context.go @@ -8,6 +8,7 @@ import ( "munin/internal/auth" "munin/internal/store" + "munin/internal/workspace" ) // Session beschreibt, wer gerade zugreift. @@ -15,12 +16,17 @@ import ( // Es gibt zwei Ausprägungen: // // - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf -// jedes Projekt zugreifen. Key und Project sind leer. +// jedes Projekt eines gewählten Workspace zugreifen. Key und Project sind +// leer. WorkspaceID ist leer, solange noch kein Workspace gewählt wurde — +// eine solche Sitzung darf ausschließlich die Workspace-Auswahl aufrufen +// (siehe requireWorkspace in middleware.go). // - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem -// Projekt gehört. Was er darf, sagt Role. +// Projekt in genau einem Workspace gehört. Was er darf, sagt Role. type Session struct { Subject string // für die CSRF-Token-Berechnung IsPlatformAdmin bool + WorkspaceID string + WorkspaceName string // nur beim Plattform-Admin gefüllt, für den Umschalter in der Kopfleiste Key store.APIKey Project store.Project Role auth.Role @@ -67,6 +73,7 @@ const ( sessionContextKey contextKey = iota projectContextKey aktorContextKey + workspaceContextKey ) // aktorHalter ist ein veränderbarer Platz im Context, in den die @@ -127,6 +134,37 @@ func ProjectFrom(r *http.Request) (store.Project, bool) { return p, ok } +// withWorkspace legt den aufgelösten Workspace (Datenbank + Suchindex) der +// aktuellen Sitzung im Request-Context ab. +// +// Das übernimmt requireSession bzw. APIAuth — für Sitzungen ohne gewählten +// Workspace (Plattform-Admin vor der Workspace-Auswahl) bleibt der Context +// ohne Eintrag, siehe WorkspaceFrom. +func withWorkspace(ctx context.Context, w *workspace.Workspace) context.Context { + return context.WithValue(ctx, workspaceContextKey, w) +} + +// WorkspaceFrom liest den Workspace der aktuellen Anfrage. +// Ohne durchlaufene Auth-Middleware oder ohne gewählten Workspace ist ok false. +func WorkspaceFrom(r *http.Request) (*workspace.Workspace, bool) { + return WorkspaceFromContext(r.Context()) +} + +// WorkspaceFromContext liest den Workspace direkt aus einem Context. +// +// Wie bei SessionFromContext: Die MCP-Werkzeuge bekommen vom SDK nur den +// Context, keinen *http.Request. +func WorkspaceFromContext(ctx context.Context) (*workspace.Workspace, bool) { + w, ok := ctx.Value(workspaceContextKey).(*workspace.Workspace) + return w, ok +} + +// WithWorkspaceForTest legt einen Workspace im Context ab. +// Siehe WithSessionForTest — derselbe Grund, derselbe Umweg. +func WithWorkspaceForTest(ctx context.Context, w *workspace.Workspace) context.Context { + return withWorkspace(ctx, w) +} + // SessionFromContext liest die Sitzung direkt aus einem Context. // // Die MCP-Werkzeuge bekommen vom SDK nur den Context übergeben, keinen diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index 14ae6a1..bbd659c 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -3,10 +3,12 @@ package web import ( "errors" "fmt" + "log" "net/http" "net/url" "strconv" "strings" + "time" "munin/internal/auth" "munin/internal/store" @@ -22,6 +24,11 @@ type projectListItem struct { Project store.Project Entries int + // Usage sind die Kennzahlen der Nutzungsübersicht (AUSBAUSTUFEN.md 4.6), + // hier als Spalten: So fällt dem Plattform-Admin auf, welches Team Munin + // gar nicht benutzt. + Usage store.UsageSummary + // Limit ist die geltende Obergrenze für schreibende MCP-Aufrufe je Stunde, // Frei der Rest im laufenden Fenster. Limit 0 heißt "unbegrenzt". // @@ -39,7 +46,9 @@ type projectListItem struct { // handleProjectList zeigt alle Projekte — nur für den Plattform-Admin. func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { - projekte, err := s.store.ListProjects(r.Context(), s.workspaceID) + ws, sess := s.workspaceAndSession(r) + + projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Projekte konnten nicht geladen werden", err) return @@ -61,6 +70,8 @@ func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { // handleProjectCreate legt ein neues Projekt an. func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) { + ws, sess := s.workspaceAndSession(r) + name := strings.TrimSpace(r.FormValue("name")) slug := strings.TrimSpace(r.FormValue("slug")) beschreibung := strings.TrimSpace(r.FormValue("description")) @@ -69,13 +80,13 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) { slug = store.Slugify(name) } - projekt, err := s.store.CreateProject(r.Context(), s.workspaceID, slug, name, beschreibung) + projekt, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, beschreibung) if err != nil { s.renderProjectListWithError(w, r, err.Error()) return } - s.store.LogAudit(r.Context(), store.AuditEvent{ + ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: projekt.ID, Actor: "platform-admin", Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), }) @@ -87,9 +98,10 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) { // handleProjectDelete löscht ein Projekt samt Einträgen und Keys. func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { + ws, _ := s.workspaceAndSession(r) id := r.FormValue("id") - projekt, err := s.store.ProjectByID(r.Context(), id) + projekt, err := ws.Store.ProjectByID(r.Context(), id) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return @@ -108,13 +120,13 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { return } - if err := s.store.DeleteProject(r.Context(), id); err != nil { + if err := ws.Store.DeleteProject(r.Context(), id); err != nil { s.serverError(w, "Projekt konnte nicht gelöscht werden", err) return } - s.search.Forget(id) - s.store.LogAudit(r.Context(), store.AuditEvent{ + ws.Search.Forget(id) + ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: id, Actor: "platform-admin", Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: projekt.Slug, @@ -125,7 +137,9 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { // renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung. func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, meldung string) { - projekte, err := s.store.ListProjects(r.Context(), s.workspaceID) + ws, sess := s.workspaceAndSession(r) + + projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Projekte konnten nicht geladen werden", err) return @@ -143,23 +157,30 @@ func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Reque }) } -// projectListItems reichert die Projekte mit Eintragszahl und Schreibbudget an. +// projectListItems reichert die Projekte mit Eintragszahl, Nutzung und +// Schreibbudget an. // // Eine eigene Funktion, weil die Übersicht an zwei Stellen gerendert wird — mit // und ohne Fehlermeldung. Ohne sie liefen beide Fassungen früher oder später // auseinander. func (s *Server) projectListItems(r *http.Request, projekte []store.Project) ([]projectListItem, error) { + ws, _ := s.workspaceAndSession(r) items := make([]projectListItem, 0, len(projekte)) - for _, p := range projekte { - anzahl, err := s.store.CountEntries(r.Context(), p.ID) - if err != nil { - return items, err - } + // Die Kennzahlen aller Projekte in einem Rutsch (zwei Abfragen), statt + // je Projekt einzeln nachzufragen — bei 40 Projekten wären das sonst 80. + // Die Eintragszahl steckt mit darin. + nutzung, err := ws.Store.UsageSummaries(r.Context(), "", time.Now()) + if err != nil { + return items, err + } + for _, p := range projekte { + u := nutzung[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch item := projectListItem{ Project: p, - Entries: anzahl, + Entries: u.Entries, + Usage: u, EigenesLimit: p.WriteLimitHourly, } if s.writeBudget != nil { @@ -194,6 +215,8 @@ func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) { // handleProjectEditSave speichert Name, Beschreibung und Obergrenze. func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { + ws, _ := s.workspaceAndSession(r) + projekt, ok := s.projectFromSlug(w, r) if !ok { return @@ -221,7 +244,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { return } - if err := s.store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{ + if err := ws.Store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{ Name: name, Description: beschreibung, WriteLimitHourly: limit, @@ -234,7 +257,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { // DASS jemand etwas geändert hat. Gerade bei der Obergrenze ist das der // interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine // Betriebsentscheidung. - s.store.LogAudit(r.Context(), store.AuditEvent{ + ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: projekt.ID, Actor: "platform-admin", Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: strings.Join(aenderungen, "; "), @@ -270,7 +293,9 @@ func projektAenderungen(alt store.Project, name, beschreibung string, limit int) // schon nur der Plattform-Admin unterwegs (siehe Routes), und der Bezeichner // steht unter einem anderen Platzhalter. func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) { - projekt, err := s.store.ProjectBySlug(r.Context(), s.workspaceID, r.PathValue("slug")) + ws, sess := s.workspaceAndSession(r) + + projekt, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, r.PathValue("slug")) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return store.Project{}, false @@ -312,6 +337,13 @@ type keyListData struct { // er hier genau einmal angezeigt. NewKey string NewKeyName string + + // ServerURL ist die Adresse dieses Servers für die fertigen + // Konfigurationsblöcke, etwa "https://munin.example.org". Dieselbe Funktion + // wie in der Hilfe (serverURL in handlers_hilfe.go) — gerade hier zählt es, + // weil der Key nur dieses eine Mal zu sehen ist und der Block ohne + // Nacharbeit kopierbar sein soll. + ServerURL string } // handleKeyList zeigt die Keys eines Projekts. @@ -321,8 +353,8 @@ func (s *Server) handleKeyList(w http.ResponseWriter, r *http.Request) { // handleKeyCreate erzeugt einen neuen Key und zeigt ihn genau einmal an. func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { + ws, sess := s.workspaceAndSession(r) project, _ := ProjectFrom(r) - sess, _ := SessionFrom(r) name := strings.TrimSpace(r.FormValue("name")) rolle := auth.Role(r.FormValue("role")) @@ -338,13 +370,23 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { return } - erzeugt, err := s.store.CreateAPIKey(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle)) + erzeugt, err := ws.Store.CreateAPIKey(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle)) if err != nil { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", err.Error()) return } - s.store.LogAudit(r.Context(), store.AuditEvent{ + // Der Routing-Index muss im selben Zug nachgezogen werden — sonst lässt sich + // der Key künftig nicht mehr seinem Workspace zuordnen (siehe + // AUSBAUSTUFEN.md 4.4). Schlägt das fehl, bleibt der Key in dieser + // Workspace-Datei trotzdem gültig; nur das schnelle Routing über mehrere + // Workspaces hinweg wäre für ihn kaputt. Deshalb kein Abbruch, nur ein + // Log-Eintrag zum Nachziehen. + if err := s.control.IndexKey(r.Context(), erzeugt.Hash, sess.WorkspaceID); err != nil { + log.Printf("Key %s konnte nicht im Routing-Index vermerkt werden: %v", erzeugt.ID, err) + } + + ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(), Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: erzeugt.Name + " (" + string(rolle) + ")", @@ -357,25 +399,29 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { // handleKeyRevoke zieht einen Key zurück. func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { + ws, sess := s.workspaceAndSession(r) project, _ := ProjectFrom(r) - sess, _ := SessionFrom(r) id := r.FormValue("id") // Nachsehen, ob der Key wirklich zu diesem Projekt gehört. Ohne diese // Prüfung könnte ein Projekt-Admin über eine untergeschobene ID den Key eines // fremden Projekts sperren. - key, err := s.store.APIKeyByID(r.Context(), id) + key, err := ws.Store.APIKeyByID(r.Context(), id) if err != nil || key.ProjectID != project.ID { http.NotFound(w, r) return } - if err := s.store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) { + if err := ws.Store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) { s.serverError(w, "Key konnte nicht zurückgezogen werden", err) return } - s.store.LogAudit(r.Context(), store.AuditEvent{ + if err := s.control.RemoveKeyIndex(r.Context(), key.Hash); err != nil { + log.Printf("Key %s konnte nicht aus dem Routing-Index entfernt werden: %v", id, err) + } + + ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: id, Actor: sess.ActorName(), Action: store.ActionKeyRevoked, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: key.Name, @@ -386,14 +432,16 @@ func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { // renderKeyList zeigt die Key-Verwaltung mit optionaler Meldung. func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status int, daten keyListData, hinweis, fehler string) { + ws, _ := s.workspaceAndSession(r) project, _ := ProjectFrom(r) - keys, err := s.store.ListAPIKeys(r.Context(), project.ID) + keys, err := ws.Store.ListAPIKeys(r.Context(), project.ID) if err != nil { s.serverError(w, "Keys konnten nicht geladen werden", err) return } daten.Keys = keys + daten.ServerURL = serverURL(r) s.render(w, r, "keys.html", status, pageData{ Title: "Zugänge – " + project.Name, @@ -427,9 +475,10 @@ func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) { // renderAudit lädt Ereignisse samt Klarnamen und rendert die Tabelle. func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter store.AuditFilter, global bool, titel string) { + ws, sess := s.workspaceAndSession(r) ctx := r.Context() - ereignisse, err := s.store.ListAudit(ctx, filter) + ereignisse, err := ws.Store.ListAudit(ctx, filter) if err != nil { s.serverError(w, "Protokoll konnte nicht geladen werden", err) return @@ -437,7 +486,7 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor // IDs in lesbare Namen übersetzen — in der Tabelle sollen keine rohen // Kennungen stehen. - projektNamen, err := s.store.ProjectNames(ctx, s.workspaceID) + projektNamen, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID) if err != nil { s.serverError(w, "Projektnamen konnten nicht geladen werden", err) return @@ -448,7 +497,7 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor projektIDs = append(projektIDs, id) } - keyNamen, err := s.store.KeyNames(ctx, projektIDs) + keyNamen, err := ws.Store.KeyNames(ctx, projektIDs) if err != nil { s.serverError(w, "Key-Namen konnten nicht geladen werden", err) return @@ -473,13 +522,28 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor // Der Export läuft ohnehin täglich (siehe main.go). Der Knopf hier ist für den // Fall gedacht, dass jemand unmittelbar vor einer Sicherung oder nach einer // größeren Änderung einen aktuellen Stand haben möchte. +// +// Exportiert wird nur der gerade gewählte Workspace — derselbe, dessen +// Projekte auf der Seite mit dem Knopf stehen. Der tägliche Lauf in main.go +// übernimmt alle. func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { if s.exporter == nil { http.Error(w, "Der Export ist nicht eingerichtet (EXPORT_DIR fehlt).", http.StatusNotFound) return } - stats, err := s.exporter.ExportAll(r.Context(), s.workspaceID) + ws, sess := s.workspaceAndSession(r) + + // Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung: Er + // landet in _workspace.md, und nach einem Umbenennen soll dort der neue + // stehen, nicht der beim Anmelden gemerkte. + eintrag, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) + if err != nil { + s.serverError(w, "Workspace konnte nicht gelesen werden", err) + return + } + + stats, err := s.exporter.ExportWorkspace(r.Context(), ws.Store, eintrag) if err != nil { s.serverError(w, "Export fehlgeschlagen", err) return diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 3c00b9a..0e38467 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -2,6 +2,7 @@ package web import ( "fmt" + "log" "net/http" "strconv" "time" @@ -15,6 +16,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { sess, _ := SessionFrom(r) if sess.IsPlatformAdmin { + if sess.WorkspaceID == "" { + http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther) + return + } http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) return } @@ -44,9 +49,9 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { } s.limiter.RecordFailure(ip) - s.store.LogAudit(r.Context(), store.AuditEvent{ - Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceWeb, IP: ip, - }) + // Ein ungültiger Key oder Klartext lässt sich keinem Workspace zuordnen + // — siehe der entsprechende Kommentar in APIAuth (middleware.go). + log.Printf("Ungültiger Anmeldeversuch von %s", ip) s.render(w, r, "login.html", http.StatusUnauthorized, pageData{ Title: "Anmelden", @@ -61,6 +66,11 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { } // resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück. +// +// Der Plattform-Admin bekommt hier noch keinen Workspace zugewiesen — das +// Subject ist zunächst nur "platform". Erst über /admin/workspaces wird ein +// Workspace gewählt und das Subject um dessen ID ergänzt (siehe +// handleWorkspaceSelect). func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { if key == "" { return "", false @@ -70,10 +80,7 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { // die Hashes, damit auch hier keine Zeichenkette Zeichen für Zeichen // verglichen wird und die Antwortzeit nichts verrät. if auth.HashKey(key) == s.adminTokenHash { - s.store.LogAudit(r.Context(), store.AuditEvent{ - Actor: "platform-admin", Action: store.ActionLoginSuccess, - Source: store.SourceWeb, IP: auth.ClientIP(r), - }) + log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r)) return auth.SubjectPlatformAdmin, true } @@ -81,17 +88,27 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { return "", false } - resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key)) + hash := auth.HashKey(key) + + workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash) + if err != nil { + return "", false + } + ws, err := s.workspaces.Get(workspaceID) + if err != nil { + return "", false + } + resolved, err := ws.Store.ResolveKeyHash(r.Context(), hash) if err != nil { return "", false } - s.store.LogAudit(r.Context(), store.AuditEvent{ + ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: resolved.Project.ID, APIKeyID: resolved.Key.ID, Actor: resolved.Key.Name, Action: store.ActionLoginSuccess, Source: store.SourceWeb, IP: auth.ClientIP(r), }) - return auth.SubjectKeyPrefix + resolved.Key.ID, true + return auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, true } // handleLogout löscht den Session-Cookie. diff --git a/internal/web/handlers_entries.go b/internal/web/handlers_entries.go index 9be5f6f..44aac2d 100644 --- a/internal/web/handlers_entries.go +++ b/internal/web/handlers_entries.go @@ -1,11 +1,17 @@ package web import ( + "encoding/json" "errors" + "fmt" "html/template" + "log" "net/http" + "net/url" + "sort" "strconv" "strings" + "time" "munin/internal/entry" "munin/internal/store" @@ -29,6 +35,7 @@ type entryListItem struct { // handleEntryList zeigt die Einträge eines Projekts — gefiltert, gesucht oder // einfach nach letzter Änderung. func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) project, _ := ProjectFrom(r) ctx := r.Context() @@ -48,11 +55,11 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) { return } - if daten.Tags, err = s.store.ListTags(ctx, project.ID); err != nil { + if daten.Tags, err = ws.Store.ListTags(ctx, project.ID); err != nil { s.serverError(w, "Schlagworte konnten nicht geladen werden", err) return } - if daten.Total, err = s.store.CountEntries(ctx, project.ID); err != nil { + if daten.Total, err = ws.Store.CountEntries(ctx, project.ID); err != nil { s.serverError(w, "Einträge konnten nicht gezählt werden", err) return } @@ -61,15 +68,17 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) { Title: project.Name, Bereich: "eintraege", AktivesTag: tag, + Flash: r.URL.Query().Get("hinweis"), Data: daten, }) } // searchEntries führt eine Volltextsuche durch und lädt die Treffer nach. func (s *Server) searchEntries(r *http.Request, project store.Project, query string) ([]entryListItem, error) { + ws, _ := WorkspaceFrom(r) ctx := r.Context() - idx, err := s.search.For(ctx, project.ID) + idx, err := ws.Search.For(ctx, project.ID) if err != nil { return nil, err } @@ -78,7 +87,7 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str items := make([]entryListItem, 0, len(treffer)) for _, t := range treffer { - e, err := s.store.EntryBySlug(ctx, project.ID, t.DocID) + e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID) if errors.Is(err, store.ErrNotFound) { // Der Index kann einen Eintrag kennen, den es nicht mehr gibt — // dann wird er hier stillschweigend übersprungen. @@ -98,7 +107,8 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str // listEntries lädt die Einträge ohne Suche, zuletzt geänderte zuerst. func (s *Server) listEntries(r *http.Request, project store.Project, tag string) ([]entryListItem, error) { - eintraege, err := s.store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100}) + ws, _ := WorkspaceFrom(r) + eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100}) if err != nil { return nil, err } @@ -122,10 +132,11 @@ type entryViewData struct { // handleEntryView zeigt einen einzelnen Eintrag. func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) project, _ := ProjectFrom(r) slug := r.PathValue("slug") - e, err := s.store.EntryBySlug(r.Context(), project.ID, slug) + e, err := ws.Store.EntryBySlug(r.Context(), project.ID, slug) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return @@ -142,7 +153,7 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) { return } - backlinks, err := s.store.Backlinks(r.Context(), project.ID, slug) + backlinks, err := ws.Store.Backlinks(r.Context(), project.ID, slug) if err != nil { s.serverError(w, "Rückverweise konnten nicht geladen werden", err) return @@ -151,6 +162,7 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) { s.render(w, r, "entry.html", http.StatusOK, pageData{ Title: e.Title, Bereich: "eintraege", + Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung Data: entryViewData{Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks}, }) } @@ -163,19 +175,29 @@ type entryEditData struct { } // handleEntryNew zeigt das Formular für einen neuen Eintrag. +// +// Mit ?slug=… ist der Bezeichner vorbelegt — so führt die Nutzungsseite von +// einem Verweis ins Leere direkt zum passenden neuen Eintrag. Ein ungültiger +// Wert wird ignoriert statt ins Formular übernommen. func (s *Server) handleEntryNew(w http.ResponseWriter, r *http.Request) { + var vorbelegt store.Entry + if slug := r.URL.Query().Get("slug"); store.ValidSlug(slug) { + vorbelegt.Slug = slug + } + s.render(w, r, "edit.html", http.StatusOK, pageData{ Title: "Neuer Eintrag", Bereich: "eintraege", - Data: entryEditData{IsNew: true}, + Data: entryEditData{IsNew: true, Entry: vorbelegt}, }) } // handleEntryEdit zeigt das Formular für einen bestehenden Eintrag. func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) project, _ := ProjectFrom(r) - e, err := s.store.EntryBySlug(r.Context(), project.ID, r.PathValue("slug")) + e, err := ws.Store.EntryBySlug(r.Context(), project.ID, r.PathValue("slug")) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return @@ -192,8 +214,88 @@ func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) { }) } +// suggestLimit begrenzt die Vorschläge der Autovervollständigung — mehr +// würde die Liste unübersichtlich machen, ohne beim Tippen zu helfen. +const suggestLimit = 8 + +// suggestItem ist ein Vorschlag der Autovervollständigung für [[…]]-Verweise. +type suggestItem struct { + Slug string `json:"slug"` + Title string `json:"title"` +} + +// handleEntrySuggest liefert Bezeichner-Vorschläge fürs Tippen von +// [[…]]-Verweisen im Bearbeitungsformular (siehe static/autocomplete.js). +// +// Ohne Suchbegriff kommen die zuletzt geänderten Einträge — meist das, was +// gerade im Sinn ist. Mit Suchbegriff läuft dieselbe Volltextsuche wie bei +// "recall" (MCP) und der Projektsuche: ein eigener Präfix-Abgleich auf dem +// Bezeichner wäre eine zweite Suchlogik, die mit der Zeit von der ersten +// abweicht. +func (s *Server) handleEntrySuggest(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + query := strings.TrimSpace(r.URL.Query().Get("q")) + + // Nicht nil, damit die Antwort immer ein JSON-Array ist ([] statt null) — + // das JavaScript ruft direkt .forEach darauf auf. + vorschlaege := []suggestItem{} + + if query == "" { + eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Limit: suggestLimit}) + if err != nil { + s.serverError(w, "Vorschläge konnten nicht geladen werden", err) + return + } + for _, e := range eintraege { + vorschlaege = append(vorschlaege, suggestItem{Slug: e.Slug, Title: e.Title}) + } + } else { + idx, err := ws.Search.For(r.Context(), project.ID) + if err != nil { + s.serverError(w, "Vorschläge konnten nicht geladen werden", err) + return + } + for _, treffer := range idx.Search(query, suggestLimit) { + e, err := ws.Store.EntryBySlug(r.Context(), project.ID, treffer.DocID) + if err != nil { + continue // Index kann einen Eintrag kennen, den es nicht mehr gibt + } + vorschlaege = append(vorschlaege, suggestItem{Slug: e.Slug, Title: e.Title}) + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(vorschlaege); err != nil { + log.Printf("Vorschläge konnten nicht gesendet werden: %v", err) + } +} + +// handleEntryPreview rendert eingereichten Markdown-Text als HTML — für die +// Vorschau im Bearbeitungsformular (siehe static/preview.js). +// +// Nimmt bewusst nur den Inhalt entgegen, keinen ganzen Eintrag: Es wird +// nichts gespeichert, nur derselbe Renderer aufgerufen, den auch die +// Detailansicht benutzt (entry.RenderHTML) — Vorschau und späteres Ergebnis +// können dadurch nicht auseinanderlaufen. Ein leerer oder fehlerhafter Inhalt +// ist hier kein Fehlerfall: goldmark rendert auch unfertigen Markdown-Text, +// den jemand gerade erst zu tippen begonnen hat. +func (s *Server) handleEntryPreview(w http.ResponseWriter, r *http.Request) { + project, _ := ProjectFrom(r) + + html, err := entry.RenderHTML(r.FormValue("content"), "/p/"+project.Slug+"/e/") + if err != nil { + s.serverError(w, "Vorschau konnte nicht erzeugt werden", err) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) +} + // handleEntrySave speichert einen Eintrag aus dem Formular. func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) project, _ := ProjectFrom(r) sess, _ := SessionFrom(r) @@ -211,7 +313,7 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { // Hinweis statt eines stillen Überschreibens. version, _ := strconv.Atoi(r.FormValue("version")) - geschrieben, err := s.store.PutEntry(r.Context(), store.EntryWrite{ + geschrieben, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{ ProjectID: project.ID, Slug: slug, Title: titel, @@ -243,33 +345,440 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { return } - s.search.Update(project.ID, geschrieben) - s.store.LogAudit(r.Context(), store.AuditEvent{ + ws.Search.Update(project.ID, geschrieben) + ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb, + Detail: store.EntryWriteDetail(geschrieben), }) - http.Redirect(w, r, "/p/"+project.Slug+"/e/"+slug, http.StatusSeeOther) + ziel := "/p/" + project.Slug + "/e/" + slug + if geschrieben.RestoredFromTrash { + // Der Bezeichner gehörte einem Eintrag im Papierkorb. PutEntry hat ihn + // zurückgeholt und überschrieben — das soll im Protokoll stehen und + // der Mensch soll es erfahren, sonst wundert er sich über "Fassung 4" + // bei einem vermeintlich neuen Eintrag. + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), + Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceWeb, + Detail: "durch Speichern unter demselben Bezeichner", + }) + ziel += "?hinweis=" + url.QueryEscape( + "Unter diesem Bezeichner lag ein Eintrag im Papierkorb. Er wurde zurückgeholt und überschrieben; der gelöschte Stand steht im Verlauf.") + } + + http.Redirect(w, r, ziel, http.StatusSeeOther) } -// handleEntryDelete löscht einen Eintrag. +// handleEntryDelete legt einen Eintrag in den Papierkorb (AUSBAUSTUFEN.md 3.9). +// Endgültig gelöscht wird nur über die Papierkorb-Seite oder nach Ablauf der +// Frist. func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) project, _ := ProjectFrom(r) sess, _ := SessionFrom(r) slug := r.PathValue("slug") - if err := s.store.DeleteEntry(r.Context(), project.ID, slug); err != nil && !errors.Is(err, store.ErrNotFound) { + err := ws.Store.MoveEntryToTrash(r.Context(), project.ID, slug, sess.ActorName()) + if errors.Is(err, store.ErrNotFound) { + // Schon weg (etwa doppelt abgeschickt) — kein Fehler, einfach zur Liste. + http.Redirect(w, r, "/p/"+project.Slug+"/", http.StatusSeeOther) + return + } + if err != nil { s.serverError(w, "Eintrag konnte nicht gelöscht werden", err) return } - s.search.Delete(project.ID, slug) - s.store.LogAudit(r.Context(), store.AuditEvent{ + ws.Search.Delete(project.ID, slug) + ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb, + Detail: "in den Papierkorb", }) - http.Redirect(w, r, "/p/"+project.Slug+"/", http.StatusSeeOther) + meldung := fmt.Sprintf("„%s“ liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.", + slug, store.TrashRetentionDays) + http.Redirect(w, r, "/p/"+project.Slug+"/?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther) +} + +// handleEntryBulkTags fügt einer Auswahl von Einträgen Schlagworte hinzu +// und/oder entfernt welche — die Sammelbearbeitung aus der Übersichtsseite. +// +// Die Auswahl kommt als mehrere gleichnamige Formularfelder "ids" — das +// HTML-Formular in entries.html legt für jeden angehakten Eintrag ein eigenes +// verstecktes Feld an. +func (s *Server) handleEntryBulkTags(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + sess, _ := SessionFrom(r) + + if err := r.ParseForm(); err != nil { + s.serverError(w, "Formular konnte nicht gelesen werden", err) + return + } + + ziel := "/p/" + project.Slug + "/" + if tag := r.FormValue("tag"); tag != "" { + ziel += "?tag=" + url.QueryEscape(tag) + } + + ids := r.Form["ids"] + add := splitTags(r.FormValue("add_tags")) + remove := splitTags(r.FormValue("remove_tags")) + + if len(ids) == 0 || (len(add) == 0 && len(remove) == 0) { + http.Redirect(w, r, ziel, http.StatusSeeOther) + return + } + + geaendert, err := ws.Store.BulkUpdateTags(r.Context(), project.ID, ids, add, remove) + if err != nil { + s.serverError(w, "Schlagworte konnten nicht geändert werden", err) + return + } + + for _, e := range geaendert { + ws.Search.Update(project.ID, e) + } + + if len(geaendert) > 0 { + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), + Action: store.ActionEntryWrite, Source: store.SourceWeb, + Detail: fmt.Sprintf("Sammelbearbeitung: %d Einträge, +%v -%v", len(geaendert), add, remove), + }) + } + + meldung := fmt.Sprintf("%d Eintrag/Einträge aktualisiert", len(geaendert)) + trenner := "?" + if strings.Contains(ziel, "?") { + trenner = "&" + } + http.Redirect(w, r, ziel+trenner+"hinweis="+url.QueryEscape(meldung), http.StatusSeeOther) +} + +// ── Änderungshistorie ──────────────────────────────────────────────────────── + +// entryVersionListData sind die Daten der Verlaufsseite eines Eintrags. +type entryVersionListData struct { + Entry store.Entry + Versions []store.EntryVersion +} + +// handleEntryVersions zeigt den Verlauf eines Eintrags — jede abgelöste +// Fassung mit Zeitpunkt und Zugang, verlinkt zum Vergleich mit ihrer +// Nachfolgerin. +func (s *Server) handleEntryVersions(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + slug := r.PathValue("slug") + + e, err := ws.Store.EntryBySlug(r.Context(), project.ID, slug) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + s.serverError(w, "Eintrag konnte nicht geladen werden", err) + return + } + + s.renderEntryVersions(w, r, http.StatusOK, e, "") +} + +// renderEntryVersions zeigt die Verlaufsseite, auf Wunsch mit Fehlermeldung. +// Eine eigene Funktion, weil auch die Wiederherstellung bei einem Konflikt +// hierher zurückführt (siehe handleEntryRestore). +func (s *Server) renderEntryVersions(w http.ResponseWriter, r *http.Request, status int, e store.Entry, fehler string) { + ws, _ := WorkspaceFrom(r) + + versionen, err := ws.Store.EntryVersions(r.Context(), e.ID) + if err != nil { + s.serverError(w, "Änderungshistorie konnte nicht geladen werden", err) + return + } + + s.render(w, r, "entry-versions.html", status, pageData{ + Title: "Verlauf – " + e.Title, + Bereich: "eintraege", + Error: fehler, + Data: entryVersionListData{Entry: e, Versions: versionen}, + }) +} + +// handleEntryRestore holt eine abgelöste Fassung zurück (AUSBAUSTUFEN.md 5.1). +// +// # Kein eigener Speicherweg +// +// Technisch ist das ein ganz gewöhnliches PutEntry mit Titel, Inhalt und +// Schlagworten der alten Fassung. Das hat zwei Vorteile: +// +// 1. Der aktuelle Stand wird dabei — wie bei jeder Änderung — selbst zu einer +// abgelösten Fassung. Es geht also nichts verloren; auch eine +// Wiederherstellung lässt sich wiederherstellen. +// 2. Suchindex, Verweise und Schlagworte werden auf demselben Weg nachgezogen +// wie beim normalen Speichern. Es gibt keine zweite Stelle, die man bei +// künftigen Änderungen vergessen könnte. +// +// Die Markierung "kuratiert" bleibt, wie sie aktuell ist: Sie ist eine Aussage +// über den Eintrag, nicht über einen bestimmten Textstand. +// +// # Schutz vor Überschreiben +// +// Das Formular schickt die Fassungsnummer mit, die beim Anzeigen aktuell war +// ("current_version"). Hat in der Zwischenzeit jemand — etwa ein Agent — den +// Eintrag geändert, schlägt PutEntry mit ErrConflict fehl, statt diese +// Änderung stillschweigend zu verdrängen. +func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + sess, _ := SessionFrom(r) + slug := r.PathValue("slug") + + // Die Fassungsnummer steht im Pfad. strconv.Atoi wandelt Text in eine Zahl; + // alles, was keine positive Zahl ist, kann es als Fassung nicht geben. + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil || version < 1 { + http.NotFound(w, r) + return + } + + e, err := ws.Store.EntryBySlug(r.Context(), project.ID, slug) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + s.serverError(w, "Eintrag konnte nicht geladen werden", err) + return + } + + // EntryVersionByNumber sucht über die Eintrags-ID. Eine Fassung eines + // fremden Eintrags (oder fremden Projekts) ist darüber nicht erreichbar. + alt, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + s.serverError(w, "Fassung konnte nicht geladen werden", err) + return + } + + // Fehlt das Feld (oder ist es kaputt), bleibt erwartet bei 0 — dann würde + // PutEntry ohne Konfliktprüfung schreiben. Das wollen wir hier nicht: Wer + // eine alte Fassung zurückholt, soll dabei nie unbemerkt eine neuere + // Änderung eines anderen überschreiben. Deshalb lieber ablehnen. + erwartet, err := strconv.Atoi(r.FormValue("current_version")) + if err != nil || erwartet < 1 { + http.Error(w, "Fassungsangabe fehlt", http.StatusBadRequest) + return + } + + geschrieben, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{ + ProjectID: project.ID, + Slug: e.Slug, + Title: alt.Title, + Content: alt.Content, + Tags: alt.Tags, + Source: store.SourceHuman, + Author: sess.ActorName(), + Curated: e.Curated, + ExpectedVersion: erwartet, + }) + if errors.Is(err, store.ErrConflict) { + // Den Eintrag frisch laden, damit die Verlaufsseite die Änderung zeigt, + // die uns zuvorgekommen ist. Klappt das nicht, reicht auch der alte Stand. + if frisch, ladeFehler := ws.Store.EntryBySlug(r.Context(), project.ID, slug); ladeFehler == nil { + e = frisch + } + s.renderEntryVersions(w, r, http.StatusConflict, e, + "Der Eintrag wurde inzwischen geändert. Bitte den Verlauf prüfen und dann erneut wiederherstellen.") + return + } + if err != nil { + s.serverError(w, "Fassung konnte nicht wiederhergestellt werden", err) + return + } + + ws.Search.Update(project.ID, geschrieben) + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), + Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb, + Detail: store.EntryRestoreDetail(alt.Version), + }) + + meldung := fmt.Sprintf("Fassung %d wiederhergestellt — jetzt Fassung %d.", alt.Version, geschrieben.Version) + http.Redirect(w, r, "/p/"+project.Slug+"/e/"+e.Slug+"?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther) +} + +// entryDiffData sind die Daten der Vergleichsseite zweier Fassungen. +type entryDiffData struct { + Entry store.Entry + + OldVersion int + OldTitle string + OldAuthor string + OldTime time.Time + OldTags []string + + NewVersion int + NewTitle string + NewAuthor string + NewTime time.Time + NewTags []string + NewIsCurrent bool // true, wenn "neu" die aktuell gültige Fassung ist + + Lines []entry.DiffLine +} + +// handleEntryDiff vergleicht eine abgelöste Fassung mit ihrer Nachfolgerin — +// entweder der nächsten abgelösten Fassung oder, falls es keine mehr gibt, +// der aktuellen. +func (s *Server) handleEntryDiff(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + slug := r.PathValue("slug") + + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil || version < 1 { + http.NotFound(w, r) + return + } + + e, err := ws.Store.EntryBySlug(r.Context(), project.ID, slug) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + s.serverError(w, "Eintrag konnte nicht geladen werden", err) + return + } + + alt, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + s.serverError(w, "Fassung konnte nicht geladen werden", err) + return + } + + daten := entryDiffData{ + Entry: e, + OldVersion: alt.Version, OldTitle: alt.Title, OldAuthor: alt.Author, OldTime: alt.CreatedAt, OldTags: alt.Tags, + } + + neu, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version+1) + switch { + case err == nil: + daten.NewVersion, daten.NewTitle, daten.NewAuthor = neu.Version, neu.Title, neu.Author + daten.NewTime, daten.NewTags = neu.CreatedAt, neu.Tags + daten.Lines = entry.DiffLines(alt.Content, neu.Content) + case errors.Is(err, store.ErrNotFound): + // Keine weitere abgelöste Fassung mehr — die Nachfolgerin ist die + // aktuell gültige. + daten.NewVersion, daten.NewTitle, daten.NewAuthor = e.Version, e.Title, e.Author + daten.NewTime, daten.NewTags, daten.NewIsCurrent = e.UpdatedAt, e.Tags, true + daten.Lines = entry.DiffLines(alt.Content, e.Content) + default: + s.serverError(w, "Fassung konnte nicht geladen werden", err) + return + } + + s.render(w, r, "entry-diff.html", http.StatusOK, pageData{ + Title: "Vergleich – " + e.Title, + Bereich: "eintraege", + Data: daten, + }) +} + +// ── Graph ──────────────────────────────────────────────────────────────────── + +// graphNodeLimit begrenzt, wie viele Knoten die Graph-Ansicht überhaupt an +// den Browser schickt. +// +// Ohne Grenze würde ein sehr großes Projekt zwei Probleme gleichzeitig +// bekommen: Das Layout im Browser läuft mit jedem Knotenpaar (siehe +// static/graph.js — die Abstoßung zwischen allen Knoten ist O(n²)), und die +// Darstellung wird ab einigen hundert Punkten ohnehin unlesbar. Bei +// Überschreitung bleiben die am stärksten verknüpften Knoten übrig — die +// tragen das meiste zur Struktur bei. +const graphNodeLimit = 150 + +type graphNode struct { + Slug string `json:"slug"` + Title string `json:"title"` + Exists bool `json:"exists"` + Grad int `json:"degree"` +} + +type graphEdge struct { + From string `json:"from"` + To string `json:"to"` +} + +type graphResponse struct { + Nodes []graphNode `json:"nodes"` + Edges []graphEdge `json:"edges"` + Truncated bool `json:"truncated"` + Total int `json:"total"` +} + +// handleEntryGraph zeigt die Graph-Seite — nur das Gerüst, die Daten holt +// static/graph.js selbst über handleEntryGraphData nach. +func (s *Server) handleEntryGraph(w http.ResponseWriter, r *http.Request) { + project, _ := ProjectFrom(r) + s.render(w, r, "graph.html", http.StatusOK, pageData{ + Title: "Graph – " + project.Name, + Bereich: "graph", + }) +} + +// handleEntryGraphData liefert Knoten und Kanten der Graph-Ansicht als JSON. +func (s *Server) handleEntryGraphData(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + + knoten, kanten, err := ws.Store.LinkGraph(r.Context(), project.ID) + if err != nil { + s.serverError(w, "Graph konnte nicht geladen werden", err) + return + } + + grad := make(map[string]int, len(knoten)) + for _, k := range kanten { + grad[k.From]++ + grad[k.To]++ + } + + resp := graphResponse{Nodes: []graphNode{}, Edges: []graphEdge{}, Total: len(knoten)} + + behalten := knoten + if len(knoten) > graphNodeLimit { + resp.Truncated = true + sort.Slice(knoten, func(i, j int) bool { return grad[knoten[i].Slug] > grad[knoten[j].Slug] }) + behalten = knoten[:graphNodeLimit] + } + + erlaubt := make(map[string]bool, len(behalten)) + for _, k := range behalten { + erlaubt[k.Slug] = true + resp.Nodes = append(resp.Nodes, graphNode{Slug: k.Slug, Title: k.Title, Exists: k.Exists, Grad: grad[k.Slug]}) + } + for _, e := range kanten { + if erlaubt[e.From] && erlaubt[e.To] { + resp.Edges = append(resp.Edges, graphEdge{From: e.From, To: e.To}) + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Graph-Daten konnten nicht gesendet werden: %v", err) + } } // splitTags zerlegt die Schlagwort-Eingabe an Kommas. diff --git a/internal/web/handlers_hilfe.go b/internal/web/handlers_hilfe.go new file mode 100644 index 0000000..17b2eeb --- /dev/null +++ b/internal/web/handlers_hilfe.go @@ -0,0 +1,119 @@ +package web + +import ( + "html/template" + "net/http" + "regexp" + "strings" + + "munin/internal/auth" + "munin/internal/hilfe" +) + +// hilfeData sind die Daten einer Hilfeseite. +type hilfeData struct { + Seite hilfe.Seite // die angezeigte Seite + Seiten []hilfe.Seite // alle Seiten, für die Navigation + HTML template.HTML // der gerenderte Inhalt +} + +// handleHilfe zeigt eine Seite der eingebauten Onboarding-Dokumentation. +// +// Erreichbar für jede angemeldete Sitzung, unabhängig von Rolle und Projekt: +// Die Hilfe enthält nichts, was nicht jeder Nutzer wissen darf. Auch ein +// Plattform-Admin, der noch keinen Workspace gewählt hat, kommt hierher. +// +// /hilfe ohne Seitenangabe zeigt die Startseite. Deshalb kommt der Slug aus +// r.PathValue und fällt auf "start" zurück, wenn er leer ist. +func (s *Server) handleHilfe(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("seite") + if slug == "" { + slug = "start" + } + + sess, _ := SessionFrom(r) + + // Eine Admin-Seite antwortet für alle anderen mit 404, genau wie eine + // Seite, die es nicht gibt — sie steht für sie ja auch nirgends verlinkt. + seite, ok := hilfe.Finde(slug) + if !ok || (seite.NurAdmin && !sess.IsPlatformAdmin) { + http.NotFound(w, r) + return + } + + markdown, err := seite.Markdown() + if err != nil { + s.serverError(w, "Hilfeseite konnte nicht geladen werden", err) + return + } + + // Platzhalter ersetzen, damit die Beispiele direkt kopierbar sind: mit der + // Adresse dieses Servers und dem Bezeichner des eigenen Projekts statt + // mit "DEIN-SERVER". Das passiert vor dem Rendern — goldmark sieht nur + // noch fertigen Text und maskiert ihn wie jeden anderen. + projektSlug := "mein-projekt" + if sess.Project.Slug != "" { + projektSlug = sess.Project.Slug + } + markdown = strings.NewReplacer( + "{{SERVER}}", serverURL(r), + "{{PROJEKT}}", projektSlug, + ).Replace(markdown) + + inhalt, err := hilfe.Render(markdown) + if err != nil { + s.serverError(w, "Hilfeseite konnte nicht dargestellt werden", err) + return + } + + data := pageData{ + Title: seite.Titel, + Bereich: "hilfe", + Data: hilfeData{ + Seite: seite, + Seiten: hilfe.Sichtbar(sess.IsPlatformAdmin), + HTML: template.HTML(inhalt), + }, + } + + // Ein Projekt-Key soll auch auf der Hilfe seine gewohnte Seitenleiste + // sehen (Einträge, Graph, Schlagworte). render() übernimmt das Projekt + // sonst nur aus der URL, und /hilfe hat keins. + if !sess.IsPlatformAdmin { + data.Project = sess.Project + } + + s.render(w, r, "hilfe.html", http.StatusOK, data) +} + +// gueltigerHost erlaubt nur, was in einem Hostnamen mit Port vorkommen kann: +// Buchstaben, Ziffern, Punkt, Bindestrich, Doppelpunkt und die eckigen +// Klammern einer IPv6-Adresse. +var gueltigerHost = regexp.MustCompile(`^[A-Za-z0-9.\-:\[\]]+$`) + +// serverURL errät die Adresse, unter der dieser Server von außen erreichbar +// ist — für die Beispiele in der Hilfe. +// +// # Warum nur "erraten"? +// +// Der Host-Header kommt vom Browser und damit vom Nutzer selbst. Das ist hier +// unbedenklich: Das Ergebnis landet nur in der Seite, die derselbe Nutzer +// gerade abruft, und wird nirgends gespeichert. Trotzdem wird der Wert +// geprüft, und bei allem Ungewöhnlichen steht ein Platzhalter da. +// +// Das Schema ist schwerer zu erkennen: Hinter einem Reverse-Proxy, der TLS +// beendet, kommt die Anfrage hier als http an. Deshalb gilt: https, außer bei +// localhost ohne TLS — dieselbe Regel wie beim Secure-Flag der Cookies, und +// deshalb auch dieselbe Funktion (auth.IsLocalhost). +func serverURL(r *http.Request) string { + host := r.Host + if host == "" || !gueltigerHost.MatchString(host) { + return "https://DEIN-SERVER" + } + + schema := "https" + if r.TLS == nil && auth.IsLocalhost(r) { + schema = "http" + } + return schema + "://" + host +} diff --git a/internal/web/handlers_nutzung.go b/internal/web/handlers_nutzung.go new file mode 100644 index 0000000..2995306 --- /dev/null +++ b/internal/web/handlers_nutzung.go @@ -0,0 +1,86 @@ +package web + +// Nutzungsübersicht je Projekt (AUSBAUSTUFEN.md 4.6). +// +// Für alle mit Zugang zum Projekt, auch mit Leserecht: Die Seite zeigt nur +// Zahlen und Namen von Zugängen, und die stehen ohnehin schon im Protokoll, +// das jede Rolle sehen darf. + +import ( + "net/http" + "time" + + "munin/internal/store" +) + +// Wie viele Einträge die Listen auf der Seite höchstens zeigen. Die Seite +// soll einen Überblick geben, keine vollständige Aufstellung — wer mehr +// braucht, findet sie über die Eintragsliste oder den Graph. +const ( + nutzungListenLaenge = 15 + nutzungKeysLaenge = 5 +) + +// usageData sind die Daten der Nutzungsseite. +type usageData struct { + Summary store.UsageSummary + WindowDays int + Untagged []store.EntryRef + Missing []store.MissingLink + MissingMore int // wie viele Verweise ins Leere über die Liste hinaus + Keys []store.APIKey +} + +// handleProjectUsage zeigt die Nutzungsübersicht eines Projekts. +func (s *Server) handleProjectUsage(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + ctx := r.Context() + + alle, err := ws.Store.UsageSummaries(ctx, project.ID, time.Now()) + if err != nil { + s.serverError(w, "Nutzung konnte nicht geladen werden", err) + return + } + + ohneSchlagwort, err := ws.Store.UntaggedEntries(ctx, project.ID, nutzungListenLaenge) + if err != nil { + s.serverError(w, "Einträge ohne Schlagwort konnten nicht geladen werden", err) + return + } + + insLeere, err := ws.Store.MissingLinks(ctx, project.ID) + if err != nil { + s.serverError(w, "Verweise konnten nicht geladen werden", err) + return + } + + keys, err := ws.Store.RecentlyUsedKeys(ctx, project.ID, nutzungKeysLaenge) + if err != nil { + s.serverError(w, "Zugänge konnten nicht geladen werden", err) + return + } + + daten := usageData{ + // Ein Projekt ohne Einträge und Schreibvorgänge fehlt in der map — + // dann liefert alle[project.ID] den Nullwert, also überall 0. + Summary: alle[project.ID], + WindowDays: store.UsageWindowDays, + Untagged: ohneSchlagwort, + Missing: insLeere, + Keys: keys, + } + + // Die Verweise ins Leere kommen vollständig (die Gesamtzahl ist selbst + // eine Aussage), angezeigt werden nur die meistgenannten. + if len(daten.Missing) > nutzungListenLaenge { + daten.MissingMore = len(daten.Missing) - nutzungListenLaenge + daten.Missing = daten.Missing[:nutzungListenLaenge] + } + + s.render(w, r, "nutzung.html", http.StatusOK, pageData{ + Title: "Nutzung – " + project.Name, + Bereich: "nutzung", + Data: daten, + }) +} diff --git a/internal/web/handlers_papierkorb.go b/internal/web/handlers_papierkorb.go new file mode 100644 index 0000000..1588b5b --- /dev/null +++ b/internal/web/handlers_papierkorb.go @@ -0,0 +1,183 @@ +package web + +// Papierkorb je Projekt (AUSBAUSTUFEN.md 3.9). +// +// Alle drei Wege verlangen Schreibrecht, auch das bloße Ansehen. Grund: Ein +// Eintrag landet oft genau deshalb im Papierkorb, weil er etwas enthielt, das +// dort nicht hingehört — ein versehentlich abgelegtes Passwort etwa. Wer nur +// lesen darf, soll das nicht über den Papierkorb doch noch zu sehen bekommen. +// +// Wiederherstellen und endgültig Löschen gibt es bewusst NUR hier, nicht über +// MCP: Ein fehlgeleiteter Agent soll seinen Schaden nicht selbst "vollenden" +// können, indem er nach dem Löschen auch noch den Papierkorb leert. + +import ( + "fmt" + "net/http" + "net/url" + + "munin/internal/store" +) + +// trashData sind die Daten der Papierkorb-Seite. +type trashData struct { + Entries []store.TrashedEntry + + // Von ist der Filter "nur, was dieser Zugang gelöscht hat" — leer, wenn + // alles angezeigt wird. + Von string + + // Frist in Tagen, für den Hinweistext. + RetentionDays int +} + +// handleTrashList zeigt den Papierkorb eines Projekts. +func (s *Server) handleTrashList(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + von := r.URL.Query().Get("von") + + eintraege, err := ws.Store.ListTrash(r.Context(), project.ID, von) + if err != nil { + s.serverError(w, "Papierkorb konnte nicht geladen werden", err) + return + } + + s.render(w, r, "papierkorb.html", http.StatusOK, pageData{ + Title: "Papierkorb – " + project.Name, + Bereich: "papierkorb", + Flash: r.URL.Query().Get("hinweis"), + Data: trashData{Entries: eintraege, Von: von, RetentionDays: store.TrashRetentionDays}, + }) +} + +// papierkorbAuswahl liest die ausgewählten Bezeichner aus dem Formular. +// +// Das Formular schickt für jeden angehakten Eintrag ein eigenes Feld "slugs" +// — mehrere Felder mit demselben Namen. r.Form["slugs"] liefert sie alle als +// Liste; r.FormValue("slugs") gäbe nur das erste zurück. +func papierkorbAuswahl(r *http.Request) ([]string, error) { + if err := r.ParseForm(); err != nil { + return nil, err + } + + // Doppelte Angaben (etwa ein Kästchen plus derselbe Eintrag über "alle + // wiederherstellen") nur einmal behandeln — sonst stünde der Vorgang + // zweimal im Protokoll. Die map merkt sich, was schon übernommen wurde. + gesehen := map[string]bool{} + var slugs []string + for _, slug := range r.Form["slugs"] { + if slug == "" || gesehen[slug] { + continue + } + gesehen[slug] = true + slugs = append(slugs, slug) + } + return slugs, nil +} + +// zurueckZumPapierkorb leitet mit einer Meldung auf die Papierkorb-Seite +// zurück — und behält dabei einen gesetzten Filter bei. +func zurueckZumPapierkorb(w http.ResponseWriter, r *http.Request, projektSlug, meldung string) { + // url.Values baut eine Query ("a=1&b=2") und maskiert dabei jeden Wert + // korrekt — sicherer als Zeichenketten von Hand zusammenzusetzen. + werte := url.Values{} + if von := r.FormValue("von"); von != "" { + werte.Set("von", von) + } + if meldung != "" { + werte.Set("hinweis", meldung) + } + + ziel := "/p/" + projektSlug + "/trash" + if len(werte) > 0 { + ziel += "?" + werte.Encode() + } + http.Redirect(w, r, ziel, http.StatusSeeOther) +} + +// handleTrashRestore holt die ausgewählten Einträge zurück. +func (s *Server) handleTrashRestore(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + sess, _ := SessionFrom(r) + + slugs, err := papierkorbAuswahl(r) + if err != nil { + s.serverError(w, "Formular konnte nicht gelesen werden", err) + return + } + if len(slugs) == 0 { + zurueckZumPapierkorb(w, r, project.Slug, "Nichts ausgewählt.") + return + } + + zurueck, err := ws.Store.RestoreFromTrash(r.Context(), project.ID, slugs) + if err != nil { + s.serverError(w, "Einträge konnten nicht wiederhergestellt werden", err) + return + } + + // Jeder zurückgeholte Eintrag kommt wieder in den Suchindex und bekommt + // eine eigene Protokollzeile — so lässt sich im Protokoll nach einem + // einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen. + for _, e := range zurueck { + ws.Search.Update(project.ID, e) + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), + Action: store.ActionEntryRestore, EntrySlug: e.Slug, Source: store.SourceWeb, + Detail: "aus dem Papierkorb", + }) + } + + zurueckZumPapierkorb(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge wiederhergestellt.", len(zurueck))) +} + +// handleTrashPurge löscht die ausgewählten Einträge endgültig. +func (s *Server) handleTrashPurge(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + sess, _ := SessionFrom(r) + + slugs, err := papierkorbAuswahl(r) + if err != nil { + s.serverError(w, "Formular konnte nicht gelesen werden", err) + return + } + if len(slugs) == 0 { + zurueckZumPapierkorb(w, r, project.Slug, "Nichts ausgewählt.") + return + } + + // Vorher festhalten, welche Bezeichner wirklich im Papierkorb liegen — + // nur die werden gelöscht und nur die gehören ins Protokoll. Eine + // untergeschobene Angabe taucht so auch dort nicht auf. + imPapierkorb, err := ws.Store.ListTrash(r.Context(), project.ID, "") + if err != nil { + s.serverError(w, "Papierkorb konnte nicht geladen werden", err) + return + } + vorhanden := make(map[string]bool, len(imPapierkorb)) + for _, t := range imPapierkorb { + vorhanden[t.Slug] = true + } + + anzahl, err := ws.Store.PurgeFromTrash(r.Context(), project.ID, slugs) + if err != nil { + s.serverError(w, "Einträge konnten nicht endgültig gelöscht werden", err) + return + } + + for _, slug := range slugs { + if !vorhanden[slug] { + continue + } + ws.Store.LogAudit(r.Context(), store.AuditEvent{ + ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), + Action: store.ActionEntryPurge, EntrySlug: slug, Source: store.SourceWeb, + Detail: "von Hand aus dem Papierkorb", + }) + } + + zurueckZumPapierkorb(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge endgültig gelöscht.", anzahl)) +} diff --git a/internal/web/handlers_workspaces.go b/internal/web/handlers_workspaces.go new file mode 100644 index 0000000..d732929 --- /dev/null +++ b/internal/web/handlers_workspaces.go @@ -0,0 +1,243 @@ +package web + +import ( + "errors" + "log" + "net/http" + "net/url" + "strings" + + "munin/internal/store" +) + +// workspaceListData sind die Daten der Workspace-Auswahl. +type workspaceListData struct { + Workspaces []workspaceItem + + // Current ist die ID des gerade gewählten Workspace, falls einer gewählt + // ist — für die Hervorhebung in der Liste. + Current string +} + +type workspaceItem struct { + ID string + Name string + CreatedAt string + Projects int +} + +// handleWorkspaceList zeigt alle Workspaces zur Auswahl. +// +// Erreichbar auch ohne gewählten Workspace — genau dafür ist die Seite da: +// Eine frische Plattform-Admin-Sitzung landet hier, bevor sie überhaupt eine +// Projektseite aufrufen kann (siehe requireProject in middleware.go). +func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) { + s.renderWorkspaceList(w, r, http.StatusOK, "", "") +} + +// renderWorkspaceList lädt alle Workspaces und rendert die Auswahlseite. +func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, status int, hinweis, fehler string) { + sess, _ := SessionFrom(r) + + workspaces, err := s.control.ListWorkspaces(r.Context()) + if err != nil { + s.serverError(w, "Workspaces konnten nicht geladen werden", err) + return + } + + // Neueste zuerst — genau umgekehrt zu ListWorkspaces, das für + // EnsureDefaultWorkspace die älteste zuerst braucht. + items := make([]workspaceItem, len(workspaces)) + for i, ws := range workspaces { + items[len(workspaces)-1-i] = workspaceItem{ + ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"), + Projects: s.projectCount(r, ws.ID), + } + } + + s.render(w, r, "workspaces.html", status, pageData{ + Title: "Workspaces", + Bereich: "workspaces", + Flash: hinweis, + Error: fehler, + Data: workspaceListData{Workspaces: items, Current: sess.WorkspaceID}, + }) +} + +// handleWorkspaceCreate legt einen neuen Workspace an und wählt ihn gleich — +// wer eine Abteilung anlegt, will unmittelbar darin ihr erstes Projekt anlegen, +// nicht erst zurück zur Auswahl. +func (s *Server) handleWorkspaceCreate(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + s.renderWorkspaceList(w, r, http.StatusBadRequest, "", "Der Name darf nicht leer sein.") + return + } + + ws, err := s.control.CreateWorkspace(r.Context(), name) + if err != nil { + s.serverError(w, "Workspace konnte nicht angelegt werden", err) + return + } + + sess, _ := SessionFrom(r) + log.Printf("Workspace %q (%s) angelegt von %s", ws.Name, ws.ID, sess.ActorName()) + + s.setSessionCookie(w, r, "platform:"+ws.ID) + http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) +} + +// ── Bearbeiten und Löschen ─────────────────────────────────────────────────── + +// workspaceEditData sind die Daten der Bearbeitungsseite eines Workspace. +type workspaceEditData struct { + Workspace workspaceItem + Projects int +} + +// handleWorkspaceEditForm zeigt das Formular zum Umbenennen und die Gefahrenzone. +func (s *Server) handleWorkspaceEditForm(w http.ResponseWriter, r *http.Request) { + ws, ok := s.workspaceFromPath(w, r) + if !ok { + return + } + s.renderWorkspaceEdit(w, r, http.StatusOK, ws, "", "") +} + +// handleWorkspaceEditSave benennt einen Workspace um. +func (s *Server) handleWorkspaceEditSave(w http.ResponseWriter, r *http.Request) { + ws, ok := s.workspaceFromPath(w, r) + if !ok { + return + } + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, "", "Der Name darf nicht leer sein.") + return + } + + if err := s.control.UpdateWorkspace(r.Context(), ws.ID, name); err != nil { + s.serverError(w, "Workspace konnte nicht umbenannt werden", err) + return + } + + sess, _ := SessionFrom(r) + log.Printf("Workspace %s umbenannt in %q von %s", ws.ID, name, sess.ActorName()) + + http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape(name+" gespeichert"), http.StatusSeeOther) +} + +// handleWorkspaceDelete löscht einen Workspace samt aller seiner Projekte, +// Einträge und Zugänge unwiederbringlich. +// +// Erst die Kontroll-Datenbank (Workspace-Zeile, Routing-Index), dann die Datei +// selbst — siehe der Kommentar an workspace.Manager.Delete. Betraf die +// Löschung den Workspace der eigenen Sitzung, wird die Sitzung auf "kein +// Workspace gewählt" zurückgesetzt statt den Admin einfach auszusperren. +func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) { + ws, ok := s.workspaceFromID(w, r, r.FormValue("id")) + if !ok { + return + } + + if r.FormValue("confirm") != ws.Name { + s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, "", + "Zum Löschen muss der Workspace-Name \""+ws.Name+"\" zur Bestätigung eingegeben werden.") + return + } + + if err := s.control.DeleteWorkspace(r.Context(), ws.ID); err != nil { + s.serverError(w, "Workspace konnte nicht gelöscht werden", err) + return + } + + // Die Datei ist danach überflüssiger Ballast, kein Sicherheitsproblem mehr + // — die Kontroll-Datenbank kennt den Workspace nicht mehr, also kann ihn + // auch niemand mehr erreichen. Ein Fehler hier darf die Antwort deshalb + // nicht mehr aufhalten, nur im Log stehen. + if err := s.workspaces.Delete(ws.ID); err != nil { + log.Printf("Workspace-Datei %s konnte nicht gelöscht werden: %v", ws.ID, err) + } + + sess, _ := SessionFrom(r) + log.Printf("Workspace %q (%s) gelöscht von %s", ws.Name, ws.ID, sess.ActorName()) + + if sess.WorkspaceID == ws.ID { + s.setSessionCookie(w, r, "platform") + } + http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape("Workspace gelöscht"), http.StatusSeeOther) +} + +// projectCount zählt die Projekte eines Workspace. +// +// Dafür muss die Datei geöffnet werden (oder liegt schon im Cache des +// Managers) — bei 10–40 Workspaces auf einer Verwaltungsseite unproblematisch, +// siehe AUSBAUSTUFEN.md 4.4. Ein Fehler zeigt sich als 0 statt eine ganze +// Übersichtsseite scheitern zu lassen; das Ergebnis ist ohnehin nur eine +// Zusatzinformation, kein Sicherheitsmerkmal. +func (s *Server) projectCount(r *http.Request, workspaceID string) int { + ws, err := s.workspaces.Get(workspaceID) + if err != nil { + log.Printf("Projekte von Workspace %s konnten nicht gezählt werden: %v", workspaceID, err) + return 0 + } + projekte, err := ws.Store.ListProjects(r.Context(), workspaceID) + if err != nil { + log.Printf("Projekte von Workspace %s konnten nicht gezählt werden: %v", workspaceID, err) + return 0 + } + return len(projekte) +} + +// renderWorkspaceEdit lädt die Projektzahl und rendert die Bearbeitungsseite. +func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, status int, + ws workspaceItem, flash, fehler string) { + + s.render(w, r, "workspace-edit.html", status, pageData{ + Title: ws.Name, + Bereich: "workspaces", + Flash: flash, + Error: fehler, + Data: workspaceEditData{Workspace: ws, Projects: s.projectCount(r, ws.ID)}, + }) +} + +// workspaceFromPath löst die Workspace-ID aus der URL auf ({id}/edit-Routen). +func (s *Server) workspaceFromPath(w http.ResponseWriter, r *http.Request) (workspaceItem, bool) { + return s.workspaceFromID(w, r, r.PathValue("id")) +} + +// workspaceFromID lädt einen Workspace anhand seiner ID — gemeinsame Logik für +// die pfadbasierten Bearbeitungsrouten und die formularbasierte Löschroute +// (die folgt bewusst demselben Muster wie /admin/projects/delete). +func (s *Server) workspaceFromID(w http.ResponseWriter, r *http.Request, id string) (workspaceItem, bool) { + ws, err := s.control.WorkspaceByID(r.Context(), id) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return workspaceItem{}, false + } + if err != nil { + s.serverError(w, "Workspace konnte nicht geladen werden", err) + return workspaceItem{}, false + } + + return workspaceItem{ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02")}, true +} + +// handleWorkspaceSelect wechselt den Workspace der aktuellen Plattform-Admin-Sitzung. +// +// Technisch ist das eine neue Anmeldung mit demselben Subject-Schema wie beim +// Login (siehe resolveLoginKey in handlers_auth.go), nur ausgelöst durch einen +// Klick statt durch eine erneute Eingabe des ADMIN_TOKEN. +func (s *Server) handleWorkspaceSelect(w http.ResponseWriter, r *http.Request) { + id := r.FormValue("id") + + if _, err := s.control.WorkspaceByID(r.Context(), id); err != nil { + http.NotFound(w, r) + return + } + + s.setSessionCookie(w, r, "platform:"+id) + http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 5a663ab..1065f82 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -11,6 +11,7 @@ import ( "munin/internal/auth" "munin/internal/store" + "munin/internal/workspace" ) // requireSession prüft den Session-Cookie und legt die Sitzung im Context ab. @@ -48,11 +49,36 @@ func (s *Server) requireSession(next http.Handler) http.Handler { return } - next.ServeHTTP(w, r.WithContext(withSession(r.Context(), sess))) + ctx := withSession(r.Context(), sess) + + // Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) + // gibt es nichts zu öffnen — solche Sitzungen dürfen ausschließlich die + // Workspace-Auswahl aufrufen, siehe requireWorkspacePicked. + if sess.WorkspaceID != "" { + ws, err := s.workspaces.Get(sess.WorkspaceID) + if err != nil { + s.serverError(w, "Workspace konnte nicht geöffnet werden", err) + return + } + ctx = withWorkspace(ctx, ws) + } + + next.ServeHTTP(w, r.WithContext(ctx)) }) } // sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung. +// +// Ein Subject hat eine von drei Formen: +// +// - "platform" — Plattform-Admin, noch kein Workspace gewählt. +// - "platform:" — Plattform-Admin, mit gewähltem Workspace. +// - "key::" — Projekt-Zugang, immer an genau einen +// Workspace gebunden (den seines Keys). +// +// Die Workspace-ID steht direkt im (signierten, also fälschungssicheren) +// Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank +// der richtigen Workspace-Datei zuordnen. func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) { if subject == auth.SubjectPlatformAdmin { return Session{ @@ -62,26 +88,50 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b }, true } - keyID, ok := auth.KeyIDFromSubject(subject) + if rest, ok := strings.CutPrefix(subject, auth.SubjectPlatformAdmin+":"); ok { + ws, err := s.control.WorkspaceByID(r.Context(), rest) + if err != nil { + return Session{}, false + } + return Session{ + Subject: subject, + IsPlatformAdmin: true, + WorkspaceID: rest, + WorkspaceName: ws.Name, + Role: auth.RoleAdmin, + }, true + } + + rest, ok := auth.KeyIDFromSubject(subject) + if !ok { + return Session{}, false + } + workspaceID, keyID, ok := strings.Cut(rest, ":") if !ok { return Session{}, false } - resolved, err := s.store.APIKeyByID(r.Context(), keyID) + ws, err := s.workspaces.Get(workspaceID) + if err != nil { + return Session{}, false + } + + resolved, err := ws.Store.APIKeyByID(r.Context(), keyID) if err != nil || resolved.Revoked() { return Session{}, false } - project, err := s.store.ProjectByID(r.Context(), resolved.ProjectID) + project, err := ws.Store.ProjectByID(r.Context(), resolved.ProjectID) if err != nil { return Session{}, false } return Session{ - Subject: subject, - Key: resolved, - Project: project, - Role: auth.Role(resolved.Role), + Subject: subject, + WorkspaceID: workspaceID, + Key: resolved, + Project: project, + Role: auth.Role(resolved.Role), }, true } @@ -129,8 +179,16 @@ func (s *Server) requireProject(next http.Handler) http.Handler { return } + ws, ok := WorkspaceFrom(r) + if !ok { + // Plattform-Admin ohne gewählten Workspace: Projektseiten gibt es + // erst, nachdem einer gewählt wurde. + http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther) + return + } + slug := r.PathValue("project") - project, err := s.store.ProjectBySlug(r.Context(), s.workspaceID, slug) + project, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, slug) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return @@ -152,6 +210,22 @@ func (s *Server) requireProject(next http.Handler) http.Handler { }) } +// requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch. +// +// Betrifft nur den Plattform-Admin: Eine Projekt-Sitzung hat ihren Workspace +// immer schon über ihren Key (siehe sessionFromSubject). Der Plattform-Admin +// dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er +// verwalten will — dafür ist /admin/workspaces da. +func (s *Server) requireWorkspace(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := WorkspaceFrom(r); !ok { + http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + // requirePlatformAdmin lässt nur den Plattform-Admin durch. func (s *Server) requirePlatformAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -288,26 +362,42 @@ func (s *Server) APIAuth(next http.Handler) http.Handler { } if auth.LooksLikeKey(key) { - resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key)) + hash := auth.HashKey(key) + + // Erster Schritt: welcher Workspace? Erst danach lässt sich die + // richtige Datei überhaupt ansprechen (siehe ControlStore in + // internal/store/control.go und AUSBAUSTUFEN.md 4.4). + workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash) if err == nil { - s.limiter.Reset(ip) + var ws *workspace.Workspace + ws, err = s.workspaces.Get(workspaceID) + if err == nil { + var resolved store.ResolvedKey + resolved, err = ws.Store.ResolveKeyHash(r.Context(), hash) + if err == nil { + s.limiter.Reset(ip) - // Nebenbei vermerken, dass der Key benutzt wurde — damit man in - // der Verwaltung sieht, welche Keys noch aktiv sind. Ein Fehler - // dabei ist unerheblich und darf den Request nicht scheitern lassen. - _ = s.store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt) + // Nebenbei vermerken, dass der Key benutzt wurde — damit man + // in der Verwaltung sieht, welche Keys noch aktiv sind. Ein + // Fehler dabei ist unerheblich und darf den Request nicht + // scheitern lassen. + _ = ws.Store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt) - sess := Session{ - Subject: auth.SubjectKeyPrefix + resolved.Key.ID, - Key: resolved.Key, - Project: resolved.Project, - Role: auth.Role(resolved.Key.Role), + sess := Session{ + Subject: auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, + WorkspaceID: workspaceID, + Key: resolved.Key, + Project: resolved.Project, + Role: auth.Role(resolved.Key.Role), + } + + ctx := withSession(r.Context(), sess) + ctx = withProject(ctx, resolved.Project) + ctx = withWorkspace(ctx, ws) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } } - - ctx := withSession(r.Context(), sess) - ctx = withProject(ctx, resolved.Project) - next.ServeHTTP(w, r.WithContext(ctx)) - return } } @@ -320,9 +410,12 @@ func (s *Server) APIAuth(next http.Handler) http.Handler { } s.limiter.RecordFailure(ip) - s.store.LogAudit(r.Context(), store.AuditEvent{ - Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceMCP, IP: ip, - }) + + // Ein ungültiger Key lässt sich keinem Workspace zuordnen — es gibt + // also keine passende Datei für einen Audit-Eintrag. Das Protokoll + // verzeichnet nur Vorgänge innerhalb eines Workspace; ein anonymer + // Fehlversuch gehört ins allgemeine Server-Log. + log.Printf("Ungültiger API-Key von %s", ip) http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized) }) diff --git a/internal/web/server.go b/internal/web/server.go index 9bfd617..0b6dcec 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -9,8 +9,8 @@ import ( "munin/internal/auth" "munin/internal/export" - "munin/internal/search" "munin/internal/store" + "munin/internal/workspace" ) // Server bündelt alles, was die Handler brauchen. @@ -19,13 +19,12 @@ import ( // worauf sie zugreift, und im Test lässt sich ein Server mit anderen // Bestandteilen zusammensetzen. type Server struct { - store *store.Store - search *search.Registry - signer *auth.Signer - cookies auth.CookiePolicy - limiter *auth.RateLimiter - templates *template.Template - workspaceID string + workspaces *workspace.Manager + control *store.ControlStore + signer *auth.Signer + cookies auth.CookiePolicy + limiter *auth.RateLimiter + templates *template.Template // exporter ist nil, wenn EXPORT_DIR nicht gesetzt ist — dann blendet die // Oberfläche den Knopf aus, statt eine Funktion anzubieten, die nichts tut. @@ -44,13 +43,12 @@ type Server struct { // Config sammelt die Angaben zum Bauen eines Servers. type Config struct { - Store *store.Store - Search *search.Registry + Workspaces *workspace.Manager + Control *store.ControlStore Signer *auth.Signer Cookies auth.CookiePolicy Limiter *auth.RateLimiter Templates fs.FS - WorkspaceID string AdminToken string Exporter *export.Exporter WriteBudget *auth.WriteBudget @@ -66,13 +64,12 @@ func NewServer(cfg Config) (*Server, error) { } return &Server{ - store: cfg.Store, - search: cfg.Search, + workspaces: cfg.Workspaces, + control: cfg.Control, signer: cfg.Signer, cookies: cfg.Cookies, limiter: cfg.Limiter, templates: tmpl, - workspaceID: cfg.WorkspaceID, exporter: cfg.Exporter, writeBudget: cfg.WriteBudget, adminTokenHash: auth.HashKey(cfg.AdminToken), @@ -114,27 +111,61 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { mux.Handle("GET /{$}", geschuetzt(s.handleHome)) - // Plattform-Verwaltung. - mux.Handle("GET /admin/projects", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectList))) - mux.Handle("POST /admin/projects/create", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectCreate))) - mux.Handle("POST /admin/projects/delete", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectDelete))) + // Eingebaute Hilfe — für jede Sitzung, auch ohne gewählten Workspace. + // "/hilfe" zeigt die Startseite, "/hilfe/{seite}" eine bestimmte Seite. + mux.Handle("GET /hilfe", geschuetzt(s.handleHilfe)) + mux.Handle("GET /hilfe/{seite}", geschuetzt(s.handleHilfe)) + + // Workspace-Auswahl des Plattform-Admins — erreichbar auch ohne bereits + // gewählten Workspace, siehe handleWorkspaceList. + mux.Handle("GET /admin/workspaces", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceList))) + mux.Handle("POST /admin/workspaces/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceCreate))) + mux.Handle("POST /admin/workspaces/select", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceSelect))) + mux.Handle("POST /admin/workspaces/delete", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceDelete))) + // Wie bei /admin/projects/{slug}/edit: eigener Pfad statt {id} direkt unter + // /admin/workspaces, sonst wäre er mit "create"/"select"/"delete" mehrdeutig. + mux.Handle("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm))) + mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave))) + + // Plattform-Verwaltung. requireWorkspace stellt sicher, dass der + // Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte + // verwaltet — siehe requireWorkspace in middleware.go. + imWorkspace := func(h http.HandlerFunc) http.Handler { + return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h))) + } + mux.Handle("GET /admin/projects", imWorkspace(s.handleProjectList)) + mux.Handle("POST /admin/projects/create", imWorkspace(s.handleProjectCreate)) + mux.Handle("POST /admin/projects/delete", imWorkspace(s.handleProjectDelete)) // Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst // wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen // Projekt-Bezeichner. - mux.Handle("GET /admin/projects/{slug}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectEditForm))) - mux.Handle("POST /admin/projects/{slug}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectEditSave))) - mux.Handle("GET /admin/audit", geschuetzt(s.requirePlatformAdminFunc(s.handleGlobalAudit))) - mux.Handle("POST /admin/export", geschuetzt(s.requirePlatformAdminFunc(s.handleExport))) + mux.Handle("GET /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditForm)) + mux.Handle("POST /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditSave)) + mux.Handle("GET /admin/audit", imWorkspace(s.handleGlobalAudit)) + mux.Handle("POST /admin/export", imWorkspace(s.handleExport)) // Projektseiten. mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList)) mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew)) + mux.Handle("POST /p/{project}/preview", mitSchreibrecht(s.handleEntryPreview)) + mux.Handle("GET /p/{project}/entries/suggest", mitSchreibrecht(s.handleEntrySuggest)) mux.Handle("GET /p/{project}/e/{slug}", imProjekt(s.handleEntryView)) + mux.Handle("GET /p/{project}/e/{slug}/versions", imProjekt(s.handleEntryVersions)) + mux.Handle("GET /p/{project}/e/{slug}/versions/{version}", imProjekt(s.handleEntryDiff)) + mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", mitSchreibrecht(s.handleEntryRestore)) + mux.Handle("GET /p/{project}/graph", imProjekt(s.handleEntryGraph)) + mux.Handle("GET /p/{project}/graph/data", imProjekt(s.handleEntryGraphData)) mux.Handle("GET /p/{project}/e/{slug}/edit", mitSchreibrecht(s.handleEntryEdit)) mux.Handle("POST /p/{project}/e/{slug}/save", mitSchreibrecht(s.handleEntrySave)) mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete)) mux.Handle("POST /p/{project}/save", mitSchreibrecht(s.handleEntrySave)) + mux.Handle("POST /p/{project}/entries/bulk-tags", mitSchreibrecht(s.handleEntryBulkTags)) + // Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_papierkorb.go. + mux.Handle("GET /p/{project}/trash", mitSchreibrecht(s.handleTrashList)) + mux.Handle("POST /p/{project}/trash/restore", mitSchreibrecht(s.handleTrashRestore)) + mux.Handle("POST /p/{project}/trash/purge", mitSchreibrecht(s.handleTrashPurge)) mux.Handle("GET /p/{project}/audit", imProjekt(s.handleProjectAudit)) + mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage)) // Key-Verwaltung. mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList))))) @@ -166,7 +197,7 @@ type pageData struct { ExportEnabled bool // Bereich markiert den aktiven Punkt in der Seitenleiste: - // "eintraege", "zugaenge", "protokoll" oder "projekte". + // "eintraege", "zugaenge", "protokoll", "projekte" oder "hilfe". Bereich string // Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit @@ -232,8 +263,15 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) navData { var nav navData + // Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) gibt + // es weder Projekte noch Schlagworte zu zeigen. + ws, ok := WorkspaceFrom(r) + if !ok { + return nav + } + if sess.IsPlatformAdmin { - projekte, err := s.store.ListProjects(r.Context(), s.workspaceID) + projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err) } else { @@ -242,7 +280,7 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) } if project.ID != "" { - tags, err := s.store.ListTags(r.Context(), project.ID) + tags, err := ws.Store.ListTags(r.Context(), project.ID) if err != nil { log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err) } else { @@ -253,6 +291,16 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) return nav } +// workspaceAndSession liest Workspace und Sitzung der aktuellen Anfrage. +// +// Nur für Handler gedacht, die über requireWorkspace oder requireProject +// erreicht werden — dort ist immer beides gesetzt (siehe middleware.go). +func (s *Server) workspaceAndSession(r *http.Request) (*workspace.Workspace, Session) { + ws, _ := WorkspaceFrom(r) + sess, _ := SessionFrom(r) + return ws, sess +} + // serverError protokolliert einen unerwarteten Fehler und meldet dem Aufrufer // eine allgemeine Störung. // diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 63ef0c8..15b9923 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -3,6 +3,9 @@ package web import ( "bytes" "context" + "encoding/json" + "errors" + "fmt" "io/fs" "log" "net/http" @@ -10,13 +13,15 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "testing" "time" "munin/internal/auth" - "munin/internal/search" + "munin/internal/export" "munin/internal/store" + "munin/internal/workspace" ) const testSecret = "ein-test-secret-das-lang-genug-ist-fuer-tests" @@ -26,17 +31,33 @@ const testAdminToken = "test-admin-token-lang-genug" func testServer(t *testing.T) (*Server, *store.Store, store.Project) { t.Helper() - db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + tempDir := t.TempDir() + + control, err := store.OpenControl(filepath.Join(tempDir, "control.db")) if err != nil { - t.Fatalf("Datenbank: %v", err) + t.Fatalf("Kontroll-Datenbank: %v", err) } - t.Cleanup(func() { db.Close() }) + t.Cleanup(func() { control.Close() }) + + workspacesDir := filepath.Join(tempDir, "workspaces") + if err := os.MkdirAll(workspacesDir, 0750); err != nil { + t.Fatal(err) + } + workspaces := workspace.NewManager(workspacesDir) + t.Cleanup(func() { workspaces.Close() }) ctx := context.Background() - ws, err := db.EnsureWorkspace(ctx, "Test") + ws, err := control.EnsureDefaultWorkspace(ctx, "Test") if err != nil { t.Fatal(err) } + + w, err := workspaces.Get(ws.ID) + if err != nil { + t.Fatalf("Workspace: %v", err) + } + db := w.Store + projekt, err := db.CreateProject(ctx, ws.ID, "testprojekt", "Testprojekt", "") if err != nil { t.Fatal(err) @@ -45,13 +66,12 @@ func testServer(t *testing.T) (*Server, *store.Store, store.Project) { // os.DirFS("../..") zeigt vom Package-Verzeichnis auf die Projektwurzel, // wo templates/ liegt. srv, err := NewServer(Config{ - Store: db, - Search: search.NewRegistry(db), + Workspaces: workspaces, + Control: control, Signer: auth.NewSigner([]byte(testSecret)), Cookies: auth.NewCookiePolicy("false"), Limiter: auth.NewRateLimiter(), Templates: templatesForTest(t), - WorkspaceID: ws.ID, AdminToken: testAdminToken, WriteBudget: auth.NewWriteBudget(1000, time.Hour), }) @@ -78,19 +98,65 @@ func testHandler(t *testing.T, srv *Server) http.Handler { } // erzeugeKey legt einen API-Key an und gibt den Klartext zurück. -func erzeugeKey(t *testing.T, db *store.Store, projectID, name string, rolle auth.Role) string { +// +// Trägt den Key auch im Routing-Index der Kontroll-Datenbank ein — ohne den +// würden Login und APIAuth ihn nicht mehr finden (siehe ControlStore.IndexKey +// und die entsprechenden Aufrufe in handlers_admin.go). +func erzeugeKey(t *testing.T, srv *Server, db *store.Store, projectID, name string, rolle auth.Role) string { t.Helper() klartext, err := auth.GenerateKey() if err != nil { t.Fatal(err) } - if _, err := db.CreateAPIKey(context.Background(), projectID, name, auth.HashKey(klartext), string(rolle)); err != nil { + hash := auth.HashKey(klartext) + if _, err := db.CreateAPIKey(context.Background(), projectID, name, hash, string(rolle)); err != nil { + t.Fatal(err) + } + + projekt, err := db.ProjectByID(context.Background(), projectID) + if err != nil { + t.Fatal(err) + } + if err := srv.control.IndexKey(context.Background(), hash, projekt.WorkspaceID); err != nil { t.Fatal(err) } return klartext } +// waehleWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und +// gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList +// in der Oberfläche anbietet (siehe handlers_workspaces.go). +func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie { + t.Helper() + + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin) + formular := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + for _, c := range w.Result().Cookies() { + if c.Name == auth.SessionCookieName && c.Value != "" { + return c + } + } + t.Fatalf("Workspace-Auswahl lieferte keinen Cookie (Status %d)", w.Code) + return nil +} + +// anmeldenAlsPlattformAdmin meldet den Plattform-Admin an UND wählt gleich den +// gegebenen Workspace — die meisten Tests brauchen genau das, nicht den +// Zwischenschritt über die Auswahlseite. +func anmeldenAlsPlattformAdmin(t *testing.T, h http.Handler, workspaceID string) *http.Cookie { + t.Helper() + cookie := anmelden(t, h, testAdminToken) + return waehleWorkspace(t, h, cookie, workspaceID) +} + // anmelden führt eine Anmeldung durch und gibt den Session-Cookie zurück. func anmelden(t *testing.T, h http.Handler, key string) *http.Cookie { t.Helper() @@ -135,7 +201,7 @@ func TestLoginWithProjectKey(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleWrite) + key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleWrite) cookie := anmelden(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil) @@ -177,7 +243,7 @@ func TestRevokedKeyEndsSession(t *testing.T) { h := testHandler(t, srv) ctx := context.Background() - key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleWrite) + key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleWrite) cookie := anmelden(t, h, key) keys, _ := db.ListAPIKeys(ctx, projekt.ID) @@ -205,7 +271,7 @@ func TestCSRFBlocksForgedPost(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleAdmin) + key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin) cookie := anmelden(t, h, key) formular := url.Values{"name": {"geklaut"}, "role": {"admin"}} @@ -230,12 +296,12 @@ func TestCSRFAllowsRealForm(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleAdmin) + key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin) cookie := anmelden(t, h, key) // Das Token so berechnen, wie es das Template tut. keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - subject := auth.SubjectKeyPrefix + keys[0].ID + subject := auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID token := auth.NewSigner([]byte(testSecret)).CSRFToken(subject) formular := url.Values{"name": {"zweiter"}, "role": {"read"}, auth.CSRFFieldName: {token}} @@ -258,6 +324,15 @@ func TestCSRFAllowsRealForm(t *testing.T) { if !strings.Contains(w.Body.String(), auth.KeyPrefix) { t.Error("der neue Key wurde nicht angezeigt") } + + // Die Konfigurationsblöcke tragen die echte Adresse, keinen Platzhalter + // (AUSBAUSTUFEN.md 5.2). httptest setzt als Host "example.com". + if strings.Contains(w.Body.String(), "DEIN-SERVER") { + t.Error("die Zugangsseite zeigt noch den Platzhalter DEIN-SERVER") + } + if !strings.Contains(w.Body.String(), "https://example.com/mcp") { + t.Error("die Zugangsseite zeigt die Server-Adresse nicht") + } } // ── Abschottung ────────────────────────────────────────────────────────────── @@ -269,8 +344,7 @@ func TestProjectIsolation(t *testing.T) { h := testHandler(t, srv) ctx := context.Background() - ws, _ := db.EnsureWorkspace(ctx, "Test") - projektB, err := db.CreateProject(ctx, ws.ID, "fremd", "Fremdes Projekt", "") + projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "fremd", "Fremdes Projekt", "") if err != nil { t.Fatal(err) } @@ -283,7 +357,7 @@ func TestProjectIsolation(t *testing.T) { t.Fatal(err) } - keyA := erzeugeKey(t, db, projektA.ID, "a", auth.RoleAdmin) + keyA := erzeugeKey(t, srv, db, projektA.ID, "a", auth.RoleAdmin) cookie := anmelden(t, h, keyA) // Jede Route des fremden Projekts muss als "nicht vorhanden" antworten — @@ -314,13 +388,258 @@ func TestProjectIsolation(t *testing.T) { } } +// TestWorkspaceIsolation ist der wichtigste Test dieser Ausbaustufe: Zwei +// gleichzeitig offene Workspace-Dateien im selben Prozess dürfen sich +// strukturell nicht sehen — auch nicht über einen fremden, aber gültigen Key. +func TestWorkspaceIsolation(t *testing.T) { + srv, _, projektA := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + // Ein zweiter Workspace, komplett unabhängig vom ersten — eigene Datei, + // eigenes Projekt, eigener Key. + wsB, err := srv.control.CreateWorkspace(ctx, "Andere Abteilung") + if err != nil { + t.Fatal(err) + } + workspaceB, err := srv.workspaces.Get(wsB.ID) + if err != nil { + t.Fatal(err) + } + projektB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "") + if err != nil { + t.Fatal(err) + } + if _, err := workspaceB.Store.PutEntry(ctx, store.EntryWrite{ + ProjectID: projektB.ID, Slug: "geheim", Title: "Geheim", + Content: "Vertrauliches aus Workspace B", Source: store.SourceHuman, + }); err != nil { + t.Fatal(err) + } + keyB := erzeugeKey(t, srv, workspaceB.Store, projektB.ID, "b", auth.RoleAdmin) + + // Mit dem Key aus Workspace B angemeldet: Projekt A (Workspace A) darf + // unter keinen Umständen erreichbar sein, obwohl der Prozess beide + // Dateien gleichzeitig offen hält. + cookie := anmelden(t, h, keyB) + + r := httptest.NewRequest(http.MethodGet, "/p/"+projektA.Slug+"/", nil) + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusNotFound { + t.Errorf("Status = %d, erwartet 404 — Workspace B kam an ein Projekt aus Workspace A", w.Code) + } +} + +// TestWorkspaceCreateSelectsItAndRedirects prüft den Weg aus der Oberfläche: +// Ein neuer Workspace wird angelegt, die Sitzung wechselt automatisch dorthin, +// und der Admin landet direkt bei der Projektverwaltung — ohne den Umweg über +// die Auswahlseite. +func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + token := auth.NewSigner([]byte(testSecret)). + CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) + + formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/admin/projects" { + t.Fatalf("Status = %d, Location = %q — erwartet Weiterleitung zu /admin/projects", + w.Code, w.Header().Get("Location")) + } + + workspaces, err := srv.control.ListWorkspaces(context.Background()) + if err != nil { + t.Fatal(err) + } + var gefunden bool + for _, ws := range workspaces { + if ws.Name == "Marketing" { + gefunden = true + } + } + if !gefunden { + t.Error("neuer Workspace \"Marketing\" steht nicht in der Kontroll-Datenbank") + } + + // Die neue Sitzung muss den neuen Workspace tragen — ein Aufruf von + // /admin/projects mit dem zurückgegebenen Cookie darf nicht mehr zur + // Auswahlseite zurückschicken. + var neuerCookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == auth.SessionCookieName { + neuerCookie = c + } + } + if neuerCookie == nil { + t.Fatal("keine neue Sitzung nach dem Anlegen ausgestellt") + } + + r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) + r2.AddCookie(neuerCookie) + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, r2) + + if w2.Code != http.StatusOK { + t.Errorf("Status = %d, erwartet 200 — die neue Sitzung landete nicht im neuen Workspace", w2.Code) + } +} + +// TestWorkspaceListShowsProjectCount prüft, dass die Übersicht die +// tatsächliche Projektzahl je Workspace zeigt, nicht nur Name und Datum. +func TestWorkspaceListShowsProjectCount(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + if _, err := db.CreateProject(context.Background(), projekt.WorkspaceID, "zweites", "Zweites", ""); err != nil { + t.Fatal(err) + } + + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + r := httptest.NewRequest(http.MethodGet, "/admin/workspaces", nil) + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet 200", w.Code) + } + if !strings.Contains(w.Body.String(), "") { + t.Error("Projektzahl \"2\" steht nicht in der Übersicht") + } +} + +// TestWorkspaceRename prüft das Umbenennen über /admin/workspaces/{id}/edit. +func TestWorkspaceRename(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + token := auth.NewSigner([]byte(testSecret)). + CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) + + formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+projekt.WorkspaceID+"/edit", + strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusSeeOther { + t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther) + } + + ws, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID) + if err != nil { + t.Fatal(err) + } + if ws.Name != "Umbenannt" { + t.Errorf("Name = %q, erwartet \"Umbenannt\"", ws.Name) + } +} + +// TestWorkspaceDeleteRequiresConfirmation prüft die Bestätigungsabfrage — ein +// Klick allein darf nicht reichen, dahinter verschwindet alles im Workspace. +func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + token := auth.NewSigner([]byte(testSecret)). + CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) + + formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest) + } + if _, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID); err != nil { + t.Error("Workspace wurde trotz falscher Bestätigung gelöscht") + } +} + +// TestWorkspaceDeleteRemovesEverythingAndResetsSession ist der wichtigste +// Test hier: Löschen entfernt Kontroll-Eintrag UND Datei, und eine Sitzung im +// gelöschten Workspace wird auf "kein Workspace gewählt" zurückgesetzt statt +// den Admin einfach auszusperren. +func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) { + srv, _, projekt := testServer(t) + h := testHandler(t, srv) + ws, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID) + if err != nil { + t.Fatal(err) + } + + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + token := auth.NewSigner([]byte(testSecret)). + CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID) + + formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusSeeOther { + t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther) + } + + if _, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID); !errors.Is(err, store.ErrNotFound) { + t.Errorf("Workspace steht nach dem Löschen noch in der Kontroll-Datenbank (err=%v)", err) + } + + // Die neue Sitzung ist die des Admins ohne Workspace — /admin/projects + // muss jetzt zur Auswahl zurückschicken statt eine tote Sitzung zu zeigen. + var neuerCookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == auth.SessionCookieName { + neuerCookie = c + } + } + if neuerCookie == nil { + t.Fatal("keine neue Sitzung nach dem Löschen ausgestellt") + } + + r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) + r2.AddCookie(neuerCookie) + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, r2) + + if w2.Code != http.StatusSeeOther || w2.Header().Get("Location") != "/admin/workspaces" { + t.Errorf("Status = %d, Location = %q — erwartet Weiterleitung zur Workspace-Auswahl", + w2.Code, w2.Header().Get("Location")) + } +} + // TestPlatformAdminOnly prüft, dass die Projektverwaltung dem Plattform-Admin // vorbehalten bleibt. func TestPlatformAdminOnly(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleAdmin) + key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin) cookie := anmelden(t, h, key) r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) @@ -335,10 +654,10 @@ func TestPlatformAdminOnly(t *testing.T) { } func TestPlatformAdminCanAccessAnyProject(t *testing.T) { - srv, _, _ := testServer(t) + srv, _, projekt := testServer(t) h := testHandler(t, srv) - cookie := anmelden(t, h, testAdminToken) + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil) r.AddCookie(cookie) @@ -358,7 +677,7 @@ func TestReadRoleCannotWrite(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, db, projekt.ID, "nur-lesen", auth.RoleRead) + key := erzeugeKey(t, srv, db, projekt.ID, "nur-lesen", auth.RoleRead) cookie := anmelden(t, h, key) schreibrouten := []struct{ methode, pfad string }{ @@ -387,7 +706,7 @@ func TestWriteRoleCannotManageKeys(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, db, projekt.ID, "schreiber", auth.RoleWrite) + key := erzeugeKey(t, srv, db, projekt.ID, "schreiber", auth.RoleWrite) cookie := anmelden(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil) @@ -496,7 +815,7 @@ func TestAPIAuthRejectsBadKey(t *testing.T) { func TestAPIAuthAcceptsValidKey(t *testing.T) { srv, db, projekt := testServer(t) - key := erzeugeKey(t, db, projekt.ID, "agent", auth.RoleWrite) + key := erzeugeKey(t, srv, db, projekt.ID, "agent", auth.RoleWrite) var gesehen store.Project h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -518,7 +837,7 @@ func TestAPIAuthAcceptsValidKey(t *testing.T) { // anderen hinter derselben Adresse mitsperren. func TestAPIAuthValidKeyBypassesRateLimit(t *testing.T) { srv, db, projekt := testServer(t) - key := erzeugeKey(t, db, projekt.ID, "agent", auth.RoleWrite) + key := erzeugeKey(t, srv, db, projekt.ID, "agent", auth.RoleWrite) var erreicht bool h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true })) @@ -550,11 +869,11 @@ func TestEntryCreateAndView(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, db, projekt.ID, "mensch", auth.RoleWrite) + key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) cookie := anmelden(t, h, key) keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + keys[0].ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) formular := url.Values{ "title": {"Deployment-Prozess"}, @@ -588,6 +907,24 @@ func TestEntryCreateAndView(t *testing.T) { t.Errorf("Tags = %v, erwartet zwei", e.Tags) } + // Das Protokoll vermerkt, dass der Eintrag neu angelegt wurde. + ereignisse, err := db.ListAudit(context.Background(), store.AuditFilter{ProjectID: projekt.ID}) + if err != nil { + t.Fatal(err) + } + var gefunden bool + for _, ev := range ereignisse { + if ev.Action == store.ActionEntryWrite && ev.EntrySlug == "deployment-prozess" { + gefunden = true + if ev.Detail != store.DetailEntryCreated { + t.Errorf("Detail = %q, erwartet %q", ev.Detail, store.DetailEntryCreated) + } + } + } + if !gefunden { + t.Error("Schreibvorgang fehlt im Protokoll") + } + // Die Detailansicht muss den Verweis als Link darstellen. rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/deployment-prozess", nil) rv.AddCookie(cookie) @@ -603,6 +940,488 @@ func TestEntryCreateAndView(t *testing.T) { } } +// TestEntryBulkTags prüft die Sammelbearbeitung: zwei ausgewählte Einträge +// bekommen ein Schlagwort dazu und eines weg, ein dritter, nicht ausgewählter +// bleibt unangetastet. +func TestEntryBulkTags(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", + Tags: []string{"alt"}, Source: store.SourceHuman}) + b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B", + Tags: []string{"alt"}, Source: store.SourceHuman}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "c", Title: "C", + Tags: []string{"alt"}, Source: store.SourceHuman}) + + key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) + cookie := anmelden(t, h, key) + + keys, _ := db.ListAPIKeys(ctx, projekt.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + + formular := url.Values{ + "ids": {a.ID, b.ID}, + "add_tags": {"neu"}, + "remove_tags": {"alt"}, + auth.CSRFFieldName: {token}, + } + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-tags", strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusSeeOther { + t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String()) + } + + neuA, _ := db.EntryBySlug(ctx, projekt.ID, "a") + if len(neuA.Tags) != 1 || neuA.Tags[0] != "neu" { + t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags) + } + + unveraendertC, _ := db.EntryBySlug(ctx, projekt.ID, "c") + if len(unveraendertC.Tags) != 1 || unveraendertC.Tags[0] != "alt" { + t.Errorf("Tags von C = %v — C war nicht ausgewählt", unveraendertC.Tags) + } +} + +// TestEntryVersionsAndDiff prüft den Verlauf und den Vergleich zweier +// Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung", +// wenn es keine spätere abgelöste Fassung mehr gibt. +func TestEntryVersionsAndDiff(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", + Content: "Zeile 1\nZeile 2", Source: store.SourceHuman, Author: "tom"}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", + Content: "Zeile 1\nGeändert", Source: store.SourceHuman, Author: "tom"}) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + // Verlaufsseite: Fassung 1 muss als vergleichbar auftauchen. + rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions", nil) + rv.AddCookie(cookie) + wv := httptest.NewRecorder() + h.ServeHTTP(wv, rv) + + if wv.Code != http.StatusOK { + t.Fatalf("Verlauf: Status %d", wv.Code) + } + if !strings.Contains(wv.Body.String(), "/p/testprojekt/e/e/versions/1") { + t.Error("Verlauf verlinkt nicht auf den Vergleich für Fassung 1") + } + + // Vergleich Fassung 1 gegen die aktuelle (Fassung 2) — es gibt keine + // weitere abgelöste Fassung dazwischen. + rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil) + rd.AddCookie(cookie) + wd := httptest.NewRecorder() + h.ServeHTTP(wd, rd) + + if wd.Code != http.StatusOK { + t.Fatalf("Vergleich: Status %d", wd.Code) + } + body := wd.Body.String() + if !strings.Contains(body, "Zeile 2") || !strings.Contains(body, "Geändert") { + t.Error("der Vergleich zeigt weder die entfernte noch die neue Zeile") + } + if !strings.Contains(body, "aktuell") { + t.Error("der Vergleich markiert die aktuelle Fassung nicht als solche") + } + + // Eine nicht existierende Fassungsnummer muss 404 liefern, nicht + // abstürzen. + rf := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/99", nil) + rf.AddCookie(cookie) + wf := httptest.NewRecorder() + h.ServeHTTP(wf, rf) + if wf.Code != http.StatusNotFound { + t.Errorf("unbekannte Fassung: Status %d, erwartet 404", wf.Code) + } +} + +// wiederherstellenAnfrage baut den POST auf den Wiederherstellen-Knopf — +// so, wie ihn das Formular in entry-diff.html abschickt. +func wiederherstellenAnfrage(t *testing.T, db *store.Store, projekt store.Project, cookie *http.Cookie, fassung, aktuell string) *http.Request { + t.Helper() + keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + + formular := url.Values{"current_version": {aktuell}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/e/e/versions/"+fassung+"/restore", + strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + return r +} + +// TestEntryRestore: Eine alte Fassung zurückholen legt eine NEUE Fassung mit +// dem alten Inhalt an — der bisherige Stand wandert in den Verlauf, und das +// Protokoll vermerkt, woher der Inhalt kam. +func TestEntryRestore(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Alt", + Content: "guter Text", Tags: []string{"a"}, Source: store.SourceHuman, Author: "tom"}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Kaputt", + Content: "von einem Agenten zerstört", Tags: []string{"b"}, Source: store.SourceAgent, Author: "agent"}) + + key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) + cookie := anmelden(t, h, key) + + // Die Vergleichsseite zeigt den Knopf für Schreibberechtigte. + rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil) + rd.AddCookie(cookie) + wd := httptest.NewRecorder() + h.ServeHTTP(wd, rd) + if !strings.Contains(wd.Body.String(), "/p/testprojekt/e/e/versions/1/restore") { + t.Error("die Vergleichsseite bietet keine Wiederherstellung an") + } + + w := httptest.NewRecorder() + h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2")) + if w.Code != http.StatusSeeOther { + t.Fatalf("Status = %d, erwartet 303 (%s)", w.Code, w.Body.String()) + } + + e, err := db.EntryBySlug(ctx, projekt.ID, "e") + if err != nil { + t.Fatal(err) + } + if e.Version != 3 { + t.Errorf("Fassung %d, erwartet 3 — Wiederherstellen muss eine neue Fassung anlegen", e.Version) + } + if e.Title != "Alt" || e.Content != "guter Text" { + t.Errorf("Inhalt nicht wiederhergestellt: %q / %q", e.Title, e.Content) + } + if len(e.Tags) != 1 || e.Tags[0] != "a" { + t.Errorf("Schlagworte = %v, erwartet [a]", e.Tags) + } + + // Der verdrängte Stand ist nicht verloren, sondern jetzt Fassung 2 im Verlauf. + zwei, err := db.EntryVersionByNumber(ctx, e.ID, 2) + if err != nil || zwei.Content != "von einem Agenten zerstört" { + t.Errorf("verdrängte Fassung fehlt im Verlauf: %v / %q", err, zwei.Content) + } + + ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) + gefunden := false + for _, ev := range ereignisse { + if ev.Action == store.ActionEntryWrite && ev.Detail == store.EntryRestoreDetail(1) { + gefunden = true + } + } + if !gefunden { + t.Error("das Protokoll vermerkt die Wiederherstellung nicht") + } +} + +// TestEntryRestoreConflict: Hat sich der Eintrag seit dem Anzeigen geändert, +// wird nichts überschrieben. +func TestEntryRestoreConflict(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + for _, inhalt := range []string{"eins", "zwei", "drei"} { + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", + Content: inhalt, Source: store.SourceHuman}) + } + + key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) + cookie := anmelden(t, h, key) + + // Die Seite wurde bei Fassung 2 angezeigt, inzwischen gilt Fassung 3. + w := httptest.NewRecorder() + h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2")) + if w.Code != http.StatusConflict { + t.Fatalf("Status = %d, erwartet 409", w.Code) + } + + e, _ := db.EntryBySlug(ctx, projekt.ID, "e") + if e.Version != 3 || e.Content != "drei" { + t.Errorf("Eintrag wurde trotz Konflikt verändert: Fassung %d, %q", e.Version, e.Content) + } + + // Ohne Fassungsangabe wird ebenfalls abgelehnt, statt blind zu schreiben. + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "")) + if w2.Code != http.StatusBadRequest { + t.Errorf("ohne current_version: Status %d, erwartet 400", w2.Code) + } +} + +// TestEntryRestoreNeedsWriteRole: Lesende sehen keinen Knopf und kommen auch +// direkt nicht durch. +func TestEntryRestoreNeedsWriteRole(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Content: "eins", Source: store.SourceHuman}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Content: "zwei", Source: store.SourceHuman}) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil) + rd.AddCookie(cookie) + wd := httptest.NewRecorder() + h.ServeHTTP(wd, rd) + if strings.Contains(wd.Body.String(), "/restore") { + t.Error("Leserolle bekommt den Wiederherstellen-Knopf angezeigt") + } + + w := httptest.NewRecorder() + h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2")) + if w.Code != http.StatusForbidden { + t.Errorf("Status = %d, erwartet 403", w.Code) + } + e, _ := db.EntryBySlug(ctx, projekt.ID, "e") + if e.Content != "zwei" { + t.Error("Leserolle konnte eine Fassung wiederherstellen") + } +} + +// TestEntryPreviewRendersMarkdown prüft den Vorschau-Endpunkt: gleicher +// Renderer wie die Detailansicht, per POST ohne dass etwas gespeichert wird. +func TestEntryPreviewRendersMarkdown(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) + cookie := anmelden(t, h, key) + + keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + + formular := url.Values{ + "content": {"# Überschrift\n\nEin Verweis auf [[anderer-eintrag]]."}, + auth.CSRFFieldName: {token}, + } + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, "

Überschrift

") { + t.Errorf("Vorschau enthält keine gerenderte Überschrift: %s", body) + } + if !strings.Contains(body, "/p/testprojekt/e/anderer-eintrag") { + t.Error("Vorschau löst den [[…]]-Verweis nicht auf") + } + + // Es wurde nichts angelegt — die Vorschau speichert nicht. + anzahl, _ := db.CountEntries(context.Background(), projekt.ID) + if anzahl != 0 { + t.Errorf("%d Einträge nach reiner Vorschau, erwartet 0", anzahl) + } +} + +// TestEntryPreviewRequiresWriteRole: ein Nur-Lese-Zugang darf die Vorschau +// nicht aufrufen — konsistent mit dem Bearbeitungsformular selbst. +func TestEntryPreviewRequiresWriteRole(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + + formular := url.Values{"content": {"Text"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(formular.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusForbidden { + t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden) + } +} + +// TestEntrySuggest prüft die Autovervollständigung: Suche mit Begriff liefert +// den passenden Eintrag, ohne Begriff die zuletzt geänderten. +func TestEntrySuggest(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "deployment-prozess", + Title: "Deployment-Prozess", Content: "Wie wir deployen", Source: store.SourceHuman}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "urlaubsantrag", + Title: "Urlaubsantrag", Content: "Formular für Urlaub", Source: store.SourceHuman}) + + key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) + cookie := anmelden(t, h, key) + + r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/entries/suggest?q=deploy", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String()) + } + var treffer []suggestItem + if err := json.Unmarshal(w.Body.Bytes(), &treffer); err != nil { + t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String()) + } + if len(treffer) != 1 || treffer[0].Slug != "deployment-prozess" { + t.Errorf("Treffer = %+v, erwartet genau \"deployment-prozess\"", treffer) + } + + // Ohne Suchbegriff: beide Einträge kommen zurück, keine leere Antwort. + r2 := httptest.NewRequest(http.MethodGet, "/p/testprojekt/entries/suggest", nil) + r2.AddCookie(cookie) + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, r2) + + var alle []suggestItem + if err := json.Unmarshal(w2.Body.Bytes(), &alle); err != nil { + t.Fatalf("Antwort ist kein gültiges JSON: %v", err) + } + if len(alle) != 2 { + t.Errorf("%d Vorschläge ohne Suchbegriff, erwartet 2", len(alle)) + } +} + +// TestEntryGraphData prüft den JSON-Endpunkt der Graph-Ansicht: vorhandene +// Einträge als Knoten, ein Verweis auf einen fehlenden Bezeichner erzeugt +// trotzdem einen (nicht existierenden) Knoten. +func TestEntryGraphData(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", + Content: "verweist auf [[b]] und [[fehlt]]", Source: store.SourceHuman}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B", + Content: "kein Verweis", Source: store.SourceHuman}) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + // Nur Lesen reicht — die Graph-Ansicht selbst hängt an imProjekt, nicht + // an mitSchreibrecht. + r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String()) + } + + var daten graphResponse + if err := json.Unmarshal(w.Body.Bytes(), &daten); err != nil { + t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String()) + } + + if len(daten.Nodes) != 3 { + t.Fatalf("%d Knoten, erwartet 3 (a, b, fehlt)", len(daten.Nodes)) + } + if len(daten.Edges) != 2 { + t.Errorf("%d Kanten, erwartet 2", len(daten.Edges)) + } + + nachSlug := make(map[string]graphNode, len(daten.Nodes)) + for _, n := range daten.Nodes { + nachSlug[n.Slug] = n + } + if !nachSlug["a"].Exists { + t.Error("Knoten \"a\" sollte Exists = true tragen") + } + if nachSlug["fehlt"].Exists { + t.Error("Knoten \"fehlt\" sollte Exists = false tragen — es gibt keinen Eintrag dazu") + } + if daten.Truncated { + t.Error("Truncated = true bei nur drei Knoten") + } +} + +// TestEntryGraphPageRenders prüft, dass die Graph-Seite selbst ausliefert +// und das Skript samt Endpunkt-Adresse einbindet. +func TestEntryGraphPageRenders(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `data-graph="/p/testprojekt/graph/data"`) { + t.Error("die Seite bindet den Graph-Endpunkt nicht ein") + } + if !strings.Contains(body, "/static/graph.js") { + t.Error("die Seite lädt graph.js nicht") + } +} + +// TestEntryGraphDataTruncatesLargeProjects prüft die Obergrenze +// (graphNodeLimit): Mehr Einträge als das Limit führen zu Truncated = true +// und genau graphNodeLimit Knoten in der Antwort, nicht mehr. +func TestEntryGraphDataTruncatesLargeProjects(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + for i := 0; i < graphNodeLimit+5; i++ { + slug := fmt.Sprintf("eintrag-%03d", i) + db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: slug, Title: slug, + Content: "kein Verweis", Source: store.SourceHuman}) + } + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + var daten graphResponse + if err := json.Unmarshal(w.Body.Bytes(), &daten); err != nil { + t.Fatalf("Antwort ist kein gültiges JSON: %v", err) + } + + if !daten.Truncated { + t.Error("Truncated = false, obwohl mehr Einträge als graphNodeLimit existieren") + } + if len(daten.Nodes) != graphNodeLimit { + t.Errorf("%d Knoten in der Antwort, erwartet genau graphNodeLimit (%d)", len(daten.Nodes), graphNodeLimit) + } + if daten.Total != graphNodeLimit+5 { + t.Errorf("Total = %d, erwartet %d", daten.Total, graphNodeLimit+5) + } +} + // TestEntryContentIsEscaped ist ein Sicherheitstest: Eingeschleustes HTML darf // nicht ausgeführt werden. func TestEntryContentIsEscaped(t *testing.T) { @@ -617,7 +1436,7 @@ func TestEntryContentIsEscaped(t *testing.T) { t.Fatal(err) } - key := erzeugeKey(t, db, projekt.ID, "leser", auth.RoleRead) + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) cookie := anmelden(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/boese", nil) @@ -762,7 +1581,7 @@ func TestPlatformAdminEditsProject(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - w := adminPost(t, h, "/admin/projects/testprojekt/edit", url.Values{ + w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ "name": {"Neuer Name"}, "description": {"Neue Beschreibung"}, "write_limit_hourly": {"250"}, @@ -800,7 +1619,7 @@ func TestProjectEditFormShowsCurrentValues(t *testing.T) { t.Fatal(err) } - cookie := anmelden(t, h, testAdminToken) + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) r := httptest.NewRequest(http.MethodGet, "/admin/projects/testprojekt/edit", nil) r.AddCookie(cookie) @@ -835,7 +1654,7 @@ func TestProjectEditKeepsUnchangedValues(t *testing.T) { // Nur den Namen ändern — die Obergrenze kommt so zurück, wie das Formular // sie ausgeliefert hat. - adminPost(t, h, "/admin/projects/testprojekt/edit", url.Values{ + adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ "name": {"Anderer Name"}, "description": {""}, "write_limit_hourly": {"250"}, @@ -854,11 +1673,11 @@ func TestProjectAdminCannotEditProject(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleAdmin) + key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin) cookie := anmelden(t, h, key) keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + keys[0].ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) formular := url.Values{ "name": {"Gekapert"}, @@ -895,7 +1714,7 @@ func TestProjectEditRejectsGarbage(t *testing.T) { t.Fatal(err) } - w := adminPost(t, h, "/admin/projects/testprojekt/edit", url.Values{ + w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ "name": {"Testprojekt"}, "write_limit_hourly": {"viele"}, }) @@ -915,7 +1734,7 @@ func TestProjectEditRejectsEmptyName(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) - w := adminPost(t, h, "/admin/projects/testprojekt/edit", url.Values{ + w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ "name": {" "}, "write_limit_hourly": {"0"}, }) @@ -931,10 +1750,10 @@ func TestProjectEditRejectsEmptyName(t *testing.T) { // TestProjectEditUnknownSlug: ein Bezeichner, den es nicht gibt. func TestProjectEditUnknownSlug(t *testing.T) { - srv, _, _ := testServer(t) + srv, _, projekt := testServer(t) h := testHandler(t, srv) - cookie := anmelden(t, h, testAdminToken) + cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) r := httptest.NewRequest(http.MethodGet, "/admin/projects/gibtesnicht/edit", nil) r.AddCookie(cookie) @@ -946,12 +1765,14 @@ func TestProjectEditUnknownSlug(t *testing.T) { } } -// adminPost schickt ein Formular als Plattform-Admin, samt gültigem CSRF-Token. -func adminPost(t *testing.T, h http.Handler, pfad string, formular url.Values) *httptest.ResponseRecorder { +// adminPost schickt ein Formular als Plattform-Admin im gegebenen Workspace, +// samt gültigem CSRF-Token. +func adminPost(t *testing.T, h http.Handler, workspaceID, pfad string, formular url.Values) *httptest.ResponseRecorder { t.Helper() - cookie := anmelden(t, h, testAdminToken) - formular.Set(auth.CSRFFieldName, auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin)) + cookie := anmeldenAlsPlattformAdmin(t, h, workspaceID) + formular.Set(auth.CSRFFieldName, + auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin+":"+workspaceID)) r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -961,3 +1782,595 @@ func adminPost(t *testing.T, h http.Handler, pfad string, formular url.Values) * h.ServeHTTP(w, r) return w } + +// ── Hilfe ──────────────────────────────────────────────────────────────────── + +func TestHilfeBrauchtAnmeldung(t *testing.T) { + srv, _, _ := testServer(t) + h := testHandler(t, srv) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/hilfe", nil)) + + if w.Code != http.StatusSeeOther { + t.Errorf("Status = %d, erwartet %d (Weiterleitung zur Anmeldung)", w.Code, http.StatusSeeOther) + } +} + +// TestHilfeFuerLeseRolle prüft, dass auch die schwächste Rolle die Hilfe +// sieht, und dass die Platzhalter durch Server-Adresse und Projekt ersetzt +// werden — die Beispiele sollen direkt kopierbar sein. +func TestHilfeFuerLeseRolle(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + r := httptest.NewRequest(http.MethodGet, "/hilfe/mcp-einrichten", nil) + r.Host = "munin.example.org" + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "https://munin.example.org/mcp") { + t.Error("Server-Adresse wurde nicht eingesetzt") + } + if !strings.Contains(body, "munin-testprojekt") { + t.Error("Projekt-Bezeichner wurde nicht eingesetzt") + } + if strings.Contains(body, "{{SERVER}}") || strings.Contains(body, "{{PROJEKT}}") { + t.Error("Platzhalter stehen noch im Ergebnis") + } + // Die Seitenleiste des eigenen Projekts soll auch auf der Hilfe stehen. + if !strings.Contains(body, `href="/p/testprojekt/"`) { + t.Error("Seitenleiste zeigt das eigene Projekt nicht") + } +} + +func TestHilfeOhneSeiteZeigtStart(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) + + r := httptest.NewRequest(http.MethodGet, "/hilfe", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, erwartet 200", w.Code) + } + if !strings.Contains(w.Body.String(), "Willkommen") { + t.Error("/hilfe zeigt nicht die Startseite") + } +} + +func TestHilfeUnbekannteSeite(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) + + r := httptest.NewRequest(http.MethodGet, "/hilfe/gibt-es-nicht", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusNotFound { + t.Errorf("Status = %d, erwartet 404", w.Code) + } +} + +// TestHilfeFuerAdminOhneWorkspace: Ein frisch angemeldeter Plattform-Admin hat +// noch keinen Workspace gewählt. Die Hilfe muss trotzdem erreichbar sein. +func TestHilfeFuerAdminOhneWorkspace(t *testing.T) { + srv, _, _ := testServer(t) + h := testHandler(t, srv) + + cookie := anmelden(t, h, testAdminToken) + + r := httptest.NewRequest(http.MethodGet, "/hilfe/plattform-admin", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, erwartet 200", w.Code) + } +} + +// hilfeZielMuster findet in den Templates jedes Ziel innerhalb der Hilfe: +// sowohl {{template "hilfelink" "seite#abschnitt"}} als auch einfache Links +// href="/hilfe/seite". Die runden Klammern sind die "Fanggruppen" — daraus +// liest der Test Seite und (optional) Abschnitt. +var hilfeZielMuster = regexp.MustCompile(`(?:template "hilfelink" "|href="/hilfe/)([a-z0-9-]+)(?:#([a-z0-9-]+))?"`) + +// TestHilfelinksZeigenAufVorhandeneAbschnitte prüft jedes Hilfe-Ziel in allen +// Templates gegen die echten Hilfeseiten (AUSBAUSTUFEN.md 5.4). +// +// Die Abschnitts-ids entstehen aus den Überschriften der Hilfeseiten. Benennt +// jemand eine Überschrift um, zeigt der "?"-Link ins Leere — ohne diesen Test +// würde das niemand bemerken, denn der Browser springt dann einfach an den +// Seitenanfang, ganz ohne Fehlermeldung. +func TestHilfelinksZeigenAufVorhandeneAbschnitte(t *testing.T) { + srv, _, _ := testServer(t) + h := testHandler(t, srv) + + // Als Plattform-Admin, damit auch die Admin-Hilfeseite erreichbar ist. + cookie := anmelden(t, h, testAdminToken) + + dateien, err := filepath.Glob("../../templates/*.html") + if err != nil || len(dateien) == 0 { + t.Fatalf("keine Templates gefunden: %v", err) + } + + // Jede Hilfeseite nur einmal abrufen, auch wenn sie mehrfach verlinkt ist. + seiten := map[string]string{} + gefunden := 0 + + for _, datei := range dateien { + inhalt, err := os.ReadFile(datei) + if err != nil { + t.Fatal(err) + } + for _, treffer := range hilfeZielMuster.FindAllStringSubmatch(string(inhalt), -1) { + // treffer[0] ist der ganze Fund, treffer[1] die Seite, + // treffer[2] der Abschnitt (leer, wenn es keinen gibt). + seite, abschnitt := treffer[1], treffer[2] + gefunden++ + + html, schonGeholt := seiten[seite] + if !schonGeholt { + r := httptest.NewRequest(http.MethodGet, "/hilfe/"+seite, nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Errorf("%s: Hilfeseite %q antwortet mit %d", filepath.Base(datei), seite, w.Code) + continue + } + html = w.Body.String() + seiten[seite] = html + } + + if abschnitt != "" && !strings.Contains(html, `id="`+abschnitt+`"`) { + t.Errorf("%s: Abschnitt %q gibt es auf /hilfe/%s nicht", filepath.Base(datei), abschnitt, seite) + } + } + } + + // Findet das Muster gar nichts, prüft der Test auch nichts — dann ist eher + // das Muster kaputt als die Templates. + if gefunden < 5 { + t.Errorf("nur %d Hilfe-Ziele in den Templates gefunden — stimmt das Suchmuster noch?", gefunden) + } +} + +// TestHilfelinkImHTML: Das Partial erzeugt einen echten Link mit Abschnitt — +// html/template darf das "#" dabei nicht kaputt-maskieren. +func TestHilfelinkImHTML(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) + + r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/new", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if !strings.Contains(w.Body.String(), `href="/hilfe/weboberflaeche#markdown-im-inhalt"`) { + t.Error("der Editor verlinkt nicht auf den Markdown-Abschnitt der Hilfe") + } +} + +func TestServerURL(t *testing.T) { + faelle := []struct { + host, erwartet string + }{ + {"munin.intern", "https://munin.intern"}, + {"localhost:8080", "http://localhost:8080"}, + {"127.0.0.1:8080", "http://127.0.0.1:8080"}, + {"[::1]:8080", "http://[::1]:8080"}, + {"böse + + + {{end}} {{define "kopf"}}{{template "html-anfang" .}} -{{/* Schmale Kopfleiste über die ganze Breite: Marke und Abmelden. Alles - Weitere steht in der Seitenleiste. */}} +{{/* Schmale Kopfleiste über die ganze Breite: Marke, Workspace-Umschalter und + Abmelden. Alles Weitere steht in der Seitenleiste. + + Der Umschalter steht bewusst hier statt in der Seitenleiste — ein + Plattform-Admin mit mehreren Abteilungen muss auf jeder Seite sehen, + WELCHEN Workspace er gerade bearbeitet, nicht nur auf der Auswahlseite + selbst. Für einen Projekt-Zugang (immer genau ein Workspace) bleibt der + Umschalter weg — WorkspaceName ist dann leer. */}}
- Munin +
+ Munin + {{if .Session.WorkspaceName}} + + + {{.Session.WorkspaceName}} + + + {{end}} +
@@ -71,10 +89,15 @@ @@ -101,9 +124,18 @@ {{end}} + + {{/* Die Hilfe steht für alle ganz unten — gleich, mit welcher Rolle + man angemeldet ist. */}} +
@@ -111,6 +143,20 @@ {{if .Flash}}
{{.Flash}}
{{end}} {{end}} +{{/* hilfelink setzt ein kleines "?" neben eine Überschrift oder ein Feld, das + zur passenden Stelle der eingebauten Hilfe führt (AUSBAUSTUFEN.md 5.4). + Aufruf mit dem Ziel unterhalb von /hilfe/, auf Wunsch mit Abschnitt: + {{template "hilfelink" "weboberflaeche#markdown-im-inhalt"}} + Die Abschnittsnamen entstehen aus den Überschriften der Hilfeseiten + (internal/markdown, ankerIDs). Ein Test in web_test.go prüft, dass jedes + Ziel in den Templates wirklich existiert — wer eine Überschrift umbenennt, + merkt es also. + + Die Hilfe öffnet in einem neuen Tab: Wer mitten im Bearbeiten nachschlägt, + soll dabei nicht seinen ungespeicherten Text verlieren. rel="noopener" + verhindert, dass der neue Tab auf das Fenster dieser Seite zugreifen kann. */}} +{{define "hilfelink"}}?{{end}} + {{define "fuss"}}
diff --git a/templates/projects.html b/templates/projects.html index d2deb65..33d7810 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -2,7 +2,7 @@
-

Projekte

+

Projekte {{template "hilfelink" "plattform-admin#projekte"}}

Jedes Projekt hat eigene Einträge, eigene Zugänge und einen eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.
@@ -26,6 +26,8 @@
+ + @@ -38,7 +40,23 @@ {{if .Project.Description}}
{{.Project.Description}}
{{end}} - + + {{/* Schreibvorgänge der letzten 30 Tage: Gesamtzahl, darunter die + Aufteilung. "keine" ist das Signal, auf das es hier ankommt — + dieses Team benutzt Munin gerade nicht. */}} + {{$t := .Usage.Total}} + + diff --git a/templates/workspace-edit.html b/templates/workspace-edit.html new file mode 100644 index 0000000..9781874 --- /dev/null +++ b/templates/workspace-edit.html @@ -0,0 +1,54 @@ +{{template "kopf" .}} + +
+
+

{{.Data.Workspace.Name}}

+ Angelegt am {{.Data.Workspace.CreatedAt}} · {{.Data.Projects}} Projekt(e) +
+ +
+ +
+ + + +
+ + +
+ +
+ + Abbrechen +
+ +
+ +
+

Gefahrenzone

+ Das Löschen entfernt alle {{.Data.Projects}} Projekte dieses Workspace samt + ihrer Einträge und Zugänge unwiederbringlich — die Workspace-Datei wird von der Platte + entfernt. Der Markdown-Export bleibt erhalten, sofern einer eingerichtet ist. + +
+
+ + + +
+ + Zum Löschen {{.Data.Workspace.Name}} eintippen. + +
+ +
+ +
+ +
+
+ +{{template "fuss" .}} diff --git a/templates/workspaces.html b/templates/workspaces.html new file mode 100644 index 0000000..ac375aa --- /dev/null +++ b/templates/workspaces.html @@ -0,0 +1,80 @@ +{{template "kopf" .}} + +
+
+

Workspaces {{template "hilfelink" "plattform-admin#workspaces"}}

+ Jede Abteilung hat ihren eigenen Workspace — eigene Projekte, + eigene Einträge, eigene Datei. Sie sehen sich gegenseitig nicht. +
+
+ +{{if .Data.Workspaces}} +
+
2Projekt Bezeichner EinträgeSchreibvorgänge
letzte 30 Tage
Zuletzt geändert Schreibbudget Verwaltung
{{.Project.Slug}}{{.Entries}} + {{.Entries}} + {{if .Entries}}
{{.Usage.CuratedPercent}} % kuratiert
{{end}} +
+ {{if $t.Writes}} + {{$t.Writes}} +
{{$t.Created}} neu · {{$t.Updated}} akt. · Agenten {{.Usage.Agent.Writes}}
+ {{else}} + keine + {{end}} +
{{if .Usage.LastUpdatedAt.IsZero}}—{{else}}{{datum .Usage.LastUpdatedAt}}{{end}} {{if .Limit}} {{.Frei}} / {{.Limit}} @@ -51,6 +69,7 @@ Bearbeiten · + Nutzung · Zugänge
+ + + + + + + + + + + {{$aktuell := .Data.Current}} + {{range .Data.Workspaces}} + + + + + + + + {{end}} + +
WorkspaceProjekteAngelegtAuswahlVerwaltung
+ {{.Name}} + {{if eq .ID $aktuell}}Aktuell{{end}} + {{.Projects}}{{.CreatedAt}} + {{if eq .ID $aktuell}} + Weiter + {{else}} +
+ + + +
+ {{end}} +
+ Bearbeiten +
+ +{{else}} +
+

Noch kein Workspace angelegt.

+

Ein Workspace ist die Abschottungsgrenze zwischen Abteilungen — meist einer je Team.

+
+{{end}} + +
+

Neuer Workspace

+ Für eine neue Abteilung. Projekte werden danach innerhalb + des Workspace angelegt. + +
+
+ + +
+ + +
+ +
+ +
+
+
+
+ +{{template "fuss" .}}