initial commit after rewrite

This commit is contained in:
Thomas Adamek 2026-09-15 20:36:26 +02:00
commit f846955e58
62 changed files with 13114 additions and 0 deletions

29
.dockerignore Normal file
View file

@ -0,0 +1,29 @@
# Was Docker beim Bauen NICHT in den Kontext kopieren soll.
# Weniger Kontext = schnellerer Bau.
# Datenbank kommt als Volume, nicht ins Abbild
data/
# Lokal gebautes Binary
munin
# Git-Metadaten braucht Docker nicht
.git
# Dokumentation ist kein Laufzeit-Artefakt
README.md
REPOMAP.md
# Geheimnisse gehören nicht in den Kontext.
#
# Im fertigen Abbild landen sie zwar nicht (die zweite Stufe kopiert nur das
# Binary), aber die Bau-Stufe enthält sie — und die liegt im Docker-Zwischen-
# speicher und wird in manchen CI-Umgebungen sogar mit veröffentlicht.
.env
.env.*
# Editor- und Werkzeugverzeichnisse
.claude
.opencode
.cline
.DS_Store

80
.env.example Normal file
View file

@ -0,0 +1,80 @@
# Wissensspeicher — Konfigurationsvorlage
#
# Kopieren und mit eigenen Werten befüllen:
# cp .env.example .env
#
# .env enthält Geheimnisse und steht in .gitignore. Niemals einchecken.
# ── Pflichtangaben ────────────────────────────────────────────────────────────
# Signiert Sitzungs-Cookies und CSRF-Tokens. Mindestens 32 Zeichen.
# openssl rand -hex 32
SESSION_SECRET=
# Zugang des Plattform-Admins: legt Projekte an und löscht sie, kommt an jedes
# Projekt. Mindestens 16 Zeichen.
# openssl rand -hex 24
ADMIN_TOKEN=
# ── Server ────────────────────────────────────────────────────────────────────
# Pfad zur SQLite-Datenbank. Der Ordner wird beim Start angelegt.
DB_PATH=data/munin.db
# Port, auf dem der Server lauscht.
PORT=8080
# ── Sicherheit und Betrieb ────────────────────────────────────────────────────
# Secure-Flag der Cookies: Der Browser schickt sie dann nur über HTTPS.
#
# Ohne Angabe wird automatisch entschieden — bei Zugriff über localhost aus,
# sonst an. Für die lokale Entwicklung ist damit nichts zu tun. Ausdrücklich auf
# true setzen, wenn ein Reverse-Proxy das TLS beendet und intern http spricht.
# COOKIE_SECURE=true
# HSTS-Header senden: weist den Browser an, die Adresse künftig nur noch über
# HTTPS aufzurufen.
#
# NUR einschalten, wenn ein gültiges Zertifikat vorhanden ist — ein Browser, der
# den Header einmal gesehen hat, weigert sich ein Jahr lang, die Seite über http
# zu laden.
# ENABLE_HSTS=true
# Vertrauenswürdige Reverse-Proxys — kommagetrennte IPs und/oder CIDR-Netze.
#
# Nur wenn ein Request von einer dieser Adressen kommt, wird X-Forwarded-For
# ausgewertet, um die echte Client-Adresse zu bestimmen (wichtig für
# Rate-Limiting und Protokoll). Ohne Angabe wird der Header ignoriert — er ist
# frei fälschbar.
# TRUSTED_PROXY=127.0.0.1,10.0.0.0/8
# Höchstgröße eines MCP-Requests in Bytes. Standard: 4194304 (4 MB).
# MAX_API_BODY_BYTES=4194304
# Schreibbudget: wie oft ein Projekt je Stunde über MCP schreiben darf
# (remember und forget). Standard: 1000. Lesen ist nie begrenzt.
#
# Warum es das gibt: Ein gültiger API-Key umgeht die IP-Sperre bewusst — sonst
# würde ein falsch konfigurierter Client alle anderen im selben Büro mitsperren.
# Damit hätte aber ein Agent, der in einer Schleife hängt, überhaupt keine
# Grenze mehr. Diese hier ist sein Netz.
#
# Die Zahl darf großzügig sein: Sie soll eine Schleife stoppen, nicht Arbeit
# rationieren. Einzelne Projekte bekommen bei Bedarf eine eigene Obergrenze
# unter /admin/projects.
#
# 0 schaltet die Begrenzung vollständig ab.
# WRITE_LIMIT_HOURLY=1000
# ── Markdown-Export ───────────────────────────────────────────────────────────
# Zielverzeichnis für den Export. Ohne Angabe ist der Export abgeschaltet.
#
# Der Bestand wird täglich (und auf Knopfdruck) als Markdown-Dateien dorthin
# geschrieben — ein Ordner je Projekt. Gedacht als Sicherung, die sich in ein
# Git-Repository legen lässt und auch ohne dieses Programm lesbar bleibt.
#
# Ein Lauf ohne inhaltliche Änderungen fasst keine Datei an und erzeugt damit
# auch keinen Git-Diff.
# EXPORT_DIR=data/export

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# Kompiliertes Binary
munin
# Datenbank und alles darunter — gehört nicht ins Repository
data/
# Geheimnisse — NIEMALS einchecken
.env
# macOS
.DS_Store
# Editor- und Werkzeugverzeichnisse
.claude
.opencode
.cline

191
AGENTEN-GUIDE.md Normal file
View file

@ -0,0 +1,191 @@
# Agenten anbinden
Wie man KI-Werkzeuge so einrichtet und anspricht, dass sie den Munin
tatsächlich benutzen.
---
## 1. Verbinden
Key erzeugen unter `/p/<projekt>/keys`. Der Klartext wird genau einmal angezeigt.
Die fertigen Konfigurationsblöcke für **Claude Code**, **Cline** und **OpenCode**
stehen im [README](README.md#mcp-einrichten) — dort auch, worin sich die drei
Dateien unterscheiden. Kurzfassung:
| Client | Datei | Schlüssel | `type` |
|---|---|---|---|
| Claude Code | `.mcp.json` | `mcpServers` | `http` |
| Cline | `cline_mcp_settings.json` | `mcpServers` | `streamableHttp` |
| OpenCode | `opencode.json` | `mcp` | `remote` |
Ein Server je Projekt. Der Key bestimmt das Projekt — kein weiterer Parameter nötig.
**Ein Key je Person und je Agent.** Der Key-Name landet als Autor an jedem Eintrag
und im Protokoll.
---
## 2. Werkzeuge
| Werkzeug | Parameter | Rolle |
|---|---|---|
| `recall` | `query`, `tags?`, `limit?` | read |
| `get` | `slug` | read |
| `list` | `tag?`, `curated?`, `limit?` | read |
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write |
| `forget` | `slug` | write |
---
## 3. Das eigentliche Problem
Der Server schickt jedem Client eine Anleitung mit (`instructions`). Viele Clients
gewichten sie schwach oder ignorieren sie. **Ohne eigene Anweisung im Projekt-
oder Systemprompt greift ein Agent selten von selbst zum Werkzeug** — er antwortet
lieber aus seinem eigenen Wissen.
Deshalb der folgende Block.
---
## 4. Projektprompt
In `CLAUDE.md`, `.clinerules`, „Custom Instructions" oder den Systemprompt der
eigenen Anwendung:
```markdown
## Munin
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
- Passenden Treffer mit `get` vollständig laden.
- Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das
kenntlich machen.
- Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht.
SCHREIBEN — sobald etwas festgehalten werden sollte:
- IMMER zuerst `recall`, ob es zum Thema schon einen Eintrag gibt.
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
- Nur wenn wirklich nichts passt, einen neuen slug wählen.
- Vor dem Aktualisieren `get` aufrufen und die gelieferte `version` als
`expected_version` mitgeben.
WAS festgehalten wird:
- Entscheidungen mit Begründung
- Abläufe, die sich wiederholen
- Lösungen für Probleme, die länger gedauert haben
- Nicht: Zwischenstände, Diskussionsverlauf, was im Code steht
WIE festgehalten wird:
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
- Verwandtes mit [[bezeichner]] verknüpfen.
- Schlagworte sparsam: 1–3 aus dem bestehenden Bestand (`list` zeigt sie).
```
---
## 5. Bezeichner (slug)
Der slug ist der Schlüssel gegen Dubletten: gleicher slug = Aktualisierung.
| gut | schlecht | warum |
|---|---|---|
| `deployment-prozess` | `notiz-2026-01-15` | Datum ist kein Thema |
| `fehler-zugriff-verweigert` | `problem-3` | nicht wiederfindbar |
| `entscheidung-sqlite` | `sqlite-entscheidung-final-v2` | Fassungen zählt das System |
Regeln: `a-z`, `0-9`, `-`, `_`. Kurz, sprechend, dauerhaft. Ohne Angabe wird er
aus dem Titel abgeleitet.
---
## 6. Muster für einzelne Aufgaben
**Nachschlagen**
```
Wie läuft bei uns das Deployment? Schau zuerst im Munin nach.
```
**Festhalten nach getaner Arbeit**
```
Halte fest, was wir über den abgelaufenen Token herausgefunden haben.
Prüfe vorher mit recall, ob es dazu schon einen Eintrag gibt — dann
ergänze diesen, statt einen neuen anzulegen.
```
**Bestand aufräumen**
```
Rufe list auf und nenne mir Einträge, die dasselbe Thema doppelt behandeln.
Nichts ändern, nur auflisten.
```
**Einarbeiten**
```
Verschaffe dir mit list und recall einen Überblick über den Munin
und fasse zusammen, was dieses Team über Betrieb und Deployment festgehalten hat.
```
**Vorhandenes fortschreiben**
```
Lies deployment-prozess mit get, ergänze den Schritt mit dem Rollback und
schreibe zurück — mit expected_version aus dem get.
```
---
## 7. Was Dubletten erzeugt
| Ursache | Gegenmittel |
|---|---|
| Agent schreibt ohne vorher zu suchen | „IMMER zuerst recall" im Projektprompt |
| Neuer slug für vorhandenes Thema | slug-Regeln oben; `recall` liefert den vorhandenen |
| Zeitstempel im slug | verbieten — Fassungen zählt das System |
| Jede Erkenntnis als eigener Eintrag | „Ein Eintrag beantwortet eine Frage" |
---
## 8. Prüfen, ob es wirkt
**Protokoll:** `/p/<projekt>/audit` — jeder Schreibvorgang mit Zugang und Zeit.
**Faustregel:** Nach ein paar Wochen sollte das Verhältnis von Aktualisierungen zu
Neuanlagen steigen. Viele Neuanlagen bei ähnlichen Themen heißt: Der Projektprompt
greift nicht.
**In der Oberfläche:** Einträge mit Herkunft „Agent" und ohne Marke „kuratiert"
sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Haken
„Kuratiert" — Agenten sehen das Feld über `recall` und `get`.
---
## 9. Rollen
| Rolle | darf | wofür |
|---|---|---|
| `read` | recall, get, list | Agenten, die nur auskunftsfähig sein sollen |
| `write` | zusätzlich remember, forget | der Normalfall |
| `admin` | zusätzlich Zugänge verwalten | Menschen |
Ein Agent, der ohne Aufsicht läuft, bekommt `read`. Schreibrecht dort, wo jemand
die Einträge auch durchsieht.
---
## 10. Grenzen
- **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.
- **Was drinsteht, landet im Kontext aller.** Wer schreiben darf, schreibt
indirekt in die Prompts der anderen — Herkunft und Autor stehen deshalb an
jedem Eintrag.

448
AUSBAUSTUFEN.md Normal file
View file

@ -0,0 +1,448 @@
# Ausbaustufen — Munin
Was bewusst **nicht** gebaut wurde, warum, und woran man merkt, dass es Zeit dafür ist.
Der Sinn dieses Dokuments ist, dass niemand — auch nicht wir selbst in einem
halben Jahr — dieselben Fragen noch einmal von vorne durchdenken muss. Wo Zahlen
stehen, sind sie gemessen, nicht geschätzt; die Messumgebung ist jeweils genannt.
---
## Teil 1 — Entschieden und aufgeschoben
Diese Punkte sind durchgerechnet. Die Entscheidung lautet jeweils „später",
nicht „nie".
### 1.1 FTS5 statt eigenem Suchindex
**Stand heute:** Ein handgeschriebener BM25-Index im Arbeitsspeicher
(`internal/search`, 464 Zeilen plus 195 Zeilen Tests), einer je Projekt, beim
ersten Zugriff aufgebaut.
**Gemessen** (10.000 Einträge à 150 Wörter, Wortschatz 5.000, Zipf-verteilt):
| | eigener Index | FTS5 |
|---|---|---|
| Arbeitsspeicher | 65 MB | 0 |
| Platte | 0 | 54 MB |
| Aufbau | 171 ms | 280 ms |
| Suche | 12 µs | 208 µs |
FTS5 ist im reinen Go-Treiber verfügbar (`ENABLE_FTS5`), der statische Build
bliebe also erhalten.
**Warum aufgeschoben:**
- Der Suchindex ist **abgeleitete Daten**. Er lässt sich jederzeit aus `entries`
neu aufbauen — eine Umstellung später bedeutet keine Datenmigration. Der übliche
Grund, so etwas früh zu erledigen, greift hier nicht.
- Es löst nicht den eigentlichen Engpass. Der ist die semantische Trefferqualität
(siehe 2.2), und die kann FTS5 so wenig wie BM25 von Hand.
- FTS5 gibt es nur in SQLite. Der eigene Index ist von der Datenbank unabhängig
und hält damit den Weg zu PostgreSQL offen.
**Auslöser für die Umstellung:**
- Ein Projekt jenseits von grob 50.000 Einträgen (~300 MB Arbeitsspeicher allein
für dessen Index)
- Der Start dauert spürbar, weil viele große Projekte gleichzeitig angefragt werden
- Phrasensuche, `NEAR` oder boolesche Operatoren werden gebraucht — die bringt
FTS5 mit, der eigene Index nicht
**Aufwand:** ein bis zwei Tage. Virtuelle Tabelle mit `content=`-Verweis auf
`entries`, Befüllung aus dem Bestand, `internal/search` entfällt.
### 1.2 Eine SQLite-Datei je Projekt
**Stand heute:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen
zum Lesen, eine zum Schreiben.
**Gemessen:**
| | |
|---|---|
| Schreibvorgänge über die eine Schreibverbindung | 8.656/s |
| Lesen in Projekt A, ungestört | 82 µs |
| Lesen in Projekt A, während drei Projekte im Dauerlauf schreiben | 121 µs |
Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100
Schreibvorgänge/s — etwa **1 % der Kapazität**.
**Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei
Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key →
Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt:
- Globales Protokoll und „Projekte mit Eintragszahl" werden aus einer Abfrage
N Abfragen plus Zusammenführen in Go
- Migrationen laufen über jede Datei, auch beim Anlegen eines Projekts
- Verbindungsverwaltung mit Verdrängung nötig (50 Projekte × 9 Verbindungen)
- Kein gemeinsamer Commit über zentrale und Projektdatenbank — „Projekt anlegen
samt erstem Key" wäre nicht mehr atomar
**Auslöser:**
- Anhaltend über ~1.000 Schreibvorgänge/s (10 % der Kapazität)
- Ein Projekt jenseits weniger GB, sodass Sicherung und `VACUUM` unangenehm werden
- Verbindliche Löschzusagen je Projekt, bei denen „Zeilen gelöscht" nicht reicht
**Billigere Schritte davor:** mehrere Einträge in einer Transaktion bündeln,
WAL-Prüfpunkte seltener setzen.
### 1.3 Zeichenketten im Suchindex teilen
`internal/search` hält in `docTerms` je Dokument eine Liste **aller seiner
verschiedenen Wörter** als eigene Zeichenketten. Bei 10.000 Einträgen sind das
über eine Million einzeln abgelegte Zeichenketten, obwohl im Projekt vielleicht
nur 20.000 verschiedene Wörter vorkommen.
Die Liste ist nötig: ohne sie müsste beim Ändern oder Löschen der komplette Index
durchsucht werden. Sie ließe sich aber verkleinern, indem gleiche Wörter dieselbe
Zeichenkette teilen. **Geschätzt ein Drittel bis die Hälfte** des Speichers.
**Warum aufgeschoben:** Löst dasselbe Problem wie 1.1, nur halb. Wenn der
Speicher wirklich drückt, ist FTS5 der gründlichere Schritt.
### 1.4 sqlc für die Datenschicht
War empfohlen, dann verworfen: sqlc ist nicht installiert, und ein zusätzlicher
Codegenerator bedeutet, dass `go build` allein nicht mehr reicht. Handgeschriebenes
`database/sql` ist außerdem besser lesbar für jemanden, der Go lernt.
**Nachrüstbar,** ohne eine Zeile SQL zu ändern — das SQL ist bereits so
geschrieben, wie sqlc es erwartet.
---
## Teil 2 — Trefferqualität
**Hier liegt der eigentliche Engpass.** Alles andere in diesem Dokument ist
Ressourcenoptimierung; das hier entscheidet darüber, ob ein Agent findet, was er
sucht.
### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt
`tokenize` bildet `ä→a`, `ö→o`, `ü→u` und `ß→ss` ab, beim Indizieren wie beim
Suchen (siehe `faltUmlaut` in `internal/search/index.go`).
```
prüfung ↔ prufung ✓
größe ↔ grosse ✓
prüfung ↔ pruefung ✗
```
**Korrektur zur früheren Fassung dieses Abschnitts:** Dort stand, die Umschreibung
mit e (`ue`, `oe`, `ae`) werde mit abgedeckt. Das stimmt nicht. Sie ließe sich nur
abdecken, indem man auch `ue` zu `u` zusammenzieht — dann würde aber aus „neue"
ein „nu" und aus „Steuer" ein „Stur". Der Schaden wäre größer als der Nutzen.
Die Punkte wegzulassen ist ohnehin die weit häufigere Schreibweise.
Gegenüber FTS5 mit `remove_diacritics` ein Vorteil: Das faltet zwar Umlaute, aber
kein `ß` — dort findet „grosse" die „Größe" nicht.
### 2.2 Semantische Suche
**Problem:** BM25 vergleicht Wörter. Fragt ein Agent „wie deployen wir", findet er
einen Eintrag namens „Release-Prozess" nicht — kein Wort passt.
**Lösung:** Einbettungen (Embeddings) plus Vektorsuche, kombiniert mit BM25
(„hybride Suche"): BM25 ist stark bei seltenen, exakten Begriffen — Fehlercodes,
Eigennamen —, die Vektorsuche bei Umschreibungen.
**Bauform:** ein eigener kleiner Dienst, angebunden über HTTP. Python hat dafür
das deutlich reichere Ökosystem; die Go-Anwendung bliebe ein Binary.
**Aufwand:** mehrere Tage, plus laufender Betrieb eines zweiten Dienstes und
eines Einbettungsmodells.
**Vorher versuchen:** die `instructions` an echten Anfragen schärfen (siehe 3.1).
Erfahrungsgemäß holt das einen guten Teil des Unterschieds, kostet aber nichts.
### 2.3 Lange Einträge zerlegen
`get` liefert einen Eintrag ganz oder gar nicht. Ein Eintrag mit 3.000 Wörtern
belegt beim Agenten Kontext, von dem er zwei Absätze braucht.
**Lösung:** Einträge in Abschnitte zerlegen (an Überschriften) und abschnittsweise
zurückgeben, mit Verweis auf den vollständigen Eintrag.
**Vorher prüfen:** ob überhaupt so lange Einträge entstehen. Bei einem
Agentengedächtnis sind die meisten Einträge kurz — dann wäre das eine Lösung ohne
Problem.
### 2.4 Stoppwörter und Wortstämme
Häufige Wörter („und", „der", „ist") blähen den Index auf, ohne zu unterscheiden.
Und „Deployment" findet „Deployments" nicht.
Beides ist Standardwerkzeug jeder Suchmaschine, kostet aber Genauigkeit bei
Fachbegriffen. **Erst messen, ob es im Alltag stört** — BM25 gewichtet häufige
Wörter ohnehin bereits herunter.
---
## Teil 3 — Funktionen für Agenten
### 3.1 Die `instructions` an echten Anfragen schärfen
Der Text in `internal/mcpserver/server.go` steht bei jedem Client im Kontext und
entscheidet darüber, ob ein Agent vor dem Schreiben sucht oder blind Dubletten
anlegt.
**Vorgehen:** Nach ein paar Wochen Betrieb ins Protokoll schauen — wie viele
`remember`-Aufrufe legen neu an, wie viele aktualisieren? Ein hoher Anteil
Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht.
**Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben.
### 3.2 Importer als Gegenstück zum Export
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
Felder im Kopf, ein Einlesen wäre also möglich.
**Wozu:** Umzug auf einen anderen Server, Wiederherstellung aus der Sicherung,
Erstbefüllung aus vorhandener Dokumentation.
**Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert
— überschreiben, überspringen oder abbrechen.
### 3.3 Dubletten über Ähnlichkeit erkennen
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
neuen Slug für dasselbe Thema, steht die Erkenntnis zweimal da.
**Lösung:** Bei `remember` mit neuem Bezeichner den vorhandenen Suchindex über
Titel und Inhalt laufen lassen. Liegt ein Treffer über einer Schwelle, den
Eintrag **nicht** anlegen, sondern die Kandidaten zurückgeben und den Agenten
entscheiden lassen:
```json
{
"created": false,
"judgment_required": true,
"candidates": [
{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "score": 0.81 }
],
"hint": "Passt einer davon? Dann mit dessen slug erneut remember aufrufen.
Sonst mit force: true anlegen."
}
```
Der entscheidende Punkt ist, die Entscheidung **zurückzugeben** statt sie zu
treffen. Ein Agent, der einen Hinweis bekommt, korrigiert sich; einer, dem der
Aufruf stillschweigend abgelehnt wird, versucht es mit einem noch schlechteren
Bezeichner.
Braucht keine Einbettungen — der BM25-Index liegt bereits vor.
**Vorbild:** [Engram](https://github.com/Gentleman-Programming/engram) macht genau
das über FTS5 (`candidates[]` plus `judgment_required` bei `mem_save`). Das Muster
ist gut und ließe sich mit unserem Index eins zu eins übernehmen.
### 3.4 Bezeichner vorschlagen lassen
Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn
raten. Ein Werkzeug `suggest_slug(thema)` würde ihm die Arbeit abnehmen:
- gibt es zum Thema bereits einen Eintrag → dessen slug zurückgeben
- sonst einen neuen vorschlagen, der zu den vorhandenen passt
Damit wird aus „hoffentlich wählt das Modell einen guten Bezeichner" ein
Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen vor.
**Vorbild:** Engrams `mem_suggest_topic_key`.
### 3.5 Aktualität von Wissen
Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von
vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit
demselben Gewicht aus `recall` zurück.
**Kleiner Schritt zuerst:** Das Änderungsdatum steht bereits in jeder Antwort,
wird aber nicht betont. Einträge jenseits einer Frist zusätzlich mit einem
sichtbaren Vermerk versehen:
```json
{ "slug": "deployment-prozess", "updated_at": "2024-03-11",
"stale": true, "hint": "Seit 18 Monaten unverändert — vor Verwendung prüfen." }
```
**Größerer Schritt:** eine Prüffrist je Eintrag, die nach Art unterschiedlich
schnell abläuft — ein Zugangsverfahren altert schneller als eine
Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein
Bestätigen, das die Frist zurücksetzt.
**Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung,
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
`entries.version` zählt hoch, aber alte Fassungen werden nicht aufgehoben.
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, ist 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.
**Alternative ohne Aufwand:** der Markdown-Export in einem Git-Repository. Dann
liegt die Historie dort, mit `git log` und `git diff`.
### 3.7 Verwandte Einträge
Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein
Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten
ein zweiter Weg zum Kontext — neben der Suche.
**Aufwand:** gering, die Daten liegen vor.
### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt
Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in
`internal/web/middleware.go`) — richtig für Menschen hinter einer gemeinsamen
Adresse, aber eine Schleife im Agenten hatte damit kein Limit.
**Umgesetzt als `auth.WriteBudget`:** ein Fixed-Window-Zähler über schreibende
MCP-Aufrufe, Vorgabe **1.000 je Stunde** (`WRITE_LIMIT_HOURLY`), durchgesetzt in
`Handler.requireWrite` in `internal/mcpserver/tools.go`. Lesen bleibt
unbegrenzt.
**Korrektur zum ursprünglichen Vorschlag:** Dort stand „je Key und Stunde". Das
Budget hängt jetzt am **Projekt**. Der Grund: Ein Limit je Key lässt sich
umgehen, indem man einen zweiten Key anlegt — und das darf jeder Projekt-Admin
selbst. Es hätte also nur vor dem Versehen geschützt, nicht vor dem Verbrauch.
Einzelne Projekte bekommen unter `/admin/projects` eine eigene Obergrenze; setzen
darf sie nur der Plattform-Admin, aus demselben Grund.
**Drei Dinge, die beim Bauen auffielen:**
1. **Die Obergrenze darf nicht zwischengespeichert werden.** Die MCP-Server je
Projekt sind Closures über eine Projektzeile, die beim ersten Zugriff gelesen
wurde — eine Änderung in der Verwaltung wäre dort nie angekommen. Genommen
wird deshalb `sess.Project.WriteLimitHourly` aus der Sitzung: Die hat
`APIAuth` für genau diesen Request frisch aus der Datenbank geholt. Aktuell,
und ohne zusätzliche Abfrage im Schreibpfad.
2. **Jede Ablehnung zu protokollieren hätte das Problem nur verschoben.** Ein
Agent in einer Schleife hätte statt der Einträge das Protokoll vollgeschrieben.
Deshalb gibt `Consume` als dritten Wert zurück, der wievielte Aufruf über der
Grenze das war — vermerkt wird nur der erste je Fenster.
3. **Die Fehlermeldung geht an ein Sprachmodell, nicht an einen Menschen.** Ein
nackter Fehler führt dazu, dass es sofort wieder versucht oder auf ein anderes
Werkzeug ausweicht. Die Meldung sagt darum ausdrücklich, was zu tun ist:
abbrechen und das Limit melden.
**Was das Budget nicht ist:** Der Zählerstand liegt im Arbeitsspeicher und ist
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.
---
## Teil 4 — Betrieb
### 4.1 Zugriffsprotokoll — ✅ umgesetzt
`LogRequests` in `internal/web/middleware.go` schreibt je Anfrage eine Zeile:
```
200 POST /mcp 1.475ms claude-desktop
202 POST /mcp 149µs claude-desktop
401 POST /mcp 193µs —
```
Statische Dateien und der Healthcheck bleiben draußen (Rauschen), die
Query-Zeichenkette ebenfalls — dort stehen Suchbegriffe, und die sind Inhalt,
kein Betriebsdatum.
**Zwei Fallstricke, die beim Bauen auffielen:**
1. Der Mantel um den `ResponseWriter` verdeckte `http.Flusher`. Der MCP-Transport
kommt über `http.ResponseController` und damit über `Unwrap()` heran, die
verbreitetere direkte Typzusicherung `w.(http.Flusher)` hätte aber lautlos
fehlgeschlagen — der Aufrufer nimmt dann an, Leeren werde nicht unterstützt.
Deshalb ist `Flush()` ausdrücklich implementiert.
2. Der Zugang stand nicht in der Zeile. Das Protokoll liegt ganz außen, damit es
auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand
angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des
Requests. Gelöst über einen veränderbaren Halter im Context (`aktorHalter`),
den die Anmelde-Middleware befüllt.
### 4.2 Automatisierte Tests bei jeder Änderung — ✅ umgesetzt
`.github/workflows/test.yml` prüft bei jedem Push und jedem Pull Request:
Formatierung, `go vet`, `go test -race` und einen Bau mit `CGO_ENABLED=0`.
`-race` ist hier kein Beiwerk: Suchindex, Rate-Limiter und die Datenbankpools
werden von vielen Anfragen gleichzeitig benutzt. Der Bau ohne CGO schlägt fehl,
sobald jemand eine Abhängigkeit hinzufügt, die C braucht — das statische Binary
ist die Grundlage des ganzen Deployments.
### 4.3 Keycloak statt Key-Anmeldung
Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf
`api_key_id`, nicht auf Klarnamen. Kommt eine Nutzerverwaltung dazu, lässt sich
`user_id` danebenstellen, ohne die Historie zu verlieren.
**Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an
Personen statt an Keys.
### 4.4 Mehrere Workspaces
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.
### 4.5 Kennzahlen
Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht.
---
## 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 |
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
der erste.
---
## Empfohlene Reihenfolge
Wenn es demnächst weitergehen soll, in dieser Folge — jeweils kleiner Aufwand,
sichtbarer Gewinn:
~~1. Zugriffsprotokoll (4.1)~~ · ~~2. Umlaute falten (2.1)~~ ·
~~3. Automatisierte Tests (4.2)~~ — erledigt.
~~4. Obergrenze je Projekt (3.8)~~ — erledigt.
Als Nächstes:
1. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
Das Zugriffsprotokoll und `/p/<projekt>/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.
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
sich in der Spalte „Schreibbudget" unter `/admin/projects`. Wenn dort nie etwas
knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder
das Limit zu eng oder ein Agent tut etwas Unsinniges.
Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird.
**Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken,
semantische Suche. Alle drei sind gute Ideen für den Fall, dass ihre Auslöser
eintreten — und drei gute Wege, Zeit in Probleme zu stecken, die es noch nicht gibt.

63
Dockerfile Normal file
View file

@ -0,0 +1,63 @@
# ── Stufe 1: Bauen ────────────────────────────────────────────────────────────
# Das offizielle Go-Image bringt Compiler und Werkzeuge mit. Es ist groß (~800 MB),
# landet aber NICHT im fertigen Abbild — dafür sorgt der zweistufige Aufbau.
FROM golang:1.26-alpine AS builder
WORKDIR /app
# Zuerst nur die Abhängigkeitsdateien kopieren und laden.
# Docker legt jeden Schritt im Zwischenspeicher ab: Ändert sich nur der Code,
# aber nicht go.mod/go.sum, müssen die Abhängigkeiten nicht erneut geladen werden.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 → reines Go ohne C-Anteile, ergibt ein statisches Binary.
# Möglich ist das nur, weil der SQLite-Treiber (modernc.org/sqlite)
# selbst in Go geschrieben ist — die verbreitete Alternative
# mattn/go-sqlite3 bindet C ein und würde das hier verhindern.
# -trimpath → entfernt lokale Pfade aus dem Binary; sie stünden sonst in
# Fehlerausgaben und verrieten Details der Baumaschine.
# -ldflags="-s -w" → lässt Debug-Symbole weg, das Binary wird kleiner.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o munin .
# ── Stufe 2: Laufzeit ─────────────────────────────────────────────────────────
# Alpine ist ein minimales Linux (~5 MB). Kein Compiler, keine Quelldateien.
FROM alpine:3.21
WORKDIR /app
# Nur das fertige Binary übernehmen. Templates und statische Dateien stecken
# darin eingebettet (//go:embed in main.go), sie brauchen keinen eigenen Ordner.
COPY --from=builder /app/munin .
# wget wird für den Healthcheck weiter unten gebraucht; in Alpine steckt es in
# busybox und ist bereits vorhanden.
EXPOSE 8080
ENV DB_PATH=/data/munin.db
ENV PORT=8080
# Im Container steht praktisch immer ein Reverse-Proxy mit HTTPS davor.
ENV COOKIE_SECURE=true
# ADMIN_TOKEN und SESSION_SECRET haben bewusst keinen Vorgabewert — ohne sie
# startet der Server nicht. Ein erratbarer Standardwert wäre eine Hintertür.
# Als gewöhnlicher Benutzer laufen statt als root: Bricht jemand über eine Lücke
# aus der Anwendung aus, hat er sonst sofort volle Rechte im Container.
# Der Datenordner gehört diesem Benutzer, damit die Datenbank beschreibbar bleibt.
RUN adduser -D -u 10001 munin && mkdir -p /data && chown -R munin:munin /data /app
USER munin
# Die Datenbank gehört in ein Volume — sonst ist sie beim nächsten
# "docker run" verschwunden.
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1:8080/healthz || exit 1
CMD ["./munin"]

484
README.md Normal file
View file

@ -0,0 +1,484 @@
# Munin
Ein projektbezogenes Gedächtnis für KI-Agenten und Menschen.
> In der nordischen Überlieferung sind Hugin und Munin die Raben Odins. Munin
> steht für das Gedächtnis: Er fliegt aus, sammelt und kehrt zurück, um zu
> berichten — was hier die Agenten tun. Passend zum Torwächter **Heimdall**,
> unserem AI-Gateway.
KI-Werkzeuge (Claude, Cline, OpenCode, eigene Anwendungen) legen darin über
**MCP** ab, was sie lernen, und rufen es wieder auf. Menschen pflegen dasselbe
Wissen über eine schlanke Weboberfläche. Beides landet im selben Speicher — der
Unterschied ist ein Feld am Eintrag, kein zweites System.
```
KI-Werkzeug ──MCP──┐
├──▶ Projekt ──▶ Einträge (SQLite)
Browser ──HTTP─────┘
```
---
## Inhalt
- [Schnellstart](#schnellstart)
- [Grundbegriffe](#grundbegriffe)
- [MCP einrichten](#mcp-einrichten)
- [Die Werkzeuge](#die-werkzeuge)
- [Weboberfläche](#weboberfläche)
- [Konfiguration](#konfiguration)
- [Sicherheit](#sicherheit)
- [Betrieb](#betrieb)
- [Entwicklung](#entwicklung)
---
## Schnellstart
```bash
# 1. Konfiguration anlegen
cp .env.example .env
# 2. Geheimnisse erzeugen und eintragen
openssl rand -hex 32 # → SESSION_SECRET
openssl rand -hex 24 # → ADMIN_TOKEN
# 3. Starten
go run .
```
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)
---
## Grundbegriffe
| Begriff | Bedeutung |
|---|---|
| **Projekt** | Die Einheit der Abschottung. Eigene Einträge, eigene Zugänge, eigener MCP-Endpunkt. 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. |
### Warum Bezeichner statt Ordner
Ein Wiki gliedert nach Ordnern. Für ein Gedächtnis, in das Agenten schreiben,
funktioniert das nicht: Wo eine Erkenntnis „hingehört", ist selten eindeutig, und
ein Agent trifft diese Entscheidung schlecht.
Stattdessen gibt es **Schlagworte** zum Einordnen und **Verweise** zum
Verknüpfen. Im Inhalt zeigt `[[bezeichner]]` auf einen anderen Eintrag; die
Gegenrichtung („was verweist hierher") entsteht daraus von selbst und steht in
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
mehreren Projekten arbeitet, trägt mehrere Server mit je eigenem Key ein.
Alle Clients sprechen denselben Endpunkt `…/mcp` über Streamable HTTP an. Die
Konfigurationsdateien sehen trotzdem unterschiedlich aus:
| Client | Datei | Schlüssel | Transport heißt dort |
|---|---|---|---|
| Claude Code | `.mcp.json` / `~/.claude.json` | `mcpServers` | `"type": "http"` |
| Cline | `cline_mcp_settings.json` / `~/.cline/mcp.json` | `mcpServers` | `"type": "streamableHttp"` |
| OpenCode | `opencode.json` | `mcp` | `"type": "remote"` |
Die beiden ersten Zeilen sind die Falle: gleicher Schlüssel, anderer Wert für
`type`. Ein aus Claude Code kopierter Block läuft in Cline nicht.
### Claude Code
Am schnellsten über die Kommandozeile:
```bash
claude mcp add --transport http munin-plattform https://munin.intern/mcp \
--header "Authorization: Bearer wk_…"
```
Ohne `--scope` landet der Eintrag in `~/.claude.json` und gilt nur im aktuellen
Projekt; `--scope user` macht ihn überall verfügbar.
Fürs Team gehört er stattdessen als `.mcp.json` ins Repository — dann aber
**ohne** den Key im Klartext. Claude Code löst `${VAR}` aus der Umgebung auf:
```json
{
"mcpServers": {
"munin-plattform": {
"type": "http",
"url": "https://munin.intern/mcp",
"headers": { "Authorization": "Bearer ${MUNIN_KEY}" }
}
}
}
```
Jede Person setzt `MUNIN_KEY` auf ihren eigenen Key. Damit steht im Repository die
Einrichtung, nicht das Geheimnis — und im Protokoll steht trotzdem, wer geschrieben
hat.
### Cline
Über die Oberfläche: *MCP Servers* → *Configure* → *Configure MCP Servers*. In der
Kommandozeilenfassung liegt dieselbe Datei unter `~/.cline/mcp.json`.
```json
{
"mcpServers": {
"munin-plattform": {
"type": "streamableHttp",
"url": "https://munin.intern/mcp",
"headers": { "Authorization": "Bearer wk_…" },
"disabled": false,
"autoApprove": ["recall", "get", "list"]
}
}
}
```
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei
lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und
ein Agent, der für jeden Blick ins Gedächtnis nachfragen muss, benutzt es nicht.
`remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern
gemeinsames Wissen.
### OpenCode
`opencode.json` im Projektverzeichnis oder global unter
`~/.config/opencode/opencode.json`. Anderes Schema als bei den beiden anderen —
der Block heißt `mcp`, nicht `mcpServers`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"munin-plattform": {
"type": "remote",
"url": "https://munin.intern/mcp",
"enabled": true,
"headers": { "Authorization": "Bearer {env:MUNIN_KEY}" }
}
}
}
```
OpenCode ersetzt `{env:VAR}` aus der Umgebung und `{file:~/pfad}` durch den Inhalt
einer Datei. Ein nicht gesetztes `{env:…}` wird zur leeren Zeichenkette — der
Server antwortet dann mit `401`, nicht mit einer Fehlermeldung über die
Konfiguration.
### Danach
**Ein Key je Person und je Agent.** Nur so steht im Protokoll, wer etwas
geschrieben hat, und ein einzelner Zugang lässt sich zurückziehen, ohne alle
anderen zu stören.
Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `remember` und
`forget` anbieten. Tut er das nicht, hilft ein Blick ins Zugriffsprotokoll des
Servers — ein `401` dort heißt falscher oder zurückgezogener Key, gar kein Eintrag
heißt, dass der Client den Server nie aufgerufen hat.
Wie man Agenten so anspricht, dass sie den Speicher auch nutzen:
[AGENTEN-GUIDE.md](AGENTEN-GUIDE.md).
---
## Die Werkzeuge
| Werkzeug | Rolle | Zweck |
|---|---|---|
| `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. |
| `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. |
Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das
Vorgehen beschreibt — vor allem: *vor* dem Schreiben erst `recall` aufrufen. Das
ist das wirksamste Mittel gegen Dubletten und steht in
`internal/mcpserver/server.go`.
### Schreibbudget
`remember` und `forget` sind je Projekt auf **1.000 Aufrufe pro Stunde** gedeckelt
(`WRITE_LIMIT_HOURLY`). Lesen ist nie begrenzt.
Der Grund ist die Kehrseite einer anderen Entscheidung: Ein gültiger Key umgeht
die IP-Sperre bewusst, weil Deploy-Skript, MCP-Client und Browser eines Büros
sich nach außen oft eine Adresse teilen. Damit hätte ein Agent, der in einer
Schleife hängt, sonst überhaupt keine Grenze.
Das Budget hängt am **Projekt**, nicht am Key: Ein Limit je Key ließe sich
umgehen, indem man einen zweiten Key anlegt, und das darf jeder Projekt-Admin
selbst. Einzelne Projekte bekommen über *Projekte → Bearbeiten* eine eigene
Obergrenze; die Übersicht zeigt, wie viel im laufenden Fenster noch frei ist.
Ändern darf sie nur der Plattform-Admin — aus demselben Grund.
Ist das Budget erschöpft, bekommt das Modell keinen nackten Fehler, sondern die
Anweisung, abzubrechen und das Limit zu melden — sonst versucht es sofort wieder
oder weicht auf ein anderes Werkzeug aus.
### Gleichzeitiges Schreiben
`get` liefert eine `version`. Wird sie bei `remember` als `expected_version`
mitgegeben, meldet der Server einen Konflikt, falls jemand anderes den Eintrag
inzwischen geändert hat — statt die fremde Änderung stillschweigend zu
überschreiben.
```
Agent A: get(deployment) → version 3
Agent B: get(deployment) → version 3
Agent A: remember(…, expected_version=3) → OK, jetzt version 4
Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenführen
```
---
## Weboberfläche
| Weg | Zweck |
|---|---|
| `/login` | Anmeldung mit API-Key oder Admin-Token |
| `/admin/projects` | Projekte anlegen und auflisten (nur Plattform-Admin) |
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern |
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen |
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) |
| `/p/{projekt}/audit` | Protokoll des Projekts |
| `/healthz` | Healthcheck, ohne Anmeldung |
### Rollen
| Rolle | Darf |
|---|---|
| `read` | lesen, suchen |
| `write` | zusätzlich schreiben und löschen |
| `admin` | zusätzlich Zugänge des eigenen Projekts verwalten |
---
## Konfiguration
| Variable | Standard | Bedeutung |
|---|---|---|
| `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 |
| `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. |
| `TRUSTED_PROXY` | — | IPs/CIDR-Netze, deren `X-Forwarded-For` geglaubt wird |
| `MAX_API_BODY_BYTES` | `4194304` | Höchstgröße eines MCP-Requests (4 MB) |
| `WRITE_LIMIT_HOURLY` | `1000` | Schreibende MCP-Aufrufe je Projekt und Stunde. `0` = unbegrenzt. |
| `EXPORT_DIR` | — | Zielverzeichnis des Markdown-Exports. Leer = abgeschaltet. |
Fehlen die Pflichtangaben oder sind sie zu kurz, startet der Server nicht. Ein
schwaches Geheimnis wäre die Wurzel für alles andere — ein klarer Abbruch ist
besser als unsicherer Betrieb.
---
## Sicherheit
### Was geprüft wird
| Schutz | Wo |
|---|---|
| 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` |
| CSRF-Token in allen Formularen | `internal/auth/csrf.go` |
| `SameSite=Lax`, `HttpOnly`, `Secure` | `internal/auth/cookies.go` |
| Rate-Limiting je Adresse, nur auf Fehlversuche | `internal/auth/ratelimit.go` |
| Content-Security-Policy und weitere Schutz-Header | `internal/web/middleware.go` |
| Größenlimits für Request-Bodies | `internal/web/middleware.go` |
| Server-Timeouts gegen Slowloris | `main.go` |
| Optimistisches Sperren gegen stilles Überschreiben | `internal/store/entries.go` |
### Abschottung der Projekte
Sie ist **strukturell**, nicht eine Frage der richtigen Filterbedingung:
- 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
nicht über Projektgrenzen hinweg berechnet
- Jeder Datenbankzugriff ist auf die Projekt-ID eingeschränkt
- Ein fremdes Projekt antwortet mit **404, nicht 403** — sonst ließe sich über
die Statuscodes herausfinden, welche Projekte es sonst noch gibt
### Warum SHA-256 für Keys und nicht bcrypt
Für Passwörter wären langsame Verfahren richtig: Menschen wählen aus einem
kleinen, erratbaren Raum. Ein API-Key hier ist aber 256 Bit aus `crypto/rand` —
diesen Raum durchsucht niemand. Die Langsamkeit brächte keinen Gewinn, würde aber
jeden Request bremsen. Entscheidend ist der zweite Punkt: Weil SHA-256 schnell
und deterministisch ist, wird der Key in **einem indizierten Lookup** aufgelöst
statt in einer Schleife über alle Keys.
### Prompt Injection
Der Inhalt des Speichers landet direkt im Kontext von KI-Werkzeugen. Wer in ein
Projekt schreiben darf, schreibt damit indirekt in die Prompts aller anderen, die
dieses Projekt nutzen.
Dagegen hilft kein technischer Schutz, nur Nachvollziehbarkeit: Jeder Eintrag
trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jede
Änderung steht im Protokoll.
### Wo die Grenzen liegen
- **Keine Benutzerkonten.** Zugänge sind Keys, keine Personen. Ein Key je Person
ist der Ersatz — geplant ist später Keycloak.
- **Projekte pflegt nur der Plattform-Admin.** Ein Team kann seinen eigenen
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.
- **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.
---
## Betrieb
### Docker
```bash
docker build -t munin .
docker run -d --name munin \
-p 8080:8080 \
-v munin-daten:/data \
-e SESSION_SECRET="$(openssl rand -hex 32)" \
-e ADMIN_TOKEN="$(openssl rand -hex 24)" \
-e ENABLE_HSTS=true \
-e TRUSTED_PROXY=172.16.0.0/12 \
munin
```
Die Geheimnisse sollten aus einem Passwortspeicher kommen, nicht bei jedem Start
neu erzeugt werden — ein neues `SESSION_SECRET` macht alle Sitzungen ungültig.
### Hinter einem Reverse-Proxy
```bash
TRUSTED_PROXY=127.0.0.1 # Adresse des Proxys
COOKIE_SECURE=true # falls der Proxy TLS beendet
ENABLE_HSTS=true # nur mit gültigem Zertifikat
```
Ohne `TRUSTED_PROXY` sieht der Server für alle Besucher dieselbe Adresse — das
Rate-Limiting würde dann entweder niemanden bremsen oder beim ersten Angreifer
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:
```bash
sqlite3 /pfad/munin.db ".backup '/sicherung/wissen-$(date +%F).db'"
```
**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:
```
export/
plattform/
_index.md
deployment.md
server-zugang.md
```
Jede Datei trägt alle Felder im Kopf (Bezeichner, Schlagworte, Herkunft, Autor,
Fassung, Zeitstempel), sodass sich der Bestand daraus auch wieder einlesen ließe.
Das eignet sich als Git-Sicherung: Ein Lauf ohne inhaltliche Änderungen fasst
keine Datei an und erzeugt deshalb auch keinen Diff.
```bash
# Auf Knopfdruck: "Jetzt exportieren" unter /admin/projects
# Für Cron oder ein Sicherungsskript:
./munin -export
```
Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien
wirken nicht zurück und gehen beim nächsten Lauf verloren.
### Protokoll
Vorgänge werden 90 Tage aufbewahrt und danach automatisch gelöscht
(`auditRetention` in `main.go`). Das Protokoll enthält Adressen und Zugangsnamen,
aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die
Voraussetzung dafür, es überhaupt führen zu dürfen.
---
## Entwicklung
```bash
go run . # starten
go test ./... # alle Tests
go vet ./... # statische Prüfung
gofmt -l . # Formatierung prüfen
```
### Aufbau
```
main.go Konfiguration, Verdrahtung, Serverstart
internal/
store/ SQLite: Schema, Migrationen, alle Abfragen
entry/ Fachlogik: Markdown, Verweise, Textausschnitte
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
templates/ static/ eingebettet ins Binary
```
Die Richtung der Abhängigkeiten zeigt immer nach innen: `web` und `mcpserver`
kennen `store`, aber `store` kennt keinen von beiden.
### Datenbank
SQLite über `modernc.org/sqlite` — ein in Go übersetzter Nachbau, kein C. Nur
deshalb lässt sich das Programm als statisches Binary ohne Laufzeitumgebung
ausliefern.
Das SQL vermeidet bewusst alles, was nur SQLite kann (kein `AUTOINCREMENT`, keine
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.
---
## Was noch aussteht
[AUSBAUSTUFEN.md](AUSBAUSTUFEN.md) hält fest, was bewusst nicht gebaut wurde —
mit Messwerten, Begründung und den Auslösern, an denen man merkt, dass es Zeit
dafür ist. Unter anderem: FTS5 statt eigenem Suchindex, eine Datenbank je
Projekt, semantische Suche.

278
REPOMAP.md Normal file
View file

@ -0,0 +1,278 @@
# Repomap — Munin
Übersicht aller Dateien und Ordner mit ihrer Aufgabe.
```
munin/
├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die
│ Pflichtangaben (SESSION_SECRET, ADMIN_TOKEN inkl.
│ Mindestlänge), öffnet die Datenbank, 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.
│
├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten.
│
├── Dockerfile ← Zweistufiger Bau: Stufe 1 kompiliert mit dem
│ Go-Compiler, Stufe 2 packt nur das fertige Binary
│ in ein minimales Alpine-Abbild. Läuft als
│ gewöhnlicher Benutzer, mit Healthcheck und Volume
│ für die Datenbank.
│
├── .env.example ← Vorlage für .env, mit Erklärung jeder Variable.
├── .gitignore / .dockerignore ← Schließen Datenbank, Binary und Geheimnisse aus.
│
├── README.md ← Anleitung: Schnellstart, MCP einrichten, Werkzeuge,
│ Konfiguration, Sicherheit, Betrieb.
├── AGENTEN-GUIDE.md ← Wie man KI-Werkzeuge anbindet und anspricht,
│ damit sie den Speicher tatsächlich nutzen:
│ Projektprompt zum Übernehmen, slug-Regeln,
│ Muster für einzelne Aufgaben.
├── AUSBAUSTUFEN.md ← Was bewusst nicht gebaut wurde: Messwerte,
│ Begründung und die Auslöser, an denen man
│ merkt, dass es Zeit dafür wird.
├── REPOMAP.md ← Diese Datei.
│
├── 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.
│ │ │
│ │ ├── models.go ← Die Datentypen (Project, APIKey, Entry, …), die
│ │ │ Fehlerwerte (ErrNotFound, ErrConflict,
│ │ │ ErrDuplicate) sowie ID- und Zeitbehandlung.
│ │ │ NewID erzeugt sortierbare IDs in Go statt per
│ │ │ 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
│ │ │ SetProjectWriteLimit für die eigene Obergrenze
│ │ │ eines Projekts.
│ │ │
│ │ ├── keys.go ← API-Keys. ResolveKeyHash ist der heißeste Pfad im
│ │ │ Programm (jeder MCP-Request) und deshalb ein
│ │ │ indizierter Lookup über die Prüfsumme, keine
│ │ │ Schleife. Zurückgezogene Keys gelten als nicht
│ │ │ vorhanden, der Datensatz bleibt fürs Protokoll.
│ │ │
│ │ ├── entries.go ← Einträge. PutEntry legt an ODER aktualisiert —
│ │ │ der Bezeichner entscheidet, und genau das ist der
│ │ │ Mechanismus gegen Dubletten. Enthält das
│ │ │ optimistische Sperren (ExpectedVersion) und das
│ │ │ Nachladen der Schlagworte in einer Abfrage statt
│ │ │ einer pro Eintrag.
│ │ │
│ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie
│ │ │ beim Rendern. Das ersetzt die frühere
│ │ │ Ordnerhierarchie als Struktur zwischen Einträgen.
│ │ │
│ │ ├── audit.go ← Protokoll: schreiben, lesen, aufräumen. Die
│ │ │ Aufbewahrungsfrist ist nicht nur eine Frage des
│ │ │ Plattenplatzes — das Protokoll enthält Adressen
│ │ │ und Zugangsnamen.
│ │ │
│ │ └── migrations/
│ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln
│ │ │ für portables SQL, an die sich alle weiteren
│ │ │ Migrationen halten sollen.
│ │ ├── 002_projekt_in_nebentabellen.sql
│ │ ← Führt project_id auch in entry_tags und
│ │ entry_links. Eine bewusste Redundanz gegen zwei
│ │ gemessene Bremsen: das Schlagwortmenü lief über
│ │ alle Einträge (6,7 ms bei jedem Seitenaufruf),
│ │ und bei den Rückverweisen kippte durch ein
│ │ ORDER BY die Verknüpfungsreihenfolge
│ │ (3,1 ms statt 9 µs).
│ │ └── 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.
│ │
│ ├── auth/ ── Wer ist das? ────────────────────────────────────
│ │ ├── doc.go ← Erklärt die beiden Wege hinein (API-Key im Header,
│ │ │ Sitzungs-Cookie im Browser) und was hier bewusst
│ │ │ NICHT liegt: die Frage, was jemand darf.
│ │ │
│ │ ├── keys.go ← Erzeugen, Prüfsumme bilden, Form prüfen. Enthält
│ │ │ die Rollen (read/write/admin) und die Begründung,
│ │ │ warum SHA-256 statt bcrypt richtig ist.
│ │ │
│ │ ├── session.go ← Signierte Sitzungs-Cookies (HMAC-SHA256). Der
│ │ │ Cookie trägt nur eine Behauptung ("ich bin Key X"),
│ │ │ nie den Key selbst.
│ │ │
│ │ ├── csrf.go ← CSRF-Tokens aus Sitzung und Geheimnis, ohne Zustand
│ │ │ auf dem Server. Der Kopfkommentar erklärt den
│ │ │ Angriff, gegen den das schützt.
│ │ │
│ │ ├── cookies.go ← Setzt HttpOnly, SameSite und Secure an einer
│ │ │ Stelle. Secure ist ohne Konfiguration an, außer
│ │ │ bei localhost — damit die lokale Entwicklung über
│ │ │ http weiter funktioniert.
│ │ │
│ │ ├── ratelimit.go ← Zwei Begrenzungen auf einem gemeinsamen
│ │ │ Fixed-Window-Zähler: RateLimiter sperrt eine
│ │ │ Adresse nach zu vielen Fehlversuchen,
│ │ │ WriteBudget deckelt die schreibenden
│ │ │ MCP-Aufrufe je Projekt und Stunde. Letzteres
│ │ │ hängt am Projekt und nicht am Key — ein
│ │ │ Key-Limit ließe sich mit einem zweiten Key
│ │ │ umgehen.
│ │ │
│ │ └── trustedproxy.go ← Entscheidet, wann X-Forwarded-For geglaubt wird:
│ │ nur bei einer über TRUSTED_PROXY hinterlegten
│ │ Absenderadresse. Ohne Konfiguration wird der
│ │ 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.
│ │
│ ├── export/ ── Sicherung als Markdown ─────────────────────────
│ │ └── export.go ← Schreibt jedes Projekt als Ordner voller
│ │ Markdown-Dateien, mit allen Feldern im Dateikopf.
│ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne
│ │ inhaltliche Änderungen fasst keine Datei an und
│ │ erzeugt deshalb auch keinen Diff. Verwaiste
│ │ Dateien gelöschter Einträge werden entfernt,
│ │ fremde Dateien im Zielordner bleiben unberührt.
│ │
│ ├── search/ ── Volltextsuche ──────────────────────────────────
│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen
│ │ │ dreifach, Schlagworte zweifach. Enthält die
│ │ │ Präfix-Rückfallebene: Ohne sie findet "deploy"
│ │ │ den "Deployment-Prozess" nicht — bei einer
│ │ │ KI-Anfrage ein stiller Fehltreffer.
│ │ │
│ │ └── registry.go ← Ein Index je Projekt, verzögert aufgebaut. Getrennte
│ │ Indizes statt eines gemeinsamen mit Filter: So wird
│ │ die Abschottung strukturell, und die BM25-Werte
│ │ werden nicht über Projektgrenzen hinweg verzerrt.
│ │
│ ├── web/ ── HTTP: Browser und API ──────────────────────────
│ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die
│ │ │ Ablage im Request-Context. CanAccessProject ist
│ │ │ die Mandantentrennung der Weboberfläche.
│ │ │
│ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"),
│ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung.
│ │ │ Dazu die allgemeinen Schutzschichten
│ │ │ SecurityHeaders und LimitBody sowie APIAuth für
│ │ │ den MCP-Endpunkt.
│ │ │
│ │ ├── server.go ← Der Server-Typ, alle Routen und das Rendern der
│ │ │ Templates (erst in einen Puffer, dann senden —
│ │ │ sonst gäbe es bei einem Fehler eine halbe Seite
│ │ │ mit Status 200).
│ │ │
│ │ ├── handlers_auth.go ← Anmelden, Abmelden, Startseite.
│ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten.
│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht. Die
│ │ Bearbeitungsseite je Projekt (Name,
│ │ Beschreibung, Schreibbudget, Löschen) liegt
│ │ hier — bewusst eine Seite je Projekt statt
│ │ eines Auswahlfelds über alle: Nur so stehen die
│ │ tatsächlichen Werte in den Feldern, und ein
│ │ Speichern ohne Änderung überschreibt nichts.
│ │
│ └── mcpserver/ ── Die Hauptschnittstelle ────────────────────────
│ ├── server.go ← Handler und Transport (Streamable HTTP), ein
│ │ MCP-Server je Projekt. Enthält den Text der
│ │ `instructions` — das wirksamste Stellrad für die
│ │ Qualität der Antworten, weil er bei jedem Client
│ │ im Kontext steht.
│ │
│ └── tools.go ← Die fünf Werkzeuge: recall, get, list, remember,
│ forget. Jedes ist eine Closure über genau ein
│ Projekt und kann strukturell nicht an ein anderes.
│
├── templates/ ← HTML der Weboberfläche. partials.html enthält die
│ gemeinsamen Blöcke "kopf" und "fuss", die jede
│ Seite einbindet — kein verschachteltes Layout.
│
├── 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.
│
└── data/ ← Datenbank (nicht im Repository, als Volume gemountet)
└── munin.db
```
## Tests
Jedes Package hat seine Tests direkt daneben. Sie laufen mit `go test ./...` ohne
weitere Vorbereitung — die Datenbanktests legen jeweils eine echte SQLite-Datei
in einem Testverzeichnis an, damit auch Migrationen und Fremdschlüssel geprüft
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/entry/entry_test.go` | HTML-Escaping, Verweisauflösung, Textausschnitte, sichere Hervorhebung |
| `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/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** |
## 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
in den Context; Fehlversuche in die Sperre
→ mcpserver/server.go (ServeHTTP) Projekt aus dem Context → dessen MCP-Server
→ mcpserver/tools.go (recall)
→ search/registry.go Index dieses Projekts (beim ersten Mal
aus der Datenbank aufgebaut)
→ search/index.go (Search) BM25, bei Bedarf Präfix-Rückfallebene
→ store/entries.go (EntryBySlug) Treffer nachladen
→ entry/entry.go (Snippet) Textausschnitt je Treffer
→ JSON-Antwort mit Trefferliste und Hinweis zum weiteren Vorgehen
```
## Datenfluss: Agent legt Wissen ab
```
KI-Werkzeug POST /mcp { … "name": "remember", "arguments": { "slug": …, "content": … } }
→ web/middleware.go (APIAuth) wie oben
→ mcpserver/tools.go (remember)
→ requireWrite() Rolle read wird hier abgewiesen
→ store/entries.go (PutEntry) eine Transaktion:
· Bezeichner vorhanden? → aktualisieren
· ExpectedVersion prüfen → ggf. Konflikt
· Schlagworte und Verweise ersetzen
· Herkunft (agent) und Zugangsname setzen
→ search/registry.go (Update) Index nachziehen
→ store/audit.go (LogAudit) wer, was, wann
→ Antwort: Bezeichner, neue Fassung, angelegt oder aktualisiert
```
## Datenfluss: Mensch bearbeitet im Browser
```
Browser POST /p/plattform/e/deployment/save
→ web/middleware.go (SecurityHeaders, LimitBody)
→ web/middleware.go (requireSession) Cookie prüfen → Key aus der Datenbank
laden → gilt er noch? → CSRF-Token prüfen
→ web/middleware.go (requireProject) Projekt aus der URL; fremdes Projekt → 404
→ web/middleware.go (requireWrite) Rollenprüfung
→ web/handlers_entries.go (handleEntrySave)
→ store/entries.go (PutEntry) Herkunft: human, Autor: Key-Name
→ search/registry.go (Update)
→ store/audit.go (LogAudit)
→ Weiterleitung zur Detailansicht
```

27
go.mod Normal file
View file

@ -0,0 +1,27 @@
module munin
go 1.26.4
require (
github.com/joho/godotenv v1.5.1
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/yuin/goldmark v1.8.2
modernc.org/sqlite v1.58.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)

70
go.sum Normal file
View file

@ -0,0 +1,70 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

487
internal/auth/auth_test.go Normal file
View file

@ -0,0 +1,487 @@
package auth
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
var testSigner = NewSigner([]byte("ein-test-secret-das-lang-genug-ist-fuer-tests"))
// ── Sitzungen ────────────────────────────────────────────────────────────────
func TestSessionRoundTrip(t *testing.T) {
cookie := testSigner.SignSession("key:abc123")
subject, ok := testSigner.VerifySession(cookie)
if !ok {
t.Fatal("frisch signierte Sitzung wurde als ungültig abgelehnt")
}
if subject != "key:abc123" {
t.Errorf("subject = %q, erwartet %q", subject, "key:abc123")
}
}
// TestSessionRejectsWrongSecret ist der Kern des Verfahrens: ohne das richtige
// Geheimnis darf sich niemand eine Sitzung ausstellen können.
func TestSessionRejectsWrongSecret(t *testing.T) {
cookie := testSigner.SignSession(SubjectPlatformAdmin)
fremder := NewSigner([]byte("ein-voellig-anderes-secret"))
if _, ok := fremder.VerifySession(cookie); ok {
t.Error("Sitzung wurde mit falschem Secret akzeptiert")
}
}
// TestSessionRejectsTampering prüft den naheliegendsten Angriff: den Cookie
// auslesen (er ist nur base64, nicht verschlüsselt), "key:xyz" durch "platform"
// ersetzen und wieder einsetzen.
func TestSessionRejectsTampering(t *testing.T) {
cookie := testSigner.SignSession("key:abc123")
_, signature, _ := strings.Cut(cookie, ".")
gefaelscht := base64.RawURLEncoding.EncodeToString(
fmt.Appendf(nil, "%s|%d", SubjectPlatformAdmin, time.Now().Add(time.Hour).Unix()),
)
if _, ok := testSigner.VerifySession(gefaelscht + "." + signature); ok {
t.Error("manipulierter Cookie wurde akzeptiert — die Signatur greift nicht")
}
}
// TestSessionRejectsExpired baut die Nutzlast von Hand mit einem Zeitstempel in
// der Vergangenheit und signiert sie korrekt — so wird wirklich nur die
// Ablaufprüfung getestet, ohne eine Stunde zu warten.
func TestSessionRejectsExpired(t *testing.T) {
payload := fmt.Sprintf("key:abc123|%d", time.Now().Add(-time.Minute).Unix())
cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + testSigner.sign(payload)
if _, ok := testSigner.VerifySession(cookie); ok {
t.Error("abgelaufene Sitzung wurde akzeptiert")
}
}
// TestSessionRejectsMalformed wirft der Prüfung kaputte Eingaben vor die Füße.
// Wichtig ist vor allem, dass nichts davon zu einem Absturz führt.
func TestSessionRejectsMalformed(t *testing.T) {
cases := []string{
"",
"kein-punkt-drin",
".",
"nicht-base64!!!.abcdef",
base64.RawURLEncoding.EncodeToString([]byte("ohne-trenner")) + ".abcdef",
base64.RawURLEncoding.EncodeToString([]byte("key:a|keine-zahl")) + ".abcdef",
}
for _, cookie := range cases {
if _, ok := testSigner.VerifySession(cookie); ok {
t.Errorf("kaputter Cookie wurde akzeptiert: %q", cookie)
}
}
}
func TestKeyIDFromSubject(t *testing.T) {
if id, ok := KeyIDFromSubject("key:abc123"); !ok || id != "abc123" {
t.Errorf("KeyIDFromSubject = (%q, %v), erwartet (\"abc123\", true)", id, ok)
}
if _, ok := KeyIDFromSubject(SubjectPlatformAdmin); ok {
t.Error("Plattform-Admin hat fälschlich eine Key-ID geliefert")
}
}
// ── CSRF ─────────────────────────────────────────────────────────────────────
func TestCSRFToken(t *testing.T) {
token := testSigner.CSRFToken("key:abc123")
if !testSigner.ValidCSRFToken(token, "key:abc123") {
t.Error("gültiges CSRF-Token wurde abgelehnt")
}
if testSigner.ValidCSRFToken(token, "key:anderer") {
t.Error("CSRF-Token einer Sitzung galt auch für eine andere")
}
if testSigner.ValidCSRFToken("", "key:abc123") {
t.Error("leeres CSRF-Token wurde akzeptiert")
}
}
// TestTokenDomainSeparation prüft, dass ein CSRF-Token (das offen im HTML steht)
// nicht als Session-Signatur durchgeht.
func TestTokenDomainSeparation(t *testing.T) {
subject := "key:abc123"
csrf := testSigner.CSRFToken(subject)
if _, ok := testSigner.VerifySession(
base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + csrf,
); ok {
t.Error("ein CSRF-Token wurde als gültige Session-Signatur akzeptiert")
}
}
// ── API-Keys ─────────────────────────────────────────────────────────────────
func TestGenerateKeyFormat(t *testing.T) {
key, err := GenerateKey()
if err != nil {
t.Fatalf("Key konnte nicht erzeugt werden: %v", err)
}
if !strings.HasPrefix(key, KeyPrefix) {
t.Errorf("Key %q hat nicht das Präfix %q", key, KeyPrefix)
}
if !LooksLikeKey(key) {
t.Errorf("selbst erzeugter Key besteht die Formprüfung nicht: %q", key)
}
}
// TestGenerateKeyIsUnique prüft, dass wirklich Zufall im Spiel ist.
func TestGenerateKeyIsUnique(t *testing.T) {
gesehen := make(map[string]bool)
for i := 0; i < 100; i++ {
key, err := GenerateKey()
if err != nil {
t.Fatal(err)
}
if gesehen[key] {
t.Fatal("zweimal derselbe Key erzeugt")
}
gesehen[key] = true
}
}
func TestHashKey(t *testing.T) {
key := "wk_" + strings.Repeat("a", 64)
if HashKey(key) != HashKey(key) {
t.Error("derselbe Key ergibt unterschiedliche Hashes")
}
if HashKey(key) == HashKey(key+"x") {
t.Error("unterschiedliche Keys ergeben denselben Hash")
}
// Der Hash darf den Key nicht enthalten — sonst wäre die Datenbank so gut
// wie eine Klartext-Ablage.
if strings.Contains(HashKey(key), key) {
t.Error("der Hash enthält den Key im Klartext")
}
}
func TestLooksLikeKey(t *testing.T) {
gueltig := KeyPrefix + strings.Repeat("ab", 32)
cases := map[string]bool{
gueltig: true,
"": false,
"wk_": false,
strings.Repeat("ab", 32): false, // ohne Präfix
KeyPrefix + "zz": false, // zu kurz
KeyPrefix + strings.Repeat("g", 64): false, // kein Hex
}
for key, erwartet := range cases {
if got := LooksLikeKey(key); got != erwartet {
t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, erwartet)
}
}
}
// ── Rollen ───────────────────────────────────────────────────────────────────
func TestRolePermissions(t *testing.T) {
cases := []struct {
role Role
valid bool
write bool
manageKeys bool
}{
{RoleRead, true, false, false},
{RoleWrite, true, true, false},
{RoleAdmin, true, true, true},
{Role("unsinn"), false, false, false},
{Role(""), false, false, false},
}
for _, tc := range cases {
t.Run(string(tc.role), func(t *testing.T) {
if tc.role.Valid() != tc.valid {
t.Errorf("Valid() = %v, erwartet %v", tc.role.Valid(), tc.valid)
}
if tc.role.CanWrite() != tc.write {
t.Errorf("CanWrite() = %v, erwartet %v", tc.role.CanWrite(), tc.write)
}
if tc.role.CanManageKeys() != tc.manageKeys {
t.Errorf("CanManageKeys() = %v, erwartet %v", tc.role.CanManageKeys(), tc.manageKeys)
}
})
}
}
// ── Cookie-Policy ────────────────────────────────────────────────────────────
func TestCookiePolicySecure(t *testing.T) {
cases := []struct {
name string
env string
host string
erwartet bool
}{
{"Standard, echte Domain", "", "munin.intern", true},
{"Standard, localhost", "", "localhost:8080", false},
{"Standard, 127.0.0.1", "", "127.0.0.1:8080", false},
{"erzwungen an, localhost", "true", "localhost:8080", true},
{"erzwungen aus, echte Domain", "false", "munin.intern", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
policy := NewCookiePolicy(tc.env)
r := httptest.NewRequest(http.MethodGet, "http://"+tc.host+"/", nil)
r.Host = tc.host
if got := policy.Secure(r); got != tc.erwartet {
t.Errorf("Secure = %v, erwartet %v", got, tc.erwartet)
}
})
}
}
func TestCookiePolicyApplySetsFlags(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://munin.intern/", nil)
r.Host = "munin.intern"
cookie := &http.Cookie{Name: "test", Value: "wert"}
NewCookiePolicy("").Apply(cookie, r)
if !cookie.HttpOnly {
t.Error("HttpOnly fehlt")
}
if !cookie.Secure {
t.Error("Secure fehlt")
}
if cookie.SameSite != http.SameSiteLaxMode {
t.Errorf("SameSite = %v, erwartet Lax", cookie.SameSite)
}
if cookie.Path != "/" {
t.Errorf("Path = %q, erwartet \"/\"", cookie.Path)
}
}
// ── Rate-Limiting ────────────────────────────────────────────────────────────
func TestRateLimiterBlocksAfterMax(t *testing.T) {
limiter := NewRateLimiter()
const ip = "203.0.113.7"
for i := 0; i < rateLimitMax; i++ {
if allowed, _ := limiter.Allow(ip); !allowed {
t.Fatalf("bereits nach %d Versuchen gesperrt — zu früh", i)
}
limiter.RecordFailure(ip)
}
allowed, retryAfter := limiter.Allow(ip)
if allowed {
t.Errorf("nach %d Fehlversuchen immer noch erlaubt", rateLimitMax)
}
if retryAfter <= 0 {
t.Errorf("retryAfter = %v, erwartet einen positiven Wert", retryAfter)
}
}
func TestRateLimiterIsPerIP(t *testing.T) {
limiter := NewRateLimiter()
for i := 0; i < rateLimitMax; i++ {
limiter.RecordFailure("203.0.113.7")
}
if allowed, _ := limiter.Allow("198.51.100.4"); !allowed {
t.Error("eine andere IP wurde mitgesperrt")
}
}
func TestRateLimiterResetClearsCounter(t *testing.T) {
limiter := NewRateLimiter()
const ip = "203.0.113.7"
for i := 0; i < rateLimitMax; i++ {
limiter.RecordFailure(ip)
}
limiter.Reset(ip)
if allowed, _ := limiter.Allow(ip); !allowed {
t.Error("nach Reset immer noch gesperrt")
}
}
// ── Schreibbudget ────────────────────────────────────────────────────────────
func TestWriteBudgetAllowsUpToLimit(t *testing.T) {
budget := NewWriteBudget(3, time.Hour)
const projekt = "p1"
for i := 1; i <= 3; i++ {
ok, _, ueber := budget.Consume(projekt, 0)
if !ok {
t.Fatalf("Aufruf %d von 3 abgelehnt", i)
}
if ueber != 0 {
t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, ueber)
}
}
ok, retryAfter, ueber := budget.Consume(projekt, 0)
if ok {
t.Error("der vierte Aufruf wurde angenommen")
}
if retryAfter <= 0 {
t.Errorf("retryAfter = %v, erwartet einen positiven Wert", retryAfter)
}
// Genau 1 beim ersten abgelehnten Aufruf — daran hängt, dass die
// Überschreitung nur einmal je Fenster protokolliert wird.
if ueber != 1 {
t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", ueber)
}
if _, _, ueber := budget.Consume(projekt, 0); ueber != 2 {
t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", ueber)
}
}
func TestWriteBudgetIsPerProject(t *testing.T) {
budget := NewWriteBudget(1, time.Hour)
budget.Consume("p1", 0)
if ok, _, _ := budget.Consume("p1", 0); ok {
t.Fatal("Vorbedingung verfehlt: p1 ist noch nicht am Limit")
}
if ok, _, _ := budget.Consume("p2", 0); !ok {
t.Error("ein anderes Projekt wurde mitgesperrt")
}
}
// TestWriteBudgetProjectLimitBeatsDefault prüft die Auflösung der Obergrenze:
// erst die eigene Angabe des Projekts, sonst der Standard.
func TestWriteBudgetProjectLimitBeatsDefault(t *testing.T) {
budget := NewWriteBudget(100, time.Hour)
budget.Consume("eng", 1)
if ok, _, _ := budget.Consume("eng", 1); ok {
t.Error("die eigene Obergrenze (1) wurde nicht angewandt")
}
// 0 heißt "keine eigene Angabe" — dann gilt der großzügige Standard.
if ok, _, _ := budget.Consume("standard", 0); !ok {
t.Error("bei projektLimit 0 wurde nicht der Standard verwendet")
}
}
// TestWriteBudgetZeroDefaultDisables prüft die Notbremse: WRITE_LIMIT_HOURLY=0
// schaltet die Begrenzung vollständig ab.
func TestWriteBudgetZeroDefaultDisables(t *testing.T) {
budget := NewWriteBudget(0, time.Hour)
for i := 0; i < 50; i++ {
if ok, _, _ := budget.Consume("p1", 0); !ok {
t.Fatalf("Aufruf %d abgelehnt, obwohl die Begrenzung aus ist", i+1)
}
}
}
func TestWriteBudgetRemainingCountsDown(t *testing.T) {
budget := NewWriteBudget(5, time.Hour)
if frei, grenze := budget.Remaining("p1", 0); frei != 5 || grenze != 5 {
t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", frei, grenze)
}
budget.Consume("p1", 0)
budget.Consume("p1", 0)
if frei, _ := budget.Remaining("p1", 0); frei != 3 {
t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", frei)
}
// Remaining selbst darf nichts verbrauchen.
if frei, _ := budget.Remaining("p1", 0); frei != 3 {
t.Errorf("frei = %d — Remaining hat mitgezählt", frei)
}
}
// TestWriteBudgetWindowExpires prüft, dass ein abgelaufenes Fenster den Zähler
// zurücksetzt statt ihn fortzuschreiben.
func TestWriteBudgetWindowExpires(t *testing.T) {
budget := NewWriteBudget(1, time.Millisecond)
budget.Consume("p1", 0)
if ok, _, _ := budget.Consume("p1", 0); ok {
t.Fatal("Vorbedingung verfehlt: p1 ist noch nicht am Limit")
}
time.Sleep(5 * time.Millisecond)
if ok, _, _ := budget.Consume("p1", 0); !ok {
t.Error("nach Ablauf des Fensters immer noch gesperrt")
}
}
// ── Client-IP / Trusted Proxy ────────────────────────────────────────────────
func TestClientIPIgnoresForwardedByDefault(t *testing.T) {
if err := SetTrustedProxies(""); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.0.0.5:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
// Ohne TRUSTED_PROXY ist der Header frei erfunden und muss ignoriert werden —
// sonst könnte sich jeder Angreifer bei jedem Versuch eine neue IP geben und
// das Rate-Limiting damit aushebeln.
if got := ClientIP(r); got != "10.0.0.5" {
t.Errorf("ClientIP = %q, erwartet %q", got, "10.0.0.5")
}
}
func TestClientIPUsesForwardedFromTrustedProxy(t *testing.T) {
if err := SetTrustedProxies("10.0.0.0/8"); err != nil {
t.Fatal(err)
}
defer SetTrustedProxies("")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.0.0.5:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
if got := ClientIP(r); got != "1.2.3.4" {
t.Errorf("ClientIP = %q, erwartet %q", got, "1.2.3.4")
}
}
// TestClientIPTakesRightmostUntrusted prüft den Fall, dass der Client selbst
// schon einen X-Forwarded-For mitschickt und der Proxy nur anhängt.
func TestClientIPTakesRightmostUntrusted(t *testing.T) {
if err := SetTrustedProxies("10.0.0.0/8"); err != nil {
t.Fatal(err)
}
defer SetTrustedProxies("")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.0.0.5:1234"
// "9.9.9.9" ist die Erfindung des Angreifers, "1.2.3.4" seine echte Adresse.
r.Header.Set("X-Forwarded-For", "9.9.9.9, 1.2.3.4")
if got := ClientIP(r); got != "1.2.3.4" {
t.Errorf("ClientIP = %q, erwartet %q — die erfundene IP wurde übernommen", got, "1.2.3.4")
}
}
func TestSetTrustedProxiesRejectsGarbage(t *testing.T) {
if err := SetTrustedProxies("keine-ip"); err == nil {
t.Error("unsinnige Angabe wurde akzeptiert")
}
SetTrustedProxies("")
}

104
internal/auth/cookies.go Normal file
View file

@ -0,0 +1,104 @@
// Dieses File bündelt die Sicherheits-Einstellungen aller Cookies an einer Stelle.
//
// Vorher setzte jeder Handler seine Cookies selbst und jeder setzte etwas andere
// Flags — der Session-Cookie hatte HttpOnly, aber weder Secure noch SameSite.
// Solche Kleinigkeiten übersieht man leicht, deshalb gibt es jetzt genau eine
// Funktion die ein Cookie "scharf schaltet".
//
// # Die drei Flags und was sie bewirken
//
// - HttpOnly: JavaScript im Browser kann das Cookie nicht auslesen. Schützt
// davor, dass eine XSS-Lücke direkt zur Übernahme der Session führt.
//
// - Secure: Der Browser schickt das Cookie nur über HTTPS. Ohne dieses Flag
// würde ein einziger versehentlicher http://-Aufruf die Session im Klartext
// durchs Netz schicken.
//
// - SameSite: Der Browser schickt das Cookie nicht mit, wenn die Anfrage von
// einer fremden Seite ausgelöst wurde. Das ist die erste Verteidigungslinie
// gegen CSRF (siehe csrf.go für die zweite).
package auth
import (
"net"
"net/http"
"strings"
)
// CookiePolicy entscheidet, ob Cookies mit dem Secure-Flag gesetzt werden.
type CookiePolicy struct {
// explicit ist gesetzt, wenn die Umgebungsvariable COOKIE_SECURE einen
// eindeutigen Wert hat. Ein Zeiger, weil wir drei Zustände brauchen:
// "erzwungen an", "erzwungen aus" und "nicht gesetzt, bitte selbst entscheiden".
explicit *bool
}
// NewCookiePolicy baut die Policy aus dem Wert der Umgebungsvariable COOKIE_SECURE.
//
// Leer (der Normalfall) bedeutet: automatisch entscheiden — siehe Secure.
func NewCookiePolicy(envValue string) CookiePolicy {
switch strings.ToLower(strings.TrimSpace(envValue)) {
case "true", "1", "yes", "on":
yes := true
return CookiePolicy{explicit: &yes}
case "false", "0", "no", "off":
no := false
return CookiePolicy{explicit: &no}
default:
return CookiePolicy{}
}
}
// Secure entscheidet für einen konkreten Request, ob das Secure-Flag gesetzt wird.
//
// Ohne explizite Konfiguration gilt: Secure ist an — außer der Aufruf kommt von
// localhost. Das ist bewusst so herum gewählt:
//
// - In Produktion (eigene Domain, HTTPS) ist das sichere Verhalten der Standard,
// ohne dass man daran denken muss eine Variable zu setzen.
// - Bei der lokalen Entwicklung über http://localhost:8080 würde ein gesetztes
// Secure-Flag den Login kaputt machen (der Browser würde das Cookie verwerfen),
// deshalb dort automatisch aus. Localhost gilt im Browser ohnehin als
// "secure context", es geht also keine Sicherheit verloren.
//
// Wer hinter einem Reverse-Proxy mit TLS-Terminierung fährt, kann mit
// COOKIE_SECURE=true nachhelfen; wer bewusst ohne TLS testet, mit COOKIE_SECURE=false.
func (p CookiePolicy) Secure(r *http.Request) bool {
if p.explicit != nil {
return *p.explicit
}
return !isLocalhost(r)
}
// Apply setzt Pfad und alle Sicherheits-Flags eines Cookies.
// Name, Wert und Ablaufzeit bleiben Sache des Aufrufers.
func (p CookiePolicy) Apply(c *http.Cookie, r *http.Request) {
c.Path = "/"
c.HttpOnly = true
c.Secure = p.Secure(r)
// Lax statt Strict: bei Strict würde ein Link von außen ins Wiki (z.B. aus
// einer Chat-Nachricht) den Nutzer trotz gültiger Session auf der Login-Seite
// landen lassen, weil der Browser das Cookie beim ersten Aufruf zurückhält.
// Lax schickt das Cookie bei normaler Navigation mit, aber nicht bei einem
// von außen ausgelösten POST — genau die Fälle die wir schützen wollen.
c.SameSite = http.SameSiteLaxMode
}
// 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 {
host := r.Host
// Den Port abtrennen, falls einer dranhängt ("localhost:8080" → "localhost").
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.ToLower(strings.Trim(host, "[]"))
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}

49
internal/auth/csrf.go Normal file
View file

@ -0,0 +1,49 @@
package auth
import (
"crypto/subtle"
"net/http"
)
// CSRFFieldName ist der Name des versteckten Formularfelds in den Templates.
const CSRFFieldName = "csrf_token"
// CSRFHeaderName erlaubt es, das Token bei einem fetch()-Aufruf im Header statt
// im Formular zu schicken.
const CSRFHeaderName = "X-CSRF-Token"
// CSRFToken berechnet das Token zu einer Sitzung.
//
// # Wogegen schützt das?
//
// CSRF ("Cross-Site Request Forgery") nutzt aus, dass der Browser Cookies
// automatisch mitschickt. Eine fremde Seite kann ein Formular auf unseren Server
// abschicken lassen, und für den Server sieht es aus wie eine Aktion des
// angemeldeten Nutzers:
//
// <form method="POST" action="https://munin.intern/projects/x/delete">
// <script>document.forms[0].submit()</script>
//
// Das Token unterbindet das: Die fremde Seite kennt es nicht und kann es wegen
// der Same-Origin-Policy des Browsers auch nicht auslesen.
//
// Das Präfix "csrf:" sorgt für Domänentrennung — dasselbe Geheimnis signiert auch
// Session-Cookies, und eine Signatur aus dem einen Zusammenhang darf im anderen
// nicht gelten.
func (s *Signer) CSRFToken(subject string) string {
return s.sign("csrf:" + subject)
}
// ValidCSRFToken prüft ein übermitteltes Token gegen die Sitzung.
func (s *Signer) ValidCSRFToken(token, subject string) bool {
expected := s.CSRFToken(subject)
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
}
// CSRFTokenFromRequest holt das Token aus dem Formular oder dem Header.
func CSRFTokenFromRequest(r *http.Request) string {
if token := r.Header.Get(CSRFHeaderName); token != "" {
return token
}
return r.FormValue(CSRFFieldName)
}

22
internal/auth/doc.go Normal file
View file

@ -0,0 +1,22 @@
// Package auth bündelt alles, was mit Authentifizierung und Sitzungen zu tun hat.
//
// # Zwei Wege hinein
//
// Es gibt genau zwei Arten, sich gegenüber dem Server auszuweisen:
//
// 1. API-Key im Authorization-Header ("Bearer wk_…") — für MCP-Clients,
// Agenten und Skripte. Der Key gehört immer zu genau einem Projekt.
//
// 2. Session-Cookie — für die Web-Oberfläche. Der Cookie entsteht durch eine
// einmalige Anmeldung mit demselben API-Key und trägt danach nur noch eine
// signierte Behauptung ("ich bin Key X"), nie den Key selbst.
//
// Dazu kommt der Plattform-Admin (Umgebungsvariable ADMIN_TOKEN), der Projekte
// anlegen und löschen darf — der einzige Zugang, der nicht an ein Projekt gebunden ist.
//
// # Was hier NICHT liegt
//
// Die Frage "darf dieser Key das?" beantwortet nicht dieses Package, sondern die
// Rolle am Key (siehe Role) in Verbindung mit den Prüfungen in internal/web.
// Hier geht es nur darum, WER jemand ist — nicht, was er darf.
package auth

110
internal/auth/keys.go Normal file
View file

@ -0,0 +1,110 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// KeyPrefix steht vor jedem API-Key.
//
// Zwei Gründe dafür:
// - Man erkennt auf einen Blick, worum es sich handelt, wenn ein Key irgendwo
// in einer Konfigurationsdatei oder einem Chat auftaucht.
// - Werkzeuge zum Aufspüren versehentlich veröffentlichter Secrets (GitHub
// Secret Scanning und ähnliche) suchen nach genau solchen Präfixen.
const KeyPrefix = "wk_"
// keyBytes ist die Länge des Zufallsanteils eines Keys.
// 32 Byte = 256 Bit — weit jenseits von allem, was sich durchprobieren lässt.
const keyBytes = 32
// Role beschreibt, was ein Key darf.
//
// Bewusst nur drei Stufen: mehr Feingranularität kostet Verständlichkeit und
// wird bei einem internen Werkzeug erfahrungsgemäß nie genutzt.
type Role string
const (
// RoleRead darf lesen und suchen — für Agenten, die nur Wissen abrufen sollen.
RoleRead Role = "read"
// RoleWrite darf zusätzlich Einträge anlegen, ändern und löschen.
RoleWrite Role = "write"
// RoleAdmin darf zusätzlich die Keys des eigenen Projekts verwalten.
RoleAdmin Role = "admin"
)
// Valid prüft, ob eine Rolle einen der bekannten Werte hat.
// Wichtig beim Lesen aus der Datenbank: dort steht Text, und Text kann alles sein.
func (r Role) Valid() bool {
switch r {
case RoleRead, RoleWrite, RoleAdmin:
return true
default:
return false
}
}
// CanWrite sagt, ob diese Rolle Einträge verändern darf.
func (r Role) CanWrite() bool {
return r == RoleWrite || r == RoleAdmin
}
// CanManageKeys sagt, ob diese Rolle die Keys des Projekts verwalten darf.
func (r Role) CanManageKeys() bool {
return r == RoleAdmin
}
// GenerateKey erzeugt einen neuen API-Key im Klartext.
//
// Der Klartext existiert nur genau einmal — direkt nach dem Erzeugen wird er dem
// Nutzer angezeigt und danach nirgends gespeichert (siehe HashKey).
func GenerateKey() (string, error) {
buf := make([]byte, keyBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("Zufallszahlen konnten nicht erzeugt werden: %w", err)
}
return KeyPrefix + hex.EncodeToString(buf), nil
}
// HashKey bildet den Speicher-Hash eines API-Keys.
//
// # Warum SHA-256 und nicht bcrypt/Argon2?
//
// Für Passwörter wären langsame Hashverfahren richtig: Menschen wählen Passwörter
// aus einem kleinen, erratbaren Raum, und die künstliche Langsamkeit macht das
// Durchprobieren teuer.
//
// Ein API-Key hier ist aber kein Passwort, sondern 256 Bit aus crypto/rand. Diesen
// Raum kann niemand durchsuchen — auch nicht mit beliebig schneller Hardware. Die
// Langsamkeit brächte also keinen Sicherheitsgewinn, würde aber jeden einzelnen
// Request ausbremsen.
//
// Entscheidend ist der zweite Punkt: Weil SHA-256 schnell und deterministisch ist,
// kann der Hash direkt als indizierte Spalte durchsucht werden — ein Lookup statt
// einer Schleife über alle Keys. Die alte Lösung verglich den Key nacheinander mit
// jedem Team; das skaliert nicht und wurde mit jedem neuen Projekt langsamer.
func HashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// LooksLikeKey prüft die äußere Form eines Keys, bevor die Datenbank befragt wird.
//
// Das ist keine Sicherheitsprüfung, sondern spart einen Datenbankzugriff bei
// offensichtlichem Unsinn (leerer Header, abgeschnittener Key, Tippfehler).
func LooksLikeKey(key string) bool {
if !strings.HasPrefix(key, KeyPrefix) {
return false
}
rest := key[len(KeyPrefix):]
if len(rest) != keyBytes*2 {
return false
}
_, err := hex.DecodeString(rest)
return err == nil
}

249
internal/auth/ratelimit.go Normal file
View file

@ -0,0 +1,249 @@
// Dieses File enthält zwei Begrenzungen, die sich dieselbe Mechanik teilen:
//
// - RateLimiter sperrt eine IP nach zu vielen fehlgeschlagenen Anmeldungen.
// - WriteBudget deckelt, wie oft ein Projekt je Stunde schreiben darf.
//
// Beide sind Fixed-Window-Zähler: Ein Zeitfenster beginnt beim ersten Ereignis
// und endet nach einer festen Dauer; danach fängt die Zählung bei null an.
package auth
import (
"sync"
"time"
)
// ── Der gemeinsame Unterbau ──────────────────────────────────────────────────
// fensterStand ist der Zählerstand eines Schlüssels im laufenden Fenster.
type fensterStand struct {
anzahl int
fensterBis time.Time
}
// fensterZaehler zählt Ereignisse je Schlüssel innerhalb eines Zeitfensters.
//
// Was der Schlüssel bedeutet, entscheidet der Benutzer des Zählers: beim
// RateLimiter ist es eine IP-Adresse, beim WriteBudget eine Projekt-ID. Für den
// Zähler ist beides nur eine Zeichenkette — deshalb lässt sich dieselbe
// Mechanik für beides verwenden.
type fensterZaehler struct {
mu sync.Mutex
fenster time.Duration
staende map[string]*fensterStand
}
// neuerZaehler erstellt einen Zähler und startet einen Hintergrund-Job, der
// abgelaufene Einträge regelmäßig entfernt.
//
// Ohne das Aufräumen würde die Map unbegrenzt wachsen: Jede IP, die einmal
// vorbeigeschaut hat, bliebe für immer darin stehen — bei Scannern und Bots sind
// das schnell sehr viele.
func neuerZaehler(fenster time.Duration) *fensterZaehler {
z := &fensterZaehler{
fenster: fenster,
staende: make(map[string]*fensterStand),
}
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
z.aufraeumen()
}
}()
return z
}
// lies gibt den aktuellen Stand zurück, ohne zu zählen.
// restlaufzeit ist die verbleibende Dauer des Fensters.
func (z *fensterZaehler) lies(schluessel string) (anzahl int, restlaufzeit time.Duration) {
z.mu.Lock()
defer z.mu.Unlock()
stand, vorhanden := z.staende[schluessel]
now := time.Now()
// Kein Eintrag oder Fenster abgelaufen: der Schlüssel steht bei null.
if !vorhanden || now.After(stand.fensterBis) {
return 0, 0
}
return stand.anzahl, stand.fensterBis.Sub(now)
}
// zaehle erhöht den Zähler um eins und gibt den neuen Stand zurück.
func (z *fensterZaehler) zaehle(schluessel string) (anzahl int, restlaufzeit time.Duration) {
z.mu.Lock()
defer z.mu.Unlock()
now := time.Now()
stand, vorhanden := z.staende[schluessel]
// Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein
// frisches ersetzt — sonst würde der alte Stand weitergezählt.
if !vorhanden || now.After(stand.fensterBis) {
stand = &fensterStand{fensterBis: now.Add(z.fenster)}
z.staende[schluessel] = stand
}
stand.anzahl++
return stand.anzahl, stand.fensterBis.Sub(now)
}
// vergiss löscht den Zähler eines Schlüssels.
func (z *fensterZaehler) vergiss(schluessel string) {
z.mu.Lock()
defer z.mu.Unlock()
delete(z.staende, schluessel)
}
// aufraeumen entfernt abgelaufene Einträge aus der Map.
func (z *fensterZaehler) aufraeumen() {
z.mu.Lock()
defer z.mu.Unlock()
now := time.Now()
for schluessel, stand := range z.staende {
if now.After(stand.fensterBis) {
delete(z.staende, schluessel)
}
}
}
// ── Anmeldeversuche ──────────────────────────────────────────────────────────
const (
rateLimitMax = 5 // erlaubte Fehlversuche pro Fenster
rateLimitWindow = 1 * time.Minute // Fenstergröße
)
// RateLimiter begrenzt fehlgeschlagene Anmeldungen pro Client-IP —
// ohne das könnte ein Angreifer beliebig oft Passwörter und API-Keys
// durchprobieren.
type RateLimiter struct {
zaehler *fensterZaehler
}
// NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche.
func NewRateLimiter() *RateLimiter {
return &RateLimiter{zaehler: neuerZaehler(rateLimitWindow)}
}
// Allow prüft ob die IP noch einen Versuch frei hat, ohne ihn zu verbrauchen.
// retryAfter gibt an wie lange bei einer Sperre noch gewartet werden muss.
func (l *RateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) {
anzahl, restlaufzeit := l.zaehler.lies(ip)
if anzahl >= rateLimitMax {
return false, restlaufzeit
}
return true, 0
}
// RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP.
func (l *RateLimiter) RecordFailure(ip string) {
l.zaehler.zaehle(ip)
}
// Reset löscht den Zähler einer IP nach einem erfolgreichen Zugriff.
func (l *RateLimiter) Reset(ip string) {
l.zaehler.vergiss(ip)
}
// ── Schreibbudget je Projekt ─────────────────────────────────────────────────
// WriteBudget deckelt, wie oft ein Projekt innerhalb eines Zeitfensters
// schreiben darf.
//
// # Warum je Projekt und nicht je Key?
//
// Ein Limit je Key ließe sich umgehen, indem man einen zweiten Key anlegt — das
// darf jeder Projekt-Admin selbst. Damit schützte es nur vor dem Versehen (eine
// Schleife im Agenten), nicht vor dem Verbrauch. Das Projekt ist die Einheit der
// Abschottung, also gehört auch das Budget dorthin.
//
// # Warum überhaupt?
//
// Ein gültiger API-Key umgeht die IP-Sperre bewusst (siehe APIAuth in
// internal/web/middleware.go): Deploy-Skript, MCP-Client und Browser eines Büros
// teilen sich nach außen oft eine Adresse. Damit hätte aber eine durchdrehende
// Schleife im Agenten überhaupt keine Grenze mehr.
//
// # Was das Budget NICHT ist
//
// Der Zählerstand liegt im Arbeitsspeicher und ist nach einem Neustart weg. Bei
// Fehlversuchen ist das gleichgültig, bei einem Stundenbudget lädt es zum
// Umgehen ein. Für eine verbindliche Zusage müsste der Zähler in die Datenbank —
// siehe AUSBAUSTUFEN.md. Solange genau ein Prozess läuft, reicht das hier.
type WriteBudget struct {
zaehler *fensterZaehler
standard int
}
// NewWriteBudget erstellt ein Schreibbudget.
//
// standard gilt für alle Projekte, die keine eigene Obergrenze gesetzt haben.
// Ein Wert von 0 oder kleiner schaltet die Begrenzung vollständig ab.
func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget {
return &WriteBudget{
zaehler: neuerZaehler(fenster),
standard: standard,
}
}
// Consume verbucht einen Schreibvorgang für ein Projekt und sagt, ob er noch ins
// Budget passte.
//
// projektLimit ist die eigene Obergrenze des Projekts; bei 0 gilt der Standard
// aus NewWriteBudget. Die Obergrenze wird bei jedem Aufruf mitgegeben statt im
// Budget gespeichert — so wirkt eine Änderung in der Verwaltung sofort, ohne
// dass hier irgendein zwischengespeicherter Wert veralten könnte.
//
// Gezählt wird auch der abgelehnte Aufruf. Sonst könnte ein Agent, der stur
// weiterversucht, das Fenster nie volllaufen lassen — und der Zählerstand wäre
// keine ehrliche Auskunft darüber, wie viel gerade los ist.
//
// ueberschreitung sagt, der wievielte Aufruf über der Grenze das war: 0 solange
// alles im Rahmen ist, 1 beim allerersten abgelehnten Aufruf, dann aufsteigend.
//
// Dieser dritte Rückgabewert sieht nach Ballast aus, hat aber einen konkreten
// Zweck: Der Aufrufer soll die Überschreitung protokollieren können, ohne bei
// JEDEM abgelehnten Aufruf eine Zeile zu schreiben. Ein Agent in einer Schleife
// erzeugt sonst genau die Datenflut, die das Budget verhindern soll — nur eben
// im Protokoll statt in den Einträgen. Mit ueberschreitung == 1 als Bedingung
// entsteht je Fenster genau ein Vermerk.
func (b *WriteBudget) Consume(projektID string, projektLimit int) (ok bool, retryAfter time.Duration, ueberschreitung int) {
grenze := b.grenzeFuer(projektLimit)
if grenze <= 0 {
return true, 0, 0 // Begrenzung abgeschaltet
}
anzahl, restlaufzeit := b.zaehler.zaehle(projektID)
if anzahl > grenze {
return false, restlaufzeit, anzahl - grenze
}
return true, 0, 0
}
// Remaining sagt, wie viele Schreibvorgänge im laufenden Fenster noch frei sind,
// ohne etwas zu verbrauchen. Für die Anzeige in der Verwaltung.
func (b *WriteBudget) Remaining(projektID string, projektLimit int) (frei int, grenze int) {
grenze = b.grenzeFuer(projektLimit)
if grenze <= 0 {
return 0, 0
}
anzahl, _ := b.zaehler.lies(projektID)
frei = grenze - anzahl
if frei < 0 {
frei = 0
}
return frei, grenze
}
// grenzeFuer löst die geltende Obergrenze auf: erst das Projekt, dann der Standard.
func (b *WriteBudget) grenzeFuer(projektLimit int) int {
if projektLimit > 0 {
return projektLimit
}
return b.standard
}

109
internal/auth/session.go Normal file
View file

@ -0,0 +1,109 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
// SessionCookieName ist der Name des Cookies, der die signierte Sitzung trägt.
//
// Wird der Name geändert, gelten alle bestehenden Sitzungen als nicht vorhanden —
// jeder muss sich einmal neu anmelden. Harmlos, aber kein Grund, ihn ohne Anlass
// anzufassen.
const SessionCookieName = "munin_session"
// SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt.
const SessionTTL = 30 * 24 * time.Hour
// SubjectPlatformAdmin ist das Session-Subject des Plattform-Admins (ADMIN_TOKEN).
const SubjectPlatformAdmin = "platform"
// SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung.
const SubjectKeyPrefix = "key:"
// Signer signiert und prüft alles, was der Server einem Browser mitgibt:
// Session-Cookies und CSRF-Tokens.
//
// Ein eigener Typ statt loser Funktionen, damit das Geheimnis genau einmal
// übergeben wird und nicht durch jeden Aufruf gereicht werden muss.
type Signer struct {
secret []byte
}
// NewSigner erzeugt einen Signer aus dem SESSION_SECRET.
func NewSigner(secret []byte) *Signer {
return &Signer{secret: secret}
}
// sign berechnet den HMAC-SHA256 einer Nutzlast.
//
// HMAC ist ein Prüfsummenverfahren mit Geheimnis: Wer das Geheimnis nicht kennt,
// kann zu einer Nutzlast keine passende Prüfsumme bilden. Damit kann der Server
// erkennen, ob ein Cookie-Inhalt von ihm selbst stammt oder unterwegs verändert wurde.
func (s *Signer) sign(payload string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
// SignSession erstellt den Cookie-Wert für ein Subject.
//
// subject ist entweder SubjectPlatformAdmin oder "key:<api-key-id>".
//
// Aufbau: base64("<subject>|<ablauf-unix>") + "." + HMAC
//
// Der Cookie ist lesbar, aber nicht fälschbar. Er enthält bewusst keinen Key —
// wer ihn stiehlt, bekommt eine Sitzung, aber kein dauerhaftes Zugangsmittel für
// die API. Und ob der zugehörige Key überhaupt noch gültig ist, prüft der Server
// bei jedem Request neu in der Datenbank (siehe internal/web).
func (s *Signer) SignSession(subject string) string {
expiry := time.Now().Add(SessionTTL).Unix()
payload := fmt.Sprintf("%s|%d", subject, expiry)
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
return encoded + "." + s.sign(payload)
}
// VerifySession prüft Signatur und Ablaufdatum und gibt das Subject zurück.
func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) {
encoded, signature, found := strings.Cut(cookieValue, ".")
if !found {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
payload := string(payloadBytes)
// Reihenfolge ist wichtig: erst die Signatur prüfen, dann den Inhalt auswerten.
// Alles vor dieser Zeile stammt aus einer nicht vertrauenswürdigen Quelle.
if subtle.ConstantTimeCompare([]byte(signature), []byte(s.sign(payload))) != 1 {
return "", false
}
subject, expiryStr, found := strings.Cut(payload, "|")
if !found {
return "", false
}
expiry, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil || time.Now().Unix() > expiry {
return "", false
}
return subject, true
}
// KeyIDFromSubject holt die API-Key-ID aus einem Session-Subject.
// Beim Plattform-Admin gibt es keine — dann ist ok false.
func KeyIDFromSubject(subject string) (keyID string, ok bool) {
return strings.CutPrefix(subject, SubjectKeyPrefix)
}

View file

@ -0,0 +1,179 @@
// Dieses File regelt, wann dem Header X-Forwarded-For geglaubt wird.
//
// # Das Problem
//
// Läuft das Wiki direkt am Netz, ist r.RemoteAddr die echte IP des Besuchers.
// Läuft davor ein Reverse-Proxy (nginx, Caddy, Traefik, Cloudflare), sieht der
// Server nur noch die IP des Proxys — für alle Besucher dieselbe. Das
// IP-Rate-Limiting (ratelimit.go) wäre damit wirkungslos: entweder es sperrt
// niemanden aus, oder es sperrt beim ersten Angreifer gleich alle.
//
// Der Proxy schickt die echte IP deshalb im Header X-Forwarded-For mit.
//
// # Warum man diesem Header nicht einfach glauben darf
//
// Header kann jeder Client frei setzen. Würde der Server X-Forwarded-For
// bedingungslos übernehmen, bräuchte ein Angreifer nur bei jedem Login-Versuch
// eine andere Fantasie-IP zu schicken — das Rate-Limiting wäre komplett
// ausgehebelt, und das Audit-Log stünde voller erfundener Adressen.
//
// # Die Lösung
//
// Der Header wird nur ausgewertet, wenn der Request tatsächlich von einem als
// vertrauenswürdig konfigurierten Proxy kommt. Konfiguriert wird das über die
// Umgebungsvariable TRUSTED_PROXY, z.B.:
//
// TRUSTED_PROXY=127.0.0.1,10.0.0.0/8
//
// Ist sie nicht gesetzt (Standard), wird der Header ignoriert — sicher per Default.
package auth
import (
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"sync"
)
// trustedProxies hält die konfigurierten Netze. Wird einmal beim Start über
// SetTrustedProxies gefüllt und danach nur noch gelesen.
//
// Ein Paket-globaler Wert ist hier vertretbar, weil clientIP() von sehr vielen
// Stellen aufgerufen wird und die Konfiguration prozessweit gilt. Der RWMutex
// schützt trotzdem sauber, falls die Konfiguration doch einmal später gesetzt wird.
var (
trustedProxiesMu sync.RWMutex
trustedProxies []netip.Prefix
)
// SetTrustedProxies liest die TRUSTED_PROXY-Konfiguration ein.
//
// Akzeptiert eine kommaseparierte Liste aus einzelnen IPs ("127.0.0.1") und
// CIDR-Netzen ("10.0.0.0/8"). Eine einzelne IP wird intern zu einem Netz mit
// voller Präfixlänge — dann gilt für beide Fälle derselbe Vergleich.
func SetTrustedProxies(value string) error {
var prefixes []netip.Prefix
for _, raw := range strings.Split(value, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return fmt.Errorf("TRUSTED_PROXY: %q ist kein gültiges Netz: %w", entry, err)
}
prefixes = append(prefixes, prefix)
continue
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return fmt.Errorf("TRUSTED_PROXY: %q ist keine gültige IP-Adresse: %w", entry, err)
}
prefixes = append(prefixes, netip.PrefixFrom(addr, addr.BitLen()))
}
trustedProxiesMu.Lock()
defer trustedProxiesMu.Unlock()
trustedProxies = prefixes
return nil
}
// TrustedProxyCount gibt zurück wie viele Netze konfiguriert sind — nur damit
// main.go beim Start eine sinnvolle Zeile ins Log schreiben kann.
func TrustedProxyCount() int {
trustedProxiesMu.RLock()
defer trustedProxiesMu.RUnlock()
return len(trustedProxies)
}
// ClientIP ermittelt die Adresse des Aufrufers.
//
// Normalerweise ist das r.RemoteAddr. Steht ein Reverse-Proxy davor, ist das
// dessen Adresse — dann wird X-Forwarded-For ausgewertet, aber nur wenn der
// Absender über TRUSTED_PROXY als vertrauenswürdig hinterlegt ist.
func ClientIP(r *http.Request) string {
if ip, ok := forwardedClientIP(r); ok {
return ip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// forwardedClientIP liefert die echte Client-IP aus X-Forwarded-For — aber nur
// wenn der direkte Absender ein vertrauenswürdiger Proxy ist.
func forwardedClientIP(r *http.Request) (string, bool) {
trustedProxiesMu.RLock()
prefixes := trustedProxies
trustedProxiesMu.RUnlock()
if len(prefixes) == 0 {
return "", false
}
// Wer klopft direkt an? Nur dessen Vertrauenswürdigkeit zählt.
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return "", false
}
peer, err := netip.ParseAddr(host)
if err != nil {
return "", false
}
if !isTrustedProxy(peer, prefixes) {
return "", false
}
header := r.Header.Get("X-Forwarded-For")
if header == "" {
return "", false
}
// Format: "client, proxy1, proxy2" — ganz links steht der ursprüngliche Client.
//
// Wir gehen von rechts nach links und überspringen alle vertrauenswürdigen
// Proxys; der erste Eintrag der kein bekannter Proxy ist, ist der Client.
// Das ist wichtiger als es klingt: würde man stur den linkesten Eintrag nehmen,
// könnte ein Client einfach ein eigenes "X-Forwarded-For: 1.2.3.4" mitschicken,
// das der Proxy dann nur ergänzt — und die erfundene IP stünde ganz links.
parts := strings.Split(header, ",")
for i := len(parts) - 1; i >= 0; i-- {
candidate := strings.TrimSpace(parts[i])
addr, err := netip.ParseAddr(candidate)
if err != nil {
// Unlesbarer Eintrag — ab hier ist der Kette nicht mehr zu trauen.
return "", false
}
if isTrustedProxy(addr, prefixes) {
continue
}
return addr.String(), true
}
// Alle Einträge waren bekannte Proxys — dann bleibt nur der direkte Absender.
return "", false
}
// isTrustedProxy prüft ob eine Adresse in einem der konfigurierten Netze liegt.
func isTrustedProxy(addr netip.Addr, prefixes []netip.Prefix) bool {
// IPv4-Adressen können in IPv6-Schreibweise ankommen (::ffff:127.0.0.1).
// Unmap macht daraus wieder eine normale IPv4-Adresse, sonst schlägt der
// Vergleich mit einem IPv4-Netz fehl.
addr = addr.Unmap()
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}

131
internal/entry/entry.go Normal file
View file

@ -0,0 +1,131 @@
// Package entry enthält die Fachlogik rund um einen Wissenseintrag: die
// Darstellung als HTML, Textausschnitte für Suchergebnisse und die Auflösung
// der [[slug]]-Verweise.
//
// Bewusst getrennt von internal/store: Dort geht es darum, wie ein Eintrag
// gespeichert wird, hier darum, wie er aussieht. Beides ändert sich aus
// unterschiedlichen Gründen.
package entry
import (
"bytes"
"fmt"
"html"
"strings"
"github.com/yuin/goldmark"
"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 <script>alert(1)</script> 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) {
// 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 {
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
}
// RenderPlain gibt den Inhalt als reinen Text zurück — Verweise werden auf ihren
// Slug reduziert. Gedacht für Textausschnitte, wo Markdown-Zeichen nur stören.
func RenderPlain(markdown string) string {
return store.ReplaceLinks(markdown, func(slug string) string { return slug })
}
// snippetRadius bestimmt, wie viel Text vor und nach einem Treffer im Ausschnitt steht.
const snippetRadius = 120
// Snippet gibt einen Textausschnitt um den ersten Treffer des Suchbegriffs zurück.
//
// Der Ausschnitt ist bewusst kurz: Er dient dazu, einen Treffer einzuordnen —
// bei einer KI-Anfrage entscheidet er darüber, ob der ganze Eintrag nachgeladen
// wird. Wer den vollen Inhalt will, holt ihn gezielt.
func Snippet(content, query string) string {
text := RenderPlain(content)
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
if idx == -1 {
// Kein wörtlicher Treffer (z.B. weil der Fund über die Präfix-Suche kam) —
// dann der Anfang des Texts als Vorschau.
return kuerzen(text, snippetRadius+snippetRadius)
}
start := max(idx-snippetRadius, 0)
ende := min(idx+len(query)+snippetRadius, len(text))
ausschnitt := strings.TrimSpace(text[start:ende])
if start > 0 {
ausschnitt = "…" + ausschnitt
}
if ende < len(text) {
ausschnitt += "…"
}
return ausschnitt
}
// kuerzen schneidet einen Text auf höchstens n Zeichen.
func kuerzen(text string, n int) string {
text = strings.TrimSpace(text)
if len(text) <= n {
return text
}
return strings.TrimSpace(text[:n]) + "…"
}
// HighlightSnippet baut einen Textausschnitt mit hervorgehobenem Treffer als
// fertiges HTML.
//
// Wichtig: Erst wird der Text escaped, dann werden die <mark>-Auszeichnungen
// eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen.
func HighlightSnippet(content, query string) string {
ausschnitt := Snippet(content, query)
sicher := html.EscapeString(ausschnitt)
if query == "" {
return sicher
}
// Im escapten Text nach dem (ebenfalls escapten) Suchbegriff suchen, damit
// die Positionen zusammenpassen.
nadel := html.EscapeString(query)
idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel))
if idx == -1 {
return sicher
}
return sicher[:idx] + "<mark>" + sicher[idx:idx+len(nadel)] + "</mark>" + sicher[idx+len(nadel):]
}
// TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
func TitleFromContent(content string) string {
for _, zeile := range strings.Split(content, "\n") {
zeile = strings.TrimSpace(zeile)
if zeile == "" {
continue
}
zeile = strings.TrimLeft(zeile, "#")
zeile = strings.TrimSpace(zeile)
if zeile != "" {
return kuerzen(zeile, 120)
}
}
return "Ohne Titel"
}

View file

@ -0,0 +1,163 @@
package entry
import (
"strings"
"testing"
)
// TestRenderHTMLEscapesRawHTML ist ein Sicherheitstest.
//
// Das gerenderte Ergebnis wird in der Weboberfläche ohne weiteres Escaping
// eingesetzt. Der Schutz davor, dass jemand über einen Eintrag ein <script>
// einschleust, hängt damit allein daran, dass goldmark kein Roh-HTML durchreicht.
// Wird diese Einstellung je gelockert, schlägt dieser Test an.
func TestRenderHTMLEscapesRawHTML(t *testing.T) {
boesartig := []string{
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"<iframe src=\"https://fremd.example\"></iframe>",
}
for _, eingabe := range boesartig {
html, err := RenderHTML(eingabe, "/p/test/e/")
if err != nil {
t.Fatalf("RenderHTML(%q): %v", eingabe, err)
}
if strings.Contains(html, "<script") || strings.Contains(html, "onerror") || strings.Contains(html, "<iframe") {
t.Errorf("Roh-HTML kam durch: %q → %q", eingabe, html)
}
}
}
// TestRenderHTMLResolvesLinks prüft, dass [[bezeichner]] zu einem Link wird.
func TestRenderHTMLResolvesLinks(t *testing.T) {
html, err := RenderHTML("Siehe [[server-zugang]] für Details.", "/p/test/e/")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, `href="/p/test/e/server-zugang"`) {
t.Errorf("der Verweis wurde nicht aufgelöst: %s", html)
}
}
// TestRenderHTMLKeepsMarkdown prüft, dass gewöhnliches Markdown funktioniert.
func TestRenderHTMLKeepsMarkdown(t *testing.T) {
html, err := RenderHTML("# Überschrift\n\n- Punkt eins\n- Punkt zwei\n\n`code`", "/p/test/e/")
if err != nil {
t.Fatal(err)
}
for _, erwartet := range []string{"<h1", "<ul>", "<li>", "<code>"} {
if !strings.Contains(html, erwartet) {
t.Errorf("%s fehlt im Ergebnis: %s", erwartet, html)
}
}
}
func TestRenderPlainStripsLinkSyntax(t *testing.T) {
got := RenderPlain("Siehe [[server-zugang]] für Details.")
if strings.Contains(got, "[[") {
t.Errorf("die Verweis-Klammern blieben stehen: %q", got)
}
if !strings.Contains(got, "server-zugang") {
t.Errorf("der Bezeichner ging verloren: %q", got)
}
}
// ── Textausschnitte ──────────────────────────────────────────────────────────
func TestSnippetAroundMatch(t *testing.T) {
text := strings.Repeat("Vorlauf. ", 40) + "GESUCHT " + strings.Repeat("Nachlauf. ", 40)
got := Snippet(text, "GESUCHT")
if !strings.Contains(got, "GESUCHT") {
t.Error("der Treffer fehlt im Ausschnitt")
}
// Der Ausschnitt soll kurz sein — er dient dem Einordnen, nicht dem Lesen.
if len(got) > 400 {
t.Errorf("Ausschnitt ist %d Zeichen lang, das ist zu viel", len(got))
}
// Vorne und hinten wurde gekürzt, das muss sichtbar sein.
if !strings.HasPrefix(got, "…") || !strings.HasSuffix(got, "…") {
t.Errorf("die Auslassungszeichen fehlen: %q", got)
}
}
func TestSnippetWithoutMatch(t *testing.T) {
// Kein wörtlicher Treffer — etwa weil der Fund über die Präfix-Suche kam.
got := Snippet("Ein kurzer Text ohne den Suchbegriff.", "deploy")
if got == "" {
t.Error("ohne Treffer kam gar kein Ausschnitt zurück")
}
if !strings.Contains(got, "kurzer Text") {
t.Errorf("es wurde nicht der Textanfang genommen: %q", got)
}
}
func TestSnippetShortText(t *testing.T) {
got := Snippet("Kurz.", "Kurz")
if got != "Kurz." {
t.Errorf("Snippet = %q, erwartet %q", got, "Kurz.")
}
}
// TestHighlightSnippetEscapesFirst ist ein Sicherheitstest: Der Inhalt muss
// escaped werden, BEVOR die Hervorhebung eingesetzt wird. Andersherum könnte ein
// Eintrag eigenes HTML in die Trefferliste einschleusen.
func TestHighlightSnippetEscapesFirst(t *testing.T) {
got := HighlightSnippet("Ein <script>alert(1)</script> im Text", "script")
if strings.Contains(got, "<script>") {
t.Errorf("unescaptes HTML im Ausschnitt: %q", got)
}
// Die spitzen Klammern des Inhalts müssen escaped sein. Geprüft wird auf
// "&lt;" statt auf "&lt;script&gt;", weil die Hervorhebung mitten im
// escapten Text steht: "&lt;<mark>script</mark>&gt;".
if !strings.Contains(got, "&lt;") || !strings.Contains(got, "&gt;") {
t.Errorf("der Inhalt wurde nicht escaped: %q", got)
}
// Die Hervorhebung selbst muss echtes HTML sein.
if !strings.Contains(got, "<mark>") {
t.Errorf("der Treffer wurde nicht hervorgehoben: %q", got)
}
}
func TestHighlightSnippetWithoutQuery(t *testing.T) {
got := HighlightSnippet("Text mit <Klammern>", "")
if strings.Contains(got, "<mark>") {
t.Error("ohne Suchbegriff wurde etwas hervorgehoben")
}
if strings.Contains(got, "<Klammern>") {
t.Errorf("der Inhalt wurde nicht escaped: %q", got)
}
}
// ── Titel raten ──────────────────────────────────────────────────────────────
func TestTitleFromContent(t *testing.T) {
cases := map[string]string{
"# Deployment\n\nText": "Deployment",
"## Unterüberschrift\nX": "Unterüberschrift",
"\n\nErste echte Zeile\nX": "Erste echte Zeile",
"": "Ohne Titel",
" \n \n": "Ohne Titel",
}
for eingabe, erwartet := range cases {
if got := TitleFromContent(eingabe); got != erwartet {
t.Errorf("TitleFromContent(%q) = %q, erwartet %q", eingabe, got, erwartet)
}
}
}
func TestTitleFromContentIsShortened(t *testing.T) {
lang := strings.Repeat("Wort ", 100)
got := TitleFromContent(lang)
if len(got) > 130 {
t.Errorf("der Titel ist %d Zeichen lang — er wurde nicht gekürzt", len(got))
}
}

309
internal/export/export.go Normal file
View file

@ -0,0 +1,309 @@
// Package export schreibt den Inhalt eines Projekts als Markdown-Dateien auf die
// Platte.
//
// # Wozu?
//
// Die Wahrheit steht in der Datenbank — das ist richtig so, weil Einträge
// Datensätze sind und keine Dokumente (siehe internal/store). Eine einzelne
// SQLite-Datei ist aber ein schlechter Ort für eine Sicherung, aus der man
// später auch ohne dieses Programm noch etwas lesen möchte:
//
// - Sie lässt sich nicht sinnvoll versionieren. Ein Git-Diff zweier
// Binärdateien sagt nichts.
// - Sie braucht ein Werkzeug zum Öffnen.
// - Wer nur schnell etwas nachschlagen will, kann nicht grep verwenden.
//
// Der Export löst das, ohne die Datenbank als führendes System aufzugeben: Ein
// Ordner voller Markdown-Dateien, der sich in ein Git-Repository legen lässt.
// Jede Datei trägt alle Felder im Kopf, sodass sich daraus später auch wieder
// importieren ließe.
//
// # Was der Export NICHT ist
//
// Er ist keine zweite Wahrheit. Änderungen an den exportierten Dateien wirken
// nicht zurück — wer sie bearbeitet, verliert seine Arbeit beim nächsten Lauf.
package export
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"munin/internal/store"
)
// Exporter schreibt Projekte in ein Zielverzeichnis.
type Exporter struct {
store *store.Store
root string
}
// New erstellt einen Exporter, der unter root schreibt.
func New(s *store.Store, root string) *Exporter {
return &Exporter{store: s, root: root}
}
// Stats fasst zusammen, was ein Lauf bewirkt hat.
type Stats struct {
Written int // geschriebene Dateien
Removed int // entfernte Dateien gelöschter Einträge
}
// ExportProject schreibt alle Einträge eines Projekts nach <root>/<projekt-slug>/.
//
// 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) {
var stats Stats
// 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
// garantiert — ein späterer Fehler an anderer Stelle soll nicht dazu führen,
// dass hier außerhalb des Zielordners geschrieben wird.
if !store.ValidSlug(project.Slug) {
return stats, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug)
}
ziel := filepath.Join(e.root, 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)
if err != nil {
return stats, err
}
// Namen merken, um danach zu wissen, was übrig bleibt.
erwartet := make(map[string]bool, len(eintraege)+1)
erwartet[indexDateiname] = true
for _, eintrag := range eintraege {
if !store.ValidSlug(eintrag.Slug) {
return stats, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug)
}
name := eintrag.Slug + ".md"
erwartet[name] = true
if err := schreibeDatei(filepath.Join(ziel, name), rendere(eintrag)); err != nil {
return stats, err
}
stats.Written++
}
// Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist.
if err := schreibeDatei(filepath.Join(ziel, indexDateiname), rendereIndex(project, eintraege)); err != nil {
return stats, err
}
stats.Written++
entfernt, err := raeumeAuf(ziel, erwartet)
if err != nil {
return stats, err
}
stats.Removed = entfernt
return stats, nil
}
// ExportAll exportiert alle Projekte eines Workspace.
func (e *Exporter) ExportAll(ctx context.Context, workspaceID string) (Stats, error) {
var gesamt Stats
projekte, err := e.store.ListProjects(ctx, workspaceID)
if err != nil {
return gesamt, err
}
for _, p := range projekte {
stats, err := e.ExportProject(ctx, p)
if err != nil {
return gesamt, fmt.Errorf("Projekt %q: %w", p.Slug, err)
}
gesamt.Written += stats.Written
gesamt.Removed += stats.Removed
}
return gesamt, nil
}
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
const indexDateiname = "_index.md"
// schreibeDatei legt eine Datei an — nur, wenn sich der Inhalt geändert hat.
//
// # Warum der Vergleich?
//
// Der Export ist als Git-Sicherung gedacht. Würde jede Datei bei jedem Lauf neu
// geschrieben, änderte sich zwar der Inhalt nicht, wohl aber die Änderungszeit —
// und Werkzeuge, die darauf schauen, meldeten ständig Bewegung, wo keine ist.
// Git selbst stört das nicht, aber Sicherungsprogramme und Dateiüberwachungen schon.
func schreibeDatei(pfad, inhalt string) error {
if vorhanden, err := os.ReadFile(pfad); err == nil && string(vorhanden) == inhalt {
return nil
}
if err := os.WriteFile(pfad, []byte(inhalt), 0640); err != nil {
return fmt.Errorf("%s konnte nicht geschrieben werden: %w", pfad, err)
}
return nil
}
// raeumeAuf entfernt Markdown-Dateien, die zu keinem Eintrag mehr gehören.
//
// Es werden ausschließlich .md-Dateien angefasst und keine Unterordner — falls
// jemand eigene Notizen daneben legt, bleiben sie unberührt.
func raeumeAuf(ordner string, erwartet map[string]bool) (int, error) {
eintraege, err := os.ReadDir(ordner)
if err != nil {
return 0, fmt.Errorf("Exportordner konnte nicht gelesen werden: %w", err)
}
var entfernt int
for _, e := range eintraege {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || erwartet[e.Name()] {
continue
}
if err := os.Remove(filepath.Join(ordner, e.Name())); err != nil {
return entfernt, fmt.Errorf("%s konnte nicht entfernt werden: %w", e.Name(), err)
}
entfernt++
}
return entfernt, nil
}
// rendere baut den Dateiinhalt eines Eintrags: YAML-Kopf plus Markdown.
//
// Der Kopf enthält alle Felder, damit sich der Bestand daraus auch wieder
// einlesen ließe — nicht nur Titel und Text.
func rendere(e store.Entry) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "slug", e.Slug)
schreibeFeld(&b, "title", e.Title)
if len(e.Tags) > 0 {
b.WriteString("tags: [")
for i, t := range e.Tags {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(yamlWert(t))
}
b.WriteString("]\n")
}
schreibeFeld(&b, "source", e.Source)
if e.Author != "" {
schreibeFeld(&b, "author", e.Author)
}
b.WriteString("curated: " + strconv.FormatBool(e.Curated) + "\n")
b.WriteString("version: " + strconv.Itoa(e.Version) + "\n")
schreibeFeld(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339))
schreibeFeld(&b, "updated_at", e.UpdatedAt.UTC().Format(time.RFC3339))
b.WriteString("---\n\n")
b.WriteString("# " + e.Title + "\n\n")
b.WriteString(strings.TrimRight(e.Content, "\n"))
b.WriteString("\n")
return b.String()
}
// rendereIndex baut die Übersicht eines Projekts, nach Schlagwort gruppiert.
//
// Bewusst OHNE Zeitstempel im Kopf: Er würde sich bei jedem Lauf ändern und
// damit auch dann einen Git-Diff erzeugen, wenn sich inhaltlich nichts getan
// hat. Wann exportiert wurde, sagen der Git-Commit und die Änderungszeit der
// Datei ohnehin — nur eben nur dann, wenn es etwas zu sagen gab.
func rendereIndex(project store.Project, eintraege []store.Entry) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "title", project.Name+" — Übersicht")
b.WriteString("---\n\n")
b.WriteString("# " + project.Name + "\n\n")
if project.Description != "" {
b.WriteString(project.Description + "\n\n")
}
b.WriteString(fmt.Sprintf("%d Einträge. Automatisch erzeugt — Änderungen hier wirken nicht zurück.\n\n",
len(eintraege)))
// Nach erstem Schlagwort gruppieren; Einträge ohne Schlagwort ans Ende.
const ohne = "Ohne Schlagwort"
gruppen := make(map[string][]store.Entry)
for _, e := range eintraege {
kategorie := ohne
if len(e.Tags) > 0 {
kategorie = e.Tags[0]
}
gruppen[kategorie] = append(gruppen[kategorie], e)
}
kategorien := make([]string, 0, len(gruppen))
for k := range gruppen {
kategorien = append(kategorien, k)
}
sort.Slice(kategorien, func(i, j int) bool {
if kategorien[i] == ohne {
return false
}
if kategorien[j] == ohne {
return true
}
return kategorien[i] < kategorien[j]
})
for _, kategorie := range kategorien {
b.WriteString("## " + kategorie + "\n\n")
liste := gruppen[kategorie]
sort.Slice(liste, func(i, j int) bool { return liste[i].Title < liste[j].Title })
for _, e := range liste {
b.WriteString(fmt.Sprintf("- [%s](%s.md)", e.Title, e.Slug))
if e.Curated {
b.WriteString(" ✓")
}
if e.Source == store.SourceAgent {
b.WriteString(" _(Agent)_")
}
b.WriteString("\n")
}
b.WriteString("\n")
}
return b.String()
}
// schreibeFeld hängt ein YAML-Feld an.
func schreibeFeld(b *strings.Builder, name, wert string) {
b.WriteString(name + ": " + yamlWert(wert) + "\n")
}
// yamlWert bringt einen Wert in eine sichere YAML-Schreibweise.
//
// Immer in doppelte Anführungszeichen und die enthaltenen maskieren: Damit ist
// egal, ob der Wert einen Doppelpunkt, ein Rautezeichen oder einen Zeilenumbruch
// enthält. Ohne diese Vorsicht würde ein Titel wie "Fehler: Zugriff verweigert"
// die Datei unlesbar machen.
func yamlWert(s string) string {
ersetzt := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`,
"\r", ``,
"\t", `\t`,
).Replace(s)
return `"` + ersetzt + `"`
}

View file

@ -0,0 +1,344 @@
package export
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"munin/internal/store"
)
// aufbau legt Datenbank, Projekt und Exporter an.
func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Datenbank: %v", err)
}
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")
if err != nil {
t.Fatal(err)
}
ziel := t.TempDir()
return New(db, ziel), db, projekt, ziel
}
// lies gibt den Inhalt einer exportierten Datei zurück.
func lies(t *testing.T, teile ...string) string {
t.Helper()
inhalt, err := os.ReadFile(filepath.Join(teile...))
if err != nil {
t.Fatalf("Datei nicht lesbar: %v", err)
}
return string(inhalt)
}
func TestExportWritesEntries(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
ctx := context.Background()
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "deployment", Title: "Deployment-Prozess",
Content: "Wir deployen über GitHub Actions.", Tags: []string{"ops", "deployment"},
Source: store.SourceAgent, Author: "claude", Curated: true,
}); err != nil {
t.Fatal(err)
}
stats, err := e.ExportProject(ctx, projekt)
if err != nil {
t.Fatalf("Export: %v", err)
}
// Ein Eintrag plus Übersicht.
if stats.Written != 2 {
t.Errorf("%d Dateien geschrieben, erwartet 2", stats.Written)
}
inhalt := lies(t, ziel, "plattform", "deployment.md")
// Der Kopf muss alle Felder tragen, damit sich der Bestand daraus auch
// wieder einlesen ließe.
for _, erwartet := range []string{
`slug: "deployment"`,
`title: "Deployment-Prozess"`,
`tags: ["deployment", "ops"]`,
`source: "agent"`,
`author: "claude"`,
"curated: true",
"version: 1",
"created_at:",
"updated_at:",
} {
if !strings.Contains(inhalt, erwartet) {
t.Errorf("%q fehlt im Dateikopf:\n%s", erwartet, inhalt)
}
}
if !strings.Contains(inhalt, "Wir deployen über GitHub Actions.") {
t.Error("der Inhalt fehlt")
}
}
// TestExportEscapesYAML prüft, dass Sonderzeichen den Dateikopf nicht zerlegen.
//
// Ein Titel wie "Fehler: Zugriff verweigert" enthält einen Doppelpunkt und würde
// ohne Anführungszeichen die YAML-Struktur zerstören.
func TestExportEscapesYAML(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
ctx := context.Background()
heikel := `Fehler: "Zugriff verweigert" — siehe #42`
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "heikel", Title: heikel,
Content: "Text", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
if _, err := e.ExportProject(ctx, projekt); err != nil {
t.Fatal(err)
}
inhalt := lies(t, ziel, "plattform", "heikel.md")
// Der Wert muss in Anführungszeichen stehen und die enthaltenen maskiert sein.
if !strings.Contains(inhalt, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) {
t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", inhalt)
}
// Der Kopf muss weiterhin genau zwei Trennlinien haben.
if anzahl := strings.Count(inhalt, "\n---\n"); anzahl != 1 {
kopfEnde := strings.Index(inhalt[4:], "---")
if kopfEnde == -1 {
t.Error("der Dateikopf wurde nicht sauber abgeschlossen")
}
}
}
// TestExportRemovesOrphans prüft, dass gelöschte Einträge auch aus dem Export
// verschwinden — sonst wäre die Sicherung nach kurzer Zeit voller Karteileichen.
func TestExportRemovesOrphans(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "bleibt", Title: "Bleibt",
Content: "x", Source: store.SourceHuman})
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 {
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 {
t.Fatal(err)
}
stats, err := e.ExportProject(ctx, projekt)
if err != nil {
t.Fatal(err)
}
if stats.Removed != 1 {
t.Errorf("%d Dateien entfernt, erwartet 1", stats.Removed)
}
if _, err := os.Stat(filepath.Join(ziel, "plattform", "geht.md")); err == nil {
t.Error("die Datei des gelöschten Eintrags liegt noch da")
}
if _, err := os.Stat(filepath.Join(ziel, "plattform", "bleibt.md")); err != nil {
t.Error("beim Aufräumen wurde zu viel gelöscht")
}
}
// TestExportLeavesForeignFilesAlone prüft, dass eigene Dateien im Zielordner
// nicht angetastet werden.
func TestExportLeavesForeignFilesAlone(t *testing.T) {
e, _, projekt, ziel := aufbau(t)
ctx := context.Background()
ordner := filepath.Join(ziel, "plattform")
if err := os.MkdirAll(ordner, 0750); err != nil {
t.Fatal(err)
}
// Eine Nicht-Markdown-Datei und ein Unterordner müssen überleben.
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 {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(ordner, "notizen.txt")); err != nil {
t.Error("eine fremde Datei wurde entfernt")
}
if _, err := os.Stat(filepath.Join(ordner, "anhaenge")); err != nil {
t.Error("ein Unterordner wurde entfernt")
}
}
// TestExportIsStable prüft, dass ein zweiter Lauf ohne Änderungen die Dateien
// nicht anfasst — sonst meldeten Sicherungswerkzeuge ständig Bewegung.
func TestExportIsStable(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
ctx := context.Background()
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 {
t.Fatal(err)
}
pfad := filepath.Join(ziel, "plattform", "eintrag.md")
vorher, err := os.Stat(pfad)
if err != nil {
t.Fatal(err)
}
if _, err := e.ExportProject(ctx, projekt); err != nil {
t.Fatal(err)
}
nachher, err := os.Stat(pfad)
if err != nil {
t.Fatal(err)
}
if !vorher.ModTime().Equal(nachher.ModTime()) {
t.Error("die unveränderte Datei wurde neu geschrieben")
}
}
// TestExportProducesNoDiffWhenUnchanged ist der Test zu einem Mangel, den erst
// der Praxislauf gezeigt hat: Die Übersicht trug einen Zeitstempel im Kopf und
// änderte sich damit bei JEDEM Export — auch wenn inhaltlich nichts passiert war.
// Für eine Git-Sicherung ist das der entscheidende Punkt: Ein Lauf ohne
// Änderungen darf keinen Diff erzeugen.
func TestExportProducesNoDiffWhenUnchanged(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
ctx := context.Background()
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 {
t.Fatal(err)
}
// Inhalt aller Dateien merken.
ordner := filepath.Join(ziel, "plattform")
vorher := make(map[string]string)
dateien, _ := os.ReadDir(ordner)
for _, d := range dateien {
vorher[d.Name()] = lies(t, ordner, d.Name())
}
if _, err := e.ExportProject(ctx, projekt); err != nil {
t.Fatal(err)
}
for name, alt := range vorher {
if neu := lies(t, ordner, name); neu != alt {
t.Errorf("%s hat sich ohne inhaltliche Änderung verändert:\n--- vorher ---\n%s\n--- nachher ---\n%s",
name, alt, neu)
}
}
}
// TestExportIndex prüft die Übersicht.
func TestExportIndex(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "Alpha",
Content: "x", Tags: []string{"ops"}, Source: store.SourceHuman, Curated: true})
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 {
t.Fatal(err)
}
index := lies(t, ziel, "plattform", "_index.md")
// Kein Zeitstempel im Kopf — siehe TestExportProducesNoDiffWhenUnchanged.
if strings.Contains(index, "generated_at") {
t.Error("die Übersicht trägt einen Zeitstempel und erzeugt damit bei jedem Lauf einen Diff")
}
for _, erwartet := range []string{
"# Plattform-Team",
"Wissen des Teams",
"## ops",
"## Ohne Schlagwort",
"[Alpha](a.md)",
"[Beta](b.md)",
} {
if !strings.Contains(index, erwartet) {
t.Errorf("%q fehlt in der Übersicht:\n%s", erwartet, index)
}
}
}
// TestExportAllProjects prüft, dass jedes Projekt seinen eigenen Ordner bekommt —
// die Abschottung gilt auch im Export.
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", "")
if err != nil {
t.Fatal(err)
}
db.PutEntry(ctx, store.EntryWrite{ProjectID: projektA.ID, Slug: "a-wissen", Title: "A",
Content: "gehört zu A", Source: store.SourceHuman})
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 {
t.Fatal(err)
}
if inhalt := lies(t, ziel, "plattform", "a-wissen.md"); !strings.Contains(inhalt, "gehört zu A") {
t.Error("Projekt A wurde nicht korrekt exportiert")
}
if inhalt := lies(t, ziel, "anderes", "b-wissen.md"); !strings.Contains(inhalt, "gehört zu B") {
t.Error("Projekt B wurde nicht korrekt exportiert")
}
// Kein Eintrag darf im Ordner des anderen Projekts liegen.
if _, err := os.Stat(filepath.Join(ziel, "plattform", "b-wissen.md")); err == nil {
t.Error("ein Eintrag aus Projekt B landete im Ordner von Projekt A")
}
}
func TestExportEmptyProject(t *testing.T) {
e, _, projekt, ziel := aufbau(t)
stats, err := e.ExportProject(context.Background(), projekt)
if err != nil {
t.Fatalf("ein leeres Projekt führte zu einem Fehler: %v", err)
}
// Nur die Übersicht.
if stats.Written != 1 {
t.Errorf("%d Dateien, erwartet 1", stats.Written)
}
if _, err := os.Stat(filepath.Join(ziel, "plattform", "_index.md")); err != nil {
t.Error("auch bei einem leeren Projekt sollte eine Übersicht entstehen")
}
}

View file

@ -0,0 +1,561 @@
package mcpserver
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/search"
"munin/internal/store"
"munin/internal/web"
)
// aufbau legt Datenbank, Projekt und Handler an.
//
// Das Schreibbudget ist bewusst großzügig: Die meisten Tests haben mit der
// Begrenzung nichts zu tun, sollen aber trotzdem durch denselben Code laufen wie
// der Betrieb. Wer die Grenze prüfen will, nimmt aufbauMitBudget.
func aufbau(t *testing.T) (*Handler, *store.Store, store.Project) {
t.Helper()
return aufbauMitBudget(t, 1000)
}
// aufbauMitBudget wie aufbau, aber mit einer wählbaren Obergrenze je Stunde.
func aufbauMitBudget(t *testing.T, limit int) (*Handler, *store.Store, store.Project) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Datenbank: %v", err)
}
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, "test", "Testprojekt", "")
if err != nil {
t.Fatal(err)
}
budget := auth.NewWriteBudget(limit, time.Hour)
return New(db, search.NewRegistry(db), budget), db, projekt
}
// ruf ruft ein MCP-Werkzeug auf.
//
// Statt einen HTTP-Server zu starten, verbindet sich der Client über ein
// In-Memory-Transportpaar direkt mit dem Server — dieselbe Logik, ohne Netzwerk.
// Die Sitzung (Projekt und Rolle) wird im Context mitgegeben, so wie es sonst
// web.APIAuth tut.
func ruf(t *testing.T, h *Handler, projekt store.Project, rolle auth.Role, werkzeug string, args map[string]any) *mcp.CallToolResult {
t.Helper()
sess := web.Session{
Subject: "key:test",
Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: projekt.ID, Role: string(rolle)},
Project: projekt,
Role: rolle,
}
ctx := web.WithSessionForTest(context.Background(), sess)
clientTransport, serverTransport := mcp.NewInMemoryTransports()
serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("Serververbindung: %v", err)
}
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("Clientverbindung: %v", err)
}
defer clientSession.Close()
ergebnis, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: werkzeug, Arguments: args})
if err != nil {
t.Fatalf("Aufruf %q: %v", werkzeug, err)
}
return ergebnis
}
// text fasst die Textbausteine eines Ergebnisses zusammen.
func text(r *mcp.CallToolResult) string {
var b strings.Builder
for _, c := range r.Content {
if t, ok := c.(*mcp.TextContent); ok {
b.WriteString(t.Text)
}
}
return b.String()
}
// ── Werkzeugbestand ──────────────────────────────────────────────────────────
// TestToolsAndInstructions prüft, dass alle Werkzeuge da sind und die
// Anleitung mitgeliefert wird. Die Anleitung ist das wirksamste Stellrad für die
// Qualität der Antworten — sie darf nicht versehentlich wegfallen.
func TestToolsAndInstructions(t *testing.T) {
h, _, projekt := aufbau(t)
ctx := context.Background()
clientTransport, serverTransport := mcp.NewInMemoryTransports()
serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatal(err)
}
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatal(err)
}
defer clientSession.Close()
if instr := clientSession.InitializeResult().Instructions; !strings.Contains(instr, "recall") {
t.Error("die Anleitung fehlt oder erklärt recall nicht")
}
liste, err := clientSession.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
vorhanden := make(map[string]bool)
for _, tool := range liste.Tools {
vorhanden[tool.Name] = true
}
for _, name := range []string{"recall", "get", "list", "remember", "forget"} {
if !vorhanden[name] {
t.Errorf("Werkzeug %q fehlt", name)
}
}
}
// ── Schreiben und Dublettenvermeidung ────────────────────────────────────────
// TestRememberDeduplicatesBySlug prüft den Kern des Gedächtnisses: Zweimal
// dasselbe Thema unter demselben Bezeichner ergibt einen Eintrag, nicht zwei.
func TestRememberDeduplicatesBySlug(t *testing.T) {
h, db, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "Erste Erkenntnis",
})
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "Zweite, bessere Erkenntnis",
})
anzahl, err := db.CountEntries(context.Background(), projekt.ID)
if err != nil {
t.Fatal(err)
}
if anzahl != 1 {
t.Errorf("%d Einträge, erwartet 1 — es wurde eine Dublette angelegt", anzahl)
}
e, _ := db.EntryBySlug(context.Background(), projekt.ID, "deployment")
if e.Content != "Zweite, bessere Erkenntnis" {
t.Error("der Eintrag wurde nicht aktualisiert")
}
if e.Version != 2 {
t.Errorf("Version = %d, erwartet 2", e.Version)
}
}
// TestRememberRecordsProvenance prüft, dass nachvollziehbar bleibt, wer geschrieben hat.
func TestRememberRecordsProvenance(t *testing.T) {
h, db, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "notiz", "title": "Notiz", "content": "Inhalt",
})
e, _ := db.EntryBySlug(context.Background(), projekt.ID, "notiz")
if e.Source != store.SourceAgent {
t.Errorf("Source = %q, erwartet %q", e.Source, store.SourceAgent)
}
if e.Author != "test-agent" {
t.Errorf("Author = %q, erwartet den Namen des Zugangs", e.Author)
}
}
// TestRememberDerivesSlug prüft, dass ein fehlender Bezeichner aus dem Titel
// abgeleitet wird — das Modell soll nicht an Formalien scheitern.
func TestRememberDerivesSlug(t *testing.T) {
h, db, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"title": "Der Deployment-Prozess", "content": "Inhalt",
})
if _, err := db.EntryBySlug(context.Background(), projekt.ID, "der-deployment-prozess"); err != nil {
t.Errorf("kein Eintrag unter dem abgeleiteten Bezeichner: %v", err)
}
}
// TestRememberDetectsConflict prüft das optimistische Sperren.
func TestRememberDetectsConflict(t *testing.T) {
h, _, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "Fassung 1",
})
// Agent A schreibt und erhöht damit auf Fassung 2.
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "von A",
})
// Agent B hatte noch Fassung 1 gelesen.
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "von B", "expected_version": 1,
})
if !ergebnis.IsError {
t.Fatal("der Konflikt wurde nicht erkannt — B hat A stillschweigend überschrieben")
}
// Die Fehlermeldung muss dem Modell sagen, was zu tun ist.
if !strings.Contains(text(ergebnis), "get") {
t.Errorf("die Fehlermeldung erklärt das weitere Vorgehen nicht: %s", text(ergebnis))
}
}
// ── Rollen ───────────────────────────────────────────────────────────────────
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang nichts verändern kann.
func TestReadRoleCannotWrite(t *testing.T) {
h, db, projekt := aufbau(t)
// Jedes Werkzeug bekommt nur die Parameter, die es kennt — sonst weist schon
// die Schemaprüfung des SDK den Aufruf ab und die Rollenprüfung käme nie dran.
faelle := map[string]map[string]any{
"remember": {"slug": "x", "title": "T", "content": "C"},
"forget": {"slug": "x"},
}
for werkzeug, args := range faelle {
t.Run(werkzeug, func(t *testing.T) {
ergebnis := ruf(t, h, projekt, auth.RoleRead, werkzeug, args)
if !ergebnis.IsError {
t.Error("der Nur-Lese-Zugang durfte schreiben")
}
if !strings.Contains(text(ergebnis), "Leserecht") {
t.Errorf("die Fehlermeldung nennt den Grund nicht: %s", text(ergebnis))
}
})
}
if n, _ := db.CountEntries(context.Background(), projekt.ID); n != 0 {
t.Errorf("%d Einträge angelegt, erwartet 0", n)
}
}
func TestReadRoleCanRead(t *testing.T) {
h, _, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "notiz", "title": "Notiz", "content": "Inhalt",
})
for _, werkzeug := range []string{"recall", "list"} {
args := map[string]any{}
if werkzeug == "recall" {
args["query"] = "Notiz"
}
if ergebnis := ruf(t, h, projekt, auth.RoleRead, werkzeug, args); ergebnis.IsError {
t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", werkzeug, text(ergebnis))
}
}
}
// ── Suchen und Lesen ─────────────────────────────────────────────────────────
// TestRecallFindsByPrefix prüft die Präfix-Rückfallebene: "deploy" muss
// "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis,
// und das Modell erfährt nie, dass es den Eintrag gäbe.
func TestRecallFindsByPrefix(t *testing.T) {
h, _, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess",
"content": "Wir deployen über GitHub Actions.",
})
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", map[string]any{"query": "deploy"})
if ergebnis.IsError {
t.Fatalf("recall schlug fehl: %s", text(ergebnis))
}
if !strings.Contains(text(ergebnis), "deployment-prozess") {
t.Errorf("die Präfix-Suche fand den Eintrag nicht: %s", text(ergebnis))
}
}
// TestRecallEmptyGivesHint prüft, dass ein leeres Ergebnis dem Modell sagt, was
// als Nächstes zu tun ist.
func TestRecallEmptyGivesHint(t *testing.T) {
h, _, projekt := aufbau(t)
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"})
if !strings.Contains(text(ergebnis), "remember") {
t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(ergebnis))
}
}
func TestRecallFiltersByTag(t *testing.T) {
h, _, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "a", "title": "Deployment A", "content": "deployment", "tags": []any{"ops"},
})
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"},
})
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall",
map[string]any{"query": "deployment", "tags": []any{"ops"}})
if strings.Contains(text(ergebnis), `"slug": "b"`) || strings.Contains(text(ergebnis), `"slug":"b"`) {
t.Errorf("die Schlagwortfilterung griff nicht: %s", text(ergebnis))
}
}
// TestGetReturnsBacklinks prüft die Verweise in beide Richtungen — sie ersetzen
// die frühere Ordnerhierarchie als Struktur zwischen Einträgen.
func TestGetReturnsBacklinks(t *testing.T) {
h, _, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "ziel", "title": "Ziel", "content": "Inhalt",
})
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "quelle", "title": "Quelle", "content": "siehe [[ziel]]",
})
ergebnis := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "ziel"})
if !strings.Contains(text(ergebnis), "quelle") {
t.Errorf("der Rückverweis fehlt: %s", text(ergebnis))
}
andersherum := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "quelle"})
if !strings.Contains(text(andersherum), "ziel") {
t.Error("der ausgehende Verweis fehlt")
}
}
func TestGetUnknownSlugExplains(t *testing.T) {
h, _, projekt := aufbau(t)
ergebnis := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"})
if !ergebnis.IsError {
t.Fatal("ein unbekannter Bezeichner wurde nicht als Fehler gemeldet")
}
// Die Meldung soll dem Modell den nächsten Schritt nennen.
if !strings.Contains(text(ergebnis), "recall") {
t.Errorf("die Meldung hilft nicht weiter: %s", text(ergebnis))
}
}
// ── Abschottung ──────────────────────────────────────────────────────────────
// TestProjectIsolation ist der wichtigste Test: Die Werkzeuge eines Projekts
// dürfen unter keinen Umständen an die Daten eines anderen kommen.
func TestProjectIsolation(t *testing.T) {
h, db, projektA := aufbau(t)
ctx := context.Background()
ws, _ := db.EnsureWorkspace(ctx, "Test")
projektB, err := db.CreateProject(ctx, ws.ID, "fremd", "Fremdes Projekt", "")
if err != nil {
t.Fatal(err)
}
// Wissen in Projekt B ablegen.
ruf(t, h, projektB, auth.RoleWrite, "remember", map[string]any{
"slug": "geheim", "title": "Geheim", "content": "Vertrauliches aus Projekt B",
})
// Mit den Werkzeugen von Projekt A danach suchen.
suche := ruf(t, h, projektA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"})
if strings.Contains(text(suche), "Vertrauliches") {
t.Error("recall in Projekt A fand Inhalte aus Projekt B")
}
lesen := ruf(t, h, projektA, auth.RoleRead, "get", map[string]any{"slug": "geheim"})
if !lesen.IsError {
t.Error("get in Projekt A lieferte einen Eintrag aus Projekt B")
}
liste := ruf(t, h, projektA, auth.RoleRead, "list", map[string]any{})
if strings.Contains(text(liste), "geheim") {
t.Error("list in Projekt A zeigte einen Eintrag aus Projekt B")
}
// Und der Gegentest: Löschen darf ebenfalls nicht durchschlagen.
ruf(t, h, projektA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"})
if n, _ := db.CountEntries(ctx, projektB.ID); n != 1 {
t.Error("forget in Projekt A hat einen Eintrag in Projekt B gelöscht")
}
}
// ── Grenzen ──────────────────────────────────────────────────────────────────
// TestRecallLimitIsCapped prüft, dass ein Client nicht beliebig viele Treffer
// anfordern kann — eine riesige Antwort sprengt das Kontextfenster des Modells.
func TestRecallLimitIsCapped(t *testing.T) {
h, _, projekt := aufbau(t)
for i := 0; i < 60; i++ {
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)),
"title": "Eintrag",
"content": "gemeinsames Stichwort",
})
}
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall",
map[string]any{"query": "gemeinsames", "limit": 9999})
// Mehr als recallMaxLimit Treffer dürfen nicht zurückkommen.
if anzahl := strings.Count(text(ergebnis), `"slug"`); anzahl > recallMaxLimit {
t.Errorf("%d Treffer zurückgegeben, Obergrenze ist %d", anzahl, recallMaxLimit)
}
}
func TestRememberRejectsEmptyContent(t *testing.T) {
h, _, projekt := aufbau(t)
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember",
map[string]any{"slug": "leer", "title": "T", "content": ""})
if !ergebnis.IsError {
t.Error("ein leerer Inhalt wurde angenommen")
}
}
// ── Schreibbudget ────────────────────────────────────────────────────────────
// TestWriteBudgetStopsRunawayAgent prüft den eigentlichen Zweck der Begrenzung:
// Ein Agent in einer Schleife wird gestoppt, statt bis zum Plattenende zu schreiben.
func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
const grenze = 3
h, db, projekt := aufbauMitBudget(t, grenze)
for i := 0; i < grenze; i++ {
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt",
})
if ergebnis.IsError {
t.Fatalf("Aufruf %d von %d abgelehnt, obwohl im Budget: %s", i+1, grenze, text(ergebnis))
}
}
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "eintrag-zuviel", "title": "Zu viel", "content": "Inhalt",
})
if !ergebnis.IsError {
t.Fatal("Aufruf über der Grenze wurde angenommen")
}
// Die Meldung geht an ein Sprachmodell. Sie muss sagen, was los ist UND was
// zu tun ist — sonst versucht es das Modell sofort wieder.
meldung := text(ergebnis)
for _, erwartet := range []string{"Schreibbudget", "NICHT sofort erneut"} {
if !strings.Contains(meldung, erwartet) {
t.Errorf("Meldung enthält %q nicht: %s", erwartet, meldung)
}
}
// Und der abgelehnte Aufruf darf nichts geschrieben haben.
if _, err := db.EntryBySlug(context.Background(), projekt.ID, "eintrag-zuviel"); err == nil {
t.Error("der abgelehnte Aufruf hat den Eintrag trotzdem angelegt")
}
}
// TestWriteBudgetLeavesReadingAlone stellt sicher, dass nur das Schreiben
// gedeckelt ist. Ein erschöpftes Budget darf das Gedächtnis nicht unlesbar machen —
// sonst wäre der Agent nicht gebremst, sondern blind.
func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
h, _, projekt := aufbauMitBudget(t, 1)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "vorhanden", "title": "Vorhanden", "content": "Inhalt",
})
// Budget ist jetzt aufgebraucht.
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "noch-einer", "title": "Noch einer", "content": "Inhalt",
}); !ergebnis.IsError {
t.Fatal("Vorbedingung verfehlt: das Budget ist noch nicht erschöpft")
}
for _, werkzeug := range []string{"recall", "get", "list"} {
args := map[string]any{}
switch werkzeug {
case "recall":
args["query"] = "Inhalt"
case "get":
args["slug"] = "vorhanden"
}
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, werkzeug, args); ergebnis.IsError {
t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", werkzeug, text(ergebnis))
}
}
}
// TestWriteBudgetIsPerProject prüft die Mandantentrennung: Ein Projekt, das sein
// Budget verbraucht, darf kein anderes mitbremsen.
func TestWriteBudgetIsPerProject(t *testing.T) {
h, db, projekt := aufbauMitBudget(t, 1)
ctx := context.Background()
zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "zweites", "Zweites Projekt", "")
if err != nil {
t.Fatal(err)
}
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "B", "content": "Inhalt",
}); !ergebnis.IsError {
t.Fatal("Vorbedingung verfehlt: das erste Projekt ist noch nicht am Limit")
}
if ergebnis := ruf(t, h, zweites, auth.RoleWrite, "remember", map[string]any{
"slug": "c", "title": "C", "content": "Inhalt",
}); ergebnis.IsError {
t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(ergebnis))
}
}
// TestWriteBudgetUsesProjectLimit prüft, dass die eigene Obergrenze eines
// Projekts den Standard schlägt — und zwar die aus der Sitzung, damit eine
// Änderung in der Verwaltung sofort wirkt.
func TestWriteBudgetUsesProjectLimit(t *testing.T) {
h, _, projekt := aufbauMitBudget(t, 100)
// Dasselbe Projekt, aber mit einer eigenen, engeren Grenze — so, wie es
// web.APIAuth nach einer Änderung in der Verwaltung aus der Datenbank läse.
projekt.WriteLimitHourly = 1
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "B", "content": "Inhalt",
}); !ergebnis.IsError {
t.Error("die eigene Obergrenze des Projekts wurde nicht angewandt")
}
}

View file

@ -0,0 +1,227 @@
// Package mcpserver stellt Munin über das Model Context Protocol bereit —
// die Hauptschnittstelle dieses Projekts.
//
// # Was ist MCP?
//
// MCP ist ein offener Standard dafür, wie KI-Werkzeuge (Claude, Cline, OpenCode
// und eigene Anwendungen) mit externen Systemen sprechen. Statt dass jemand
// zwischen Wiki und Chatfenster hin- und herkopiert, ruft das Werkzeug hier
// direkt Funktionen auf.
//
// # Transport
//
// Verwendet wird "Streamable HTTP": Jede Anfrage ist ein gewöhnlicher HTTP-POST.
// Der ältere SSE-Transport hielt eine dauerhafte Verbindung offen, die von
// Proxys nach einiger Zeit getrennt wurde — die Verbindung brach dann ab, bis
// jemand das Werkzeug neu startete.
//
// # Abschottung
//
// Je Projekt gibt es einen eigenen MCP-Server, dessen Funktionen fest auf dieses
// eine Projekt verdrahtet sind. Ein Key kann also strukturell gar nicht an die
// Daten eines anderen Projekts gelangen — nicht, weil irgendwo richtig gefiltert
// wird, sondern weil der Weg dorthin nicht existiert.
package mcpserver
import (
"net/http"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/search"
"munin/internal/store"
"munin/internal/web"
)
// instructions erklären dem KI-Werkzeug, wie es diesen Server benutzen soll.
//
// Dieser Text ist das wirksamste Stellrad für die Qualität der Ergebnisse: Er
// steht bei jedem Client im Kontext und entscheidet darüber, ob ein Agent erst
// sucht und dann schreibt — oder blind Dubletten anlegt.
const instructions = `Dies ist Munin, der Wissensspeicher dieses Projekts — das
gemeinsame Gedächtnis von Menschen und KI-Agenten.
Vorgehen beim LESEN:
1. "recall" mit einer inhaltlichen Frage aufrufen (nicht mit Stichworten).
2. Liefert die Trefferliste einen passenden Eintrag, ihn mit "get" vollständig laden.
3. "list" gibt einen Überblick, wenn nicht klar ist, wonach zu suchen ist.
Vorgehen beim SCHREIBEN:
1. IMMER zuerst "recall" aufrufen, um zu prüfen, ob es zum Thema schon etwas gibt.
2. Gibt es einen passenden Eintrag: mit DESSEN Bezeichner (slug) "remember"
aufrufen — das aktualisiert ihn, statt eine zweite, widersprüchliche
Fassung daneben zu stellen.
3. Nur wenn es wirklich nichts Passendes gibt, einen neuen Bezeichner wählen.
Zum Bezeichner (slug):
Kurz, sprechend, dauerhaft: "deployment-prozess", nicht "notiz-2026-01-15".
Er ist der Schlüssel gegen Dubletten. Erlaubt sind a-z, 0-9, "-" und "_".
Verweise:
Im Inhalt verweist [[bezeichner]] auf einen anderen Eintrag. Verwandtes so zu
verknüpfen ist ausdrücklich erwünscht — es hilft beim späteren Wiederfinden.
Gleichzeitiges Schreiben:
"get" liefert eine Feldnummer "version". Wird sie bei "remember" mitgegeben,
meldet der Server einen Konflikt, falls jemand anderes den Eintrag inzwischen
geändert hat, statt die fremde Änderung stillschweigend zu überschreiben.`
// Handler nimmt MCP-Anfragen entgegen und leitet sie an den Server des jeweiligen
// Projekts weiter.
type Handler struct {
store *store.Store
search *search.Registry
// budget deckelt die schreibenden Aufrufe je Projekt. Darf nil sein — dann
// findet keine Begrenzung statt (praktisch für Tests, die damit nichts zu
// tun haben).
budget *auth.WriteBudget
mu sync.Mutex
servers map[string]*mcp.Server // Schlüssel: Projekt-ID
streamable http.Handler
}
// New erstellt den MCP-Handler. Die Server je Projekt entstehen beim ersten
// Zugriff (siehe serverFor).
func New(s *store.Store, idx *search.Registry, budget *auth.WriteBudget) *Handler {
h := &Handler{
store: s,
search: idx,
budget: budget,
servers: make(map[string]*mcp.Server),
}
h.streamable = mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
project, ok := web.ProjectFrom(r)
if !ok {
return nil
}
return h.serverFor(project)
}, nil)
return h
}
// ServeHTTP beantwortet eine MCP-Anfrage.
//
// Die Authentifizierung ist zu diesem Zeitpunkt bereits erledigt: web.APIAuth hat
// den Key aufgelöst und Projekt samt Rolle im Context abgelegt.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if _, ok := web.ProjectFrom(r); !ok {
http.Error(w, "Kein Projekt aufgelöst", http.StatusUnauthorized)
return
}
h.streamable.ServeHTTP(w, r)
}
// serverFor gibt den MCP-Server eines Projekts zurück und baut ihn beim ersten
// Zugriff auf.
func (h *Handler) serverFor(project store.Project) *mcp.Server {
h.mu.Lock()
defer h.mu.Unlock()
if s, ok := h.servers[project.ID]; ok {
return s
}
s := h.buildServer(project)
h.servers[project.ID] = s
return s
}
// Forget wirft den Server eines Projekts weg — nach dessen Löschung.
func (h *Handler) Forget(projectID string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.servers, projectID)
}
// ── Ein- und Ausgaben der Werkzeuge ──────────────────────────────────────────
type recallInput struct {
Query string `json:"query" jsonschema:"Inhaltliche Frage oder Suchbegriff"`
Tags []string `json:"tags,omitempty" jsonschema:"Optional: nur Einträge mit diesen Schlagworten"`
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl der Treffer (Standard 10, Maximum 50)"`
}
type recallMatch struct {
Slug string `json:"slug" jsonschema:"Bezeichner des Eintrags — für get und remember"`
Title string `json:"title" jsonschema:"Titel"`
Snippet string `json:"snippet" jsonschema:"Textstelle um den Treffer"`
Tags []string `json:"tags" jsonschema:"Schlagworte"`
Source string `json:"source" jsonschema:"human oder agent"`
Curated bool `json:"curated" jsonschema:"true, wenn ein Mensch den Eintrag geprüft hat"`
Updated string `json:"updated_at" jsonschema:"Zeitpunkt der letzten Änderung"`
}
type recallOutput struct {
Results []recallMatch `json:"results" jsonschema:"Treffer, bester zuerst"`
Total int `json:"total" jsonschema:"Anzahl der Treffer"`
Hint string `json:"hint,omitempty" jsonschema:"Hinweis zum weiteren Vorgehen"`
}
type getInput struct {
Slug string `json:"slug" jsonschema:"Bezeichner des Eintrags"`
}
type getOutput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Content string `json:"content" jsonschema:"Vollständiger Inhalt als Markdown"`
Tags []string `json:"tags"`
Source string `json:"source"`
Author string `json:"author" jsonschema:"Name des Zugangs, der zuletzt geschrieben hat"`
Curated bool `json:"curated"`
Version int `json:"version" jsonschema:"Für remember mitgeben, um Konflikte zu erkennen"`
Updated string `json:"updated_at"`
LinksTo []string `json:"links_to" jsonschema:"Bezeichner, auf die dieser Eintrag verweist"`
LinkedBy []string `json:"linked_by" jsonschema:"Bezeichner, die auf diesen Eintrag verweisen"`
}
type rememberInput struct {
Slug string `json:"slug,omitempty" jsonschema:"Kurzer, dauerhafter Bezeichner (a-z, 0-9, - und _). Bei einem vorhandenen Eintrag wird dieser aktualisiert. Fehlt er, wird er aus dem Titel abgeleitet."`
Title string `json:"title" jsonschema:"Titel des Eintrags"`
Content string `json:"content" jsonschema:"Inhalt als Markdown. [[bezeichner]] verweist auf andere Einträge."`
Tags []string `json:"tags,omitempty" jsonschema:"Optional: Schlagworte zum Einordnen"`
ExpectedVersion int `json:"expected_version,omitempty" jsonschema:"Optional: die von get gelieferte Fassung — meldet einen Konflikt, wenn inzwischen jemand anderes geschrieben hat"`
}
type rememberOutput struct {
Slug string `json:"slug"`
Version int `json:"version" jsonschema:"Neue Fassung nach dem Schreiben"`
Created bool `json:"created" jsonschema:"true bei einem neuen Eintrag, false bei einer Aktualisierung"`
Message string `json:"message"`
}
type forgetInput struct {
Slug string `json:"slug" jsonschema:"Bezeichner des zu löschenden Eintrags"`
}
type forgetOutput struct {
Message string `json:"message"`
}
type listInput struct {
Tag string `json:"tag,omitempty" jsonschema:"Optional: nur Einträge mit diesem Schlagwort"`
Curated bool `json:"curated,omitempty" jsonschema:"Optional: nur von Menschen geprüfte Einträge"`
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl (Standard 50, Maximum 200)"`
}
type listItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Tags []string `json:"tags"`
Source string `json:"source"`
Curated bool `json:"curated"`
Updated string `json:"updated_at"`
}
type listOutput struct {
Entries []listItem `json:"entries"`
Total int `json:"total" jsonschema:"Gesamtzahl der Einträge im Projekt"`
Tags []string `json:"tags" jsonschema:"Alle im Projekt vergebenen Schlagworte"`
}

419
internal/mcpserver/tools.go Normal file
View file

@ -0,0 +1,419 @@
package mcpserver
import (
"context"
"errors"
"fmt"
"log"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/entry"
"munin/internal/store"
"munin/internal/web"
)
// Grenzen für die Werkzeuge.
//
// Die Obergrenzen schützen nicht den Server, sondern den Client: Eine Antwort mit
// 500 Einträgen sprengt das Kontextfenster des Modells und macht die Antwort
// schlechter, nicht besser.
const (
recallDefaultLimit = 10
recallMaxLimit = 50
listDefaultLimit = 50
listMaxLimit = 200
)
// buildServer erstellt den MCP-Server eines Projekts.
//
// Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht
// erst auf ein anderes zugreifen.
func (h *Handler) buildServer(project store.Project) *mcp.Server {
server := mcp.NewServer(
&mcp.Implementation{Name: "munin", Version: "2.0.0"},
&mcp.ServerOptions{Instructions: instructions},
)
h.addRecall(server, project)
h.addGet(server, project)
h.addList(server, project)
h.addRemember(server, project)
h.addForget(server, project)
return server
}
// requireWrite prüft, ob dieser Aufruf schreiben darf — und verbucht ihn im
// Schreibbudget des Projekts.
//
// Zwei Fragen, eine Stelle: Die Rolle sagt, ob der Zugang GRUNDSÄTZLICH schreiben
// darf, das Budget, ob das Projekt gerade noch DARF. Beide Prüfungen gehören
// zusammen, weil sie sonst an jedem schreibenden Werkzeug einzeln stehen müssten
// und irgendwann eine davon vergessen wird.
//
// Die Rolle steckt im Context, den web.APIAuth gefüllt hat. Ein Werkzeug bei
// fehlender Berechtigung ganz auszublenden wäre möglich, wäre aber verwirrender:
// Das Modell sähe je nach Key unterschiedliche Werkzeuge und könnte nicht
// erklären, warum etwas nicht geht.
func (h *Handler) requireWrite(ctx context.Context) error {
sess, ok := web.SessionFromContext(ctx)
if !ok {
return errors.New("kein gültiger Zugang")
}
if !sess.Role.CanWrite() {
return fmt.Errorf("dieser Zugang hat nur Leserecht (Rolle %q) — zum Schreiben wird %q oder %q benötigt",
sess.Role, auth.RoleWrite, auth.RoleAdmin)
}
if h.budget == nil {
return nil
}
// Die Obergrenze kommt aus dem Projekt der Sitzung, nicht aus einer eigenen
// Abfrage: web.APIAuth hat die Projektzeile für DIESEN Request gerade eben
// frisch aus der Datenbank geholt. Der Wert ist damit aktuell, und der
// Schreibpfad kostet keine zusätzliche Abfrage.
erlaubt, retryAfter, ueberschreitung := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly)
if erlaubt {
return nil
}
// Nur die erste Ablehnung je Fenster vermerken — sonst schriebe eine
// Schleife im Agenten das Protokoll voll, statt die Einträge.
if ueberschreitung == 1 {
log.Printf("Schreibbudget erschöpft: Projekt %q (%s), Zugang %q — weitere Schreibversuche werden bis zum Fensterende abgelehnt",
sess.Project.Slug, sess.Project.ID, sess.Key.Name)
}
// Die Meldung geht an ein Sprachmodell, nicht an einen Menschen. Deshalb
// steht darin ausdrücklich, was es TUN soll: warten, nicht neu versuchen und
// nicht auf ein anderes Werkzeug ausweichen.
return fmt.Errorf(
"Schreibbudget des Projekts erschöpft. In %d Minuten ist wieder Kontingent frei. "+
"NICHT sofort erneut versuchen und nicht auf andere Werkzeuge ausweichen — "+
"stattdessen die Aufgabe abbrechen und melden, dass das Schreiblimit erreicht ist",
int(retryAfter.Minutes())+1)
}
// actorName gibt den Namen des benutzten Zugangs zurück — für Herkunft und Protokoll.
func actorName(ctx context.Context) string {
if sess, ok := web.SessionFromContext(ctx); ok {
return sess.Key.Name
}
return "unbekannt"
}
// ── recall ───────────────────────────────────────────────────────────────────
func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "recall",
Description: "Durchsucht das Gedächtnis dieses Projekts. IMMER zuerst aufrufen — " +
"sowohl vor dem Beantworten einer Frage als auch vor dem Schreiben, um Dubletten zu vermeiden.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in recallInput) (*mcp.CallToolResult, recallOutput, error) {
if strings.TrimSpace(in.Query) == "" {
return nil, recallOutput{}, errors.New("query darf nicht leer sein")
}
limit := in.Limit
if limit <= 0 {
limit = recallDefaultLimit
}
if limit > recallMaxLimit {
limit = recallMaxLimit
}
idx, err := h.search.For(ctx, project.ID)
if err != nil {
return nil, recallOutput{}, err
}
// Etwas großzügiger suchen als am Ende ausgegeben wird: Die
// Schlagwortfilterung unten entfernt womöglich noch Treffer.
treffer := idx.Search(in.Query, limit*3)
gesucht := store.NormalizeTags(in.Tags)
ergebnisse := make([]recallMatch, 0, limit)
for _, t := range treffer {
if len(ergebnisse) >= limit {
break
}
e, err := h.store.EntryBySlug(ctx, project.ID, t.DocID)
if errors.Is(err, store.ErrNotFound) {
continue // Index kennt einen Eintrag, den es nicht mehr gibt
}
if err != nil {
return nil, recallOutput{}, err
}
if !hatAlleTags(e.Tags, gesucht) {
continue
}
ergebnisse = append(ergebnisse, recallMatch{
Slug: e.Slug,
Title: e.Title,
Snippet: entry.Snippet(e.Content, in.Query),
Tags: e.Tags,
Source: e.Source,
Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
})
}
out := recallOutput{Results: ergebnisse, Total: len(ergebnisse)}
if len(ergebnisse) == 0 {
out.Hint = "Nichts gefunden. Mit \"list\" einen Überblick verschaffen, " +
"oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen."
} else {
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
}
return nil, out, nil
},
)
}
// hatAlleTags prüft, ob alle gesuchten Schlagworte vorhanden sind.
func hatAlleTags(vorhanden, gesucht []string) bool {
if len(gesucht) == 0 {
return true
}
menge := make(map[string]bool, len(vorhanden))
for _, t := range vorhanden {
menge[t] = true
}
for _, g := range gesucht {
if !menge[g] {
return false
}
}
return true
}
// ── get ──────────────────────────────────────────────────────────────────────
func (h *Handler) addGet(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "get",
Description: "Lädt einen Eintrag vollständig, samt seiner Verweise. " +
"Die gelieferte \"version\" bei einem späteren \"remember\" mitgeben.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in getInput) (*mcp.CallToolResult, getOutput, error) {
e, err := h.store.EntryBySlug(ctx, project.ID, in.Slug)
if errors.Is(err, store.ErrNotFound) {
return nil, getOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug)
}
if err != nil {
return nil, getOutput{}, err
}
rueck, err := h.store.Backlinks(ctx, project.ID, in.Slug)
if err != nil {
return nil, getOutput{}, err
}
linkedBy := make([]string, 0, len(rueck))
for _, b := range rueck {
linkedBy = append(linkedBy, b.Slug)
}
return nil, getOutput{
Slug: e.Slug,
Title: e.Title,
Content: e.Content,
Tags: e.Tags,
Source: e.Source,
Author: e.Author,
Curated: e.Curated,
Version: e.Version,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
LinksTo: store.ExtractLinks(e.Content),
LinkedBy: linkedBy,
}, nil
},
)
}
// ── list ─────────────────────────────────────────────────────────────────────
func (h *Handler) addList(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "list",
Description: "Gibt einen Überblick über das Gedächtnis: zuletzt geänderte Einträge " +
"und alle vergebenen Schlagworte. Nützlich, wenn unklar ist, wonach zu suchen wäre.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, listOutput, error) {
limit := in.Limit
if limit <= 0 {
limit = listDefaultLimit
}
if limit > listMaxLimit {
limit = listMaxLimit
}
eintraege, err := h.store.ListEntries(ctx, project.ID, store.EntryFilter{
Tag: in.Tag,
Curated: in.Curated,
Limit: limit,
})
if err != nil {
return nil, listOutput{}, err
}
gesamt, err := h.store.CountEntries(ctx, project.ID)
if err != nil {
return nil, listOutput{}, err
}
tags, err := h.store.ListTags(ctx, project.ID)
if err != nil {
return nil, listOutput{}, err
}
items := make([]listItem, 0, len(eintraege))
for _, e := range eintraege {
items = append(items, listItem{
Slug: e.Slug,
Title: e.Title,
Tags: e.Tags,
Source: e.Source,
Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
})
}
tagNamen := make([]string, 0, len(tags))
for _, t := range tags {
tagNamen = append(tagNamen, t.Tag)
}
return nil, listOutput{Entries: items, Total: gesamt, Tags: tagNamen}, nil
},
)
}
// ── remember ─────────────────────────────────────────────────────────────────
func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "remember",
Description: "Legt einen Eintrag an oder aktualisiert ihn. Der slug entscheidet: " +
"Ein vorhandener Eintrag wird überschrieben, kein neuer danebengestellt. " +
"VOR dem Aufruf immer \"recall\" verwenden, um einen passenden vorhandenen Eintrag zu finden.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in rememberInput) (*mcp.CallToolResult, rememberOutput, error) {
if err := h.requireWrite(ctx); err != nil {
return nil, rememberOutput{}, err
}
if strings.TrimSpace(in.Content) == "" {
return nil, rememberOutput{}, errors.New("content darf nicht leer sein")
}
slug := strings.TrimSpace(in.Slug)
if slug == "" {
// Kein Bezeichner mitgegeben — aus dem Titel ableiten, statt den
// Aufruf abzulehnen. Das Modell soll nicht an Formalien scheitern.
slug = store.Slugify(in.Title)
}
if slug == "" {
return nil, rememberOutput{}, errors.New("slug fehlt und ließ sich nicht aus dem Titel ableiten")
}
titel := strings.TrimSpace(in.Title)
if titel == "" {
titel = entry.TitleFromContent(in.Content)
}
// Vorher nachsehen, ob es den Eintrag schon gibt — nur um in der
// Antwort "angelegt" von "aktualisiert" unterscheiden zu können. Für
// das Modell ist dieser Unterschied wichtig: Es soll merken, wenn es
// unabsichtlich etwas überschrieben hat.
_, err := h.store.EntryBySlug(ctx, project.ID, slug)
istNeu := errors.Is(err, store.ErrNotFound)
geschrieben, err := h.store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID,
Slug: slug,
Title: titel,
Content: in.Content,
Tags: in.Tags,
Source: store.SourceAgent,
Author: actorName(ctx),
ExpectedVersion: in.ExpectedVersion,
})
if errors.Is(err, store.ErrConflict) {
// Die Meldung des Stores nennt bereits die Fassungsnummern —
// hier kommt nur die Handlungsanweisung fürs Modell dazu.
return nil, rememberOutput{}, fmt.Errorf(
"%w. Mit \"get\" den aktuellen Stand laden, die eigenen Änderungen einarbeiten und erneut schreiben", err)
}
if err != nil {
return nil, rememberOutput{}, err
}
h.search.Update(project.ID, geschrieben)
sess, _ := web.SessionFromContext(ctx)
h.store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP,
})
meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version)
if istNeu {
meldung = fmt.Sprintf("Eintrag %q angelegt.", slug)
}
return nil, rememberOutput{
Slug: slug,
Version: geschrieben.Version,
Created: istNeu,
Message: meldung,
}, nil
},
)
}
// ── forget ───────────────────────────────────────────────────────────────────
func (h *Handler) addForget(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "forget",
Description: "Löscht einen Eintrag endgültig. Nur verwenden, wenn Wissen nachweislich " +
"falsch oder überholt ist — Veraltetes lässt sich meist besser mit \"remember\" richtigstellen.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in forgetInput) (*mcp.CallToolResult, forgetOutput, error) {
if err := h.requireWrite(ctx); err != nil {
return nil, forgetOutput{}, err
}
err := h.store.DeleteEntry(ctx, project.ID, in.Slug)
if errors.Is(err, store.ErrNotFound) {
return nil, forgetOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q", in.Slug)
}
if err != nil {
return nil, forgetOutput{}, err
}
h.search.Delete(project.ID, in.Slug)
sess, _ := web.SessionFromContext(ctx)
h.store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryDelete, EntrySlug: in.Slug, Source: store.SourceMCP,
})
return nil, forgetOutput{Message: fmt.Sprintf("Eintrag %q gelöscht.", in.Slug)}, nil
},
)
}

405
internal/search/index.go Normal file
View file

@ -0,0 +1,405 @@
// Package search implementiert einen In-Memory-Volltextindex mit BM25-Ranking.
//
// # Was ist BM25?
//
// BM25 (Best Match 25) ist der Standardalgorithmus moderner Suchmaschinen —
// er steckt hinter Elasticsearch, Solr, Lucene und den meisten anderen Systemen.
// Er verbessert das ältere TF-IDF durch zwei wichtige Korrekturen:
//
// 1. Sättigungsfaktor (k1): Ein Begriff der 10x so oft vorkommt wie ein anderer
// ist nicht 10x so relevant. Ab einem gewissen Punkt "sättigt" der Nutzen —
// das 20. Vorkommen eines Worts bringt kaum mehr Relevanz als das 10.
//
// 2. Längennormalisierung (b): Kurze Dokumente sollen keinen unfairen Vorteil haben.
// "Deployment" in einem 50-Wort-Artikel ist relevanter als dasselbe Wort
// in einem 2000-Wort-Dokument, in dem es nur am Rand auftaucht.
//
// # Wie funktioniert ein invertierter Index?
//
// Statt für jeden Suchbegriff alle Dokumente zu lesen, dreht ein invertierter Index
// die Beziehung um:
//
// Normaler Index: Dokument → enthaltene Begriffe
// Invertierter Index: Begriff → Dokumente die ihn enthalten
//
// Beispiel:
//
// "deployment" → ["server-setup" (3x), "pipeline" (1x)]
// "nginx" → ["server-setup" (5x), "reverse-proxy" (2x)]
//
// Bei einer Suche wird nur noch in dieser Map nachgeschlagen — kein Dateisystem-Zugriff,
// keine Schleife über alle Dokumente. Das macht die Suche sehr schnell.
//
// # Implementierungsdetails
//
// Der Index ist komplett im Arbeitsspeicher (kein Dateisystem).
// Beim Serverstart wird er aus der Datenbank aufgebaut.
// Titel-Treffer werden mit Faktor 3 höher gewichtet als Body-Treffer.
// Tags werden mit Faktor 2 gewichtet.
//
// Der Index kennt keine Einträge, sondern nur Dokument-IDs — dadurch bleibt er
// unabhängig vom Datenmodell und ließe sich ohne Änderung auch für etwas anderes
// verwenden. Die Zuordnung ID → Eintrag macht der Aufrufer.
package search
import (
"math"
"sort"
"strings"
"sync"
"unicode"
)
// BM25-Tuning-Parameter — Standardwerte aus der Originalliteratur (Robertson et al. 1994).
const (
// bm25K1 steuert die Sättigungskurve der Termhäufigkeit.
// Typischer Bereich: 1.2–2.0. Höher = längere Sättigungskurve.
bm25K1 = 1.2
// bm25B steuert die Längennormalisierung.
// 0.0 = keine Normalisierung, 1.0 = vollständige Normalisierung.
// 0.75 ist der Standardwert.
bm25B = 0.75
// titleBoost: Treffer im Titel gelten als 3x relevanter als im Body.
// Technisch umgesetzt durch Vervielfältigung der Titel-Tokens beim Indexieren.
titleBoost = 3
// tagsBoost: Treffer in Tags gelten als 2x relevanter als im Body.
tagsBoost = 2
)
// posting speichert wie oft ein Term in einem konkreten Dokument vorkommt.
// Es ist der Grundbaustein des invertierten Index.
type posting struct {
docID string // Kennung des Dokuments — hier der Slug eines Eintrags
freq int // Häufigkeit des Terms in diesem Dokument
}
// docMeta speichert die Metadaten die BM25 pro Dokument benötigt.
type docMeta struct {
length int // Anzahl der Terme (nach Tokenisierung und Boost)
title string // Titel für die Ergebnisanzeige
}
// Result ist ein einzelnes Suchergebnis mit BM25-Score.
type Result struct {
DocID string // Kennung des Dokuments — hier der Slug eines Eintrags
Score float64 // BM25-Relevanz-Score — nur für internes Ranking, kein fester Wertebereich
}
// Index kapselt den invertierten Index und alle dazugehörigen Daten.
// Er ist threadsicher durch eine RWMutex (mehrere Leser gleichzeitig erlaubt,
// aber nur ein Schreiber auf einmal).
type Index struct {
mu sync.RWMutex
// invertedIndex ist der Kern: Term → Liste aller Dokumente mit Häufigkeit.
// map[string][]posting: "nginx" → [{"server-setup", 5}, {"reverse-proxy", 2}]
invertedIndex map[string][]posting
// docMeta speichert Metadaten je Dokument — für BM25-Berechnungen nötig.
docs map[string]docMeta
// docTerms ist ein Rückwärts-Index: Dokument → Liste seiner Terme.
// Er macht das Aktualisieren/Löschen effizient:
// ohne ihn müsste man beim Löschen den gesamten invertedIndex durchsuchen.
docTerms map[string][]string
// totalTerms ist die Summe der Dokumentlängen — für die Durchschnittslänge.
totalTerms int
}
// NewIndex erstellt einen leeren, einsatzbereiten Index.
func NewIndex() *Index {
return &Index{
invertedIndex: make(map[string][]posting),
docs: make(map[string]docMeta),
docTerms: make(map[string][]string),
}
}
// Index fügt ein Dokument hinzu oder aktualisiert es.
// Falls docID bereits existiert, wird der alte Eintrag zuerst entfernt.
//
// title, body und tags werden getrennt übergeben um unterschiedliche
// Boost-Faktoren anwenden zu können.
func (s *Index) Index(docID, title, body string, tags []string) {
s.mu.Lock()
defer s.mu.Unlock()
// Zuerst alten Eintrag entfernen falls vorhanden (Update-Logik).
s.removeUnsafe(docID)
// Tokens aus allen Quellen mit Boost-Gewichtung sammeln.
// Boost durch Wiederholung: "nginx" im Titel → 3x "nginx" in der Tokenliste.
// Das erhöht die Termhäufigkeit (TF) künstlich für Titel-Treffer.
var allTokens []string
allTokens = append(allTokens, repeatTokens(tokenize(title), titleBoost)...)
allTokens = append(allTokens, tokenize(body)...)
allTokens = append(allTokens, repeatTokens(tokenize(strings.Join(tags, " ")), tagsBoost)...)
if len(allTokens) == 0 {
return
}
// Termhäufigkeit pro Token zählen: "nginx" vorkommt 5x → {nginx: 5}
termFreq := make(map[string]int, len(allTokens))
for _, t := range allTokens {
termFreq[t]++
}
// Invertierter Index aufbauen: für jeden Term einen Posting-Eintrag hinzufügen.
termList := make([]string, 0, len(termFreq))
for term, freq := range termFreq {
s.invertedIndex[term] = append(s.invertedIndex[term], posting{
docID: docID,
freq: freq,
})
termList = append(termList, term)
}
// Metadaten und Rückwärts-Index speichern.
s.docs[docID] = docMeta{length: len(allTokens), title: title}
s.docTerms[docID] = termList
s.totalTerms += len(allTokens)
}
// Remove entfernt ein Dokument vollständig aus dem Index.
func (s *Index) Remove(docID string) {
s.mu.Lock()
defer s.mu.Unlock()
s.removeUnsafe(docID)
}
// removeUnsafe entfernt ein Dokument ohne Lock — nur aus internen Methoden aufrufen
// die bereits einen Lock halten.
func (s *Index) removeUnsafe(docID string) {
meta, exists := s.docs[docID]
if !exists {
return
}
// Alle Postings für dieses Dokument aus dem invertierten Index entfernen.
// Wir nutzen docTerms um zu wissen welche Terme betroffen sind —
// viel schneller als den gesamten Index zu scannen.
for _, term := range s.docTerms[docID] {
postings := s.invertedIndex[term]
filtered := postings[:0] // wiederverwendet das bestehende Slice (kein Speicher-Allokierung)
for _, p := range postings {
if p.docID != docID {
filtered = append(filtered, p)
}
}
if len(filtered) == 0 {
delete(s.invertedIndex, term)
} else {
s.invertedIndex[term] = filtered
}
}
s.totalTerms -= meta.length
delete(s.docs, docID)
delete(s.docTerms, docID)
}
// Search führt eine BM25-Suche für den übergebenen Suchbegriff durch.
// Die Ergebnisse sind absteigend nach Relevanz sortiert (bester Treffer zuerst).
// limit begrenzt die Anzahl der zurückgegebenen Ergebnisse.
func (s *Index) Search(queryStr string, limit int) []Result {
s.mu.RLock()
defer s.mu.RUnlock()
queryTokens := tokenize(queryStr)
if len(queryTokens) == 0 || len(s.docs) == 0 {
return nil
}
// Gesamtanzahl Dokumente und Durchschnittslänge für BM25 berechnen.
N := float64(len(s.docs))
avgDocLen := float64(s.totalTerms) / N
// scores sammelt den BM25-Score je Dokument über alle Query-Terme.
// map[docID]score
scores := make(map[string]float64)
for _, term := range queryTokens {
postings, found := s.invertedIndex[term]
if !found {
// Rückfallebene: als Wortanfang suchen.
//
// Ohne das findet "deploy" den Eintrag "Deployment-Prozess" nicht,
// weil BM25 auf exakten Tokens arbeitet. Bei einem Menschen ist das
// ärgerlich, bei einer KI-Anfrage ein stiller Fehltreffer — sie
// bekommt schlicht kein Ergebnis und weiß nicht, dass es eines gäbe.
//
// Der Durchlauf über alle Terme ist linear und damit teurer als der
// Lookup. Er greift aber nur, wenn es keinen exakten Treffer gab,
// und der Wortschatz eines Projekts bleibt überschaubar.
postings = s.prefixPostings(term)
if len(postings) == 0 {
continue
}
}
// IDF (Inverse Document Frequency):
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1)
// nq = Anzahl der Dokumente die diesen Term enthalten
nq := float64(len(postings))
idf := math.Log((N-nq+0.5)/(nq+0.5) + 1)
for _, p := range postings {
meta := s.docs[p.docID]
docLen := float64(meta.length)
// BM25-Termgewicht:
// Zähler: tf * (k1 + 1) — gibt bei tf=0 den Wert 0
// Nenner: tf + k1 * (1 - b + b * |D| / avgdl) — beugt Übergewichtung vor
tf := float64(p.freq)
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
scores[p.docID] += idf * tfNorm
}
}
if len(scores) == 0 {
return nil
}
// Ergebnisse in eine sortierbare Liste umwandeln.
results := make([]Result, 0, len(scores))
for docID, score := range scores {
results = append(results, Result{DocID: docID, Score: score})
}
// Absteigend nach Score sortieren (bester Treffer zuerst).
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
// Auf limit begrenzen.
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results
}
// tokenize zerlegt einen Text in Terme für die Indizierung.
// Es werden nur Buchstaben und Ziffern behalten, alles andere gilt als Trennzeichen.
// Einzeichen-Tokens werden ignoriert (kaum aussagekräftig).
//
// Umlaute und ß werden dabei vereinheitlicht — siehe faltUmlaut.
func tokenize(text string) []string {
text = strings.ToLower(text)
var tokens []string
var current strings.Builder
for _, r := range text {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
current.WriteString(faltUmlaut(r))
} else {
if current.Len() > 1 {
tokens = append(tokens, current.String())
}
current.Reset()
}
}
// Letztes Token nicht vergessen
if current.Len() > 1 {
tokens = append(tokens, current.String())
}
return tokens
}
// faltUmlaut bildet Umlaute und ß auf ihre Grundform ab.
//
// # Wozu?
//
// Ohne das findet „prufung" den Eintrag „Prüfung" nicht — es sind für den Index
// zwei verschiedene Wörter. Genau so tippt aber, wer auf einer fremden Tastatur
// sitzt oder es eilig hat. Bei einem Menschen ist das ärgerlich, bei einer
// KI-Anfrage ein stiller Fehltreffer: Sie bekommt kein Ergebnis und erfährt
// nicht, dass es eines gäbe.
//
// Die Faltung greift, weil tokenize sowohl beim Indizieren als auch beim Suchen
// verwendet wird — beide Seiten landen also auf derselben Form.
//
// # Was sie abdeckt und was nicht
//
// prüfung → prufung ✓ und „prufung" findet es
// größe → grosse ✓ und „grosse" findet es
// prüfung → prufung ✗ „pruefung" findet es NICHT
//
// Die Umschreibung mit e (ue, oe, ae) bleibt außen vor. Sie ließe sich nur
// abdecken, indem man auch „ue" zu „u" zusammenzieht — dann würde aber aus
// „neue" ein „nu" und aus „Steuer" ein „Stur". Der Schaden wäre größer als der
// Nutzen. Die Punkte wegzulassen ist die weit häufigere Schreibweise.
//
// Alle anderen Zeichen bleiben unverändert; für lateinische Buchstaben und
// Ziffern kostet die Funktion nur einen Vergleich.
func faltUmlaut(r rune) string {
switch r {
case 'ä':
return "a"
case 'ö':
return "o"
case 'ü':
return "u"
case 'ß':
return "ss"
default:
return string(r)
}
}
// repeatTokens wiederholt eine Tokenliste n-mal — damit wird Boost durch
// erhöhte Termhäufigkeit (TF) simuliert, ohne die BM25-Formel zu verändern.
func repeatTokens(tokens []string, n int) []string {
if n <= 1 {
return tokens
}
result := make([]string, 0, len(tokens)*n)
for i := 0; i < n; i++ {
result = append(result, tokens...)
}
return result
}
// prefixPostings sammelt die Fundstellen aller Terme, die mit prefix beginnen.
//
// Terme unter drei Zeichen werden übersprungen: "de" würde sonst auf halbe
// Wörterbücher passen und das Ranking unbrauchbar machen.
func (s *Index) prefixPostings(prefix string) []posting {
if len(prefix) < 3 {
return nil
}
// Je Dokument die höchste gefundene Häufigkeit übernehmen. Würde man
// aufsummieren, bekämen Dokumente mit vielen ähnlichen Wörtern ("deploy",
// "deployment", "deployt") ein unverdient hohes Gewicht.
beste := make(map[string]int)
for term, postings := range s.invertedIndex {
if !strings.HasPrefix(term, prefix) {
continue
}
for _, p := range postings {
if p.freq > beste[p.docID] {
beste[p.docID] = p.freq
}
}
}
if len(beste) == 0 {
return nil
}
ergebnis := make([]posting, 0, len(beste))
for docID, freq := range beste {
ergebnis = append(ergebnis, posting{docID: docID, freq: freq})
}
return ergebnis
}

102
internal/search/registry.go Normal file
View file

@ -0,0 +1,102 @@
package search
import (
"context"
"fmt"
"sync"
"munin/internal/store"
)
// Registry hält je Projekt einen Suchindex.
//
// # Warum getrennte Indizes statt einem großen?
//
// Ein gemeinsamer Index mit Projektfilter wäre möglich, hätte aber zwei Nachteile:
// Die BM25-Werte (Dokumentanzahl, Durchschnittslänge, Seltenheit eines Begriffs)
// würden über Projektgrenzen hinweg berechnet — ein Begriff, der in Projekt A
// häufig ist, wäre dann auch in Projekt B abgewertet. Und ein Programmierfehler
// beim Filtern würde Inhalte quer über Projekte hinweg sichtbar machen.
//
// Getrennte Indizes machen die Abschottung strukturell statt zu einer Frage der
// richtigen WHERE-Klausel.
type Registry struct {
store *store.Store
mu sync.Mutex
indexes map[string]*Index // Schlüssel: Projekt-ID
}
// NewRegistry erstellt eine leere Registry. Die Indizes entstehen beim ersten
// Zugriff auf ein Projekt (siehe For).
func NewRegistry(s *store.Store) *Registry {
return &Registry{
store: s,
indexes: make(map[string]*Index),
}
}
// For gibt den Index eines Projekts zurück und baut ihn beim ersten Zugriff auf.
//
// Der verzögerte Aufbau hat einen praktischen Grund: Beim Serverstart alle
// Projekte einzulesen würde bei vielen Projekten den Start ausbremsen, obwohl
// die meisten davon in dieser Laufzeit vielleicht nie angefragt werden. So zahlt
// nur der erste Zugriff je Projekt.
func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) {
r.mu.Lock()
defer r.mu.Unlock()
if idx, ok := r.indexes[projectID]; ok {
return idx, nil
}
idx := NewIndex()
if err := r.fill(ctx, idx, projectID); err != nil {
return nil, err
}
r.indexes[projectID] = idx
return idx, nil
}
// fill liest alle Einträge eines Projekts und nimmt sie in den Index auf.
func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error {
eintraege, err := r.store.AllEntries(ctx, projectID)
if err != nil {
return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err)
}
for _, e := range eintraege {
idx.Index(e.Slug, e.Title, e.Content, e.Tags)
}
return nil
}
// Update nimmt einen geänderten Eintrag in den Index auf.
// Existiert für das Projekt noch kein Index, passiert nichts — er wird beim
// ersten Zugriff ohnehin vollständig aufgebaut.
func (r *Registry) Update(projectID string, e store.Entry) {
r.mu.Lock()
defer r.mu.Unlock()
if idx, ok := r.indexes[projectID]; ok {
idx.Index(e.Slug, e.Title, e.Content, e.Tags)
}
}
// Delete entfernt einen Eintrag aus dem Index.
func (r *Registry) Delete(projectID, slug string) {
r.mu.Lock()
defer r.mu.Unlock()
if idx, ok := r.indexes[projectID]; ok {
idx.Remove(slug)
}
}
// Forget wirft den Index eines Projekts weg — nach dem Löschen des Projekts.
func (r *Registry) Forget(projectID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.indexes, projectID)
}

View file

@ -0,0 +1,266 @@
package search
import (
"strings"
"testing"
)
// baueIndex legt einen Index mit ein paar Dokumenten an.
func baueIndex() *Index {
idx := NewIndex()
idx.Index("deployment-prozess", "Deployment-Prozess",
"Wir deployen über GitHub Actions auf den Produktivserver.", []string{"ops", "deployment"})
idx.Index("server-zugang", "Server-Zugang",
"Der Zugang läuft über den Bastion-Host per SSH.", []string{"ops", "sicherheit"})
idx.Index("onboarding", "Onboarding neuer Kolleginnen",
"Am ersten Tag: Rechner einrichten, Zugänge beantragen, Team kennenlernen.", []string{"personal"})
return idx
}
// ergebnisIDs gibt die Kennungen der Treffer in Reihenfolge zurück.
func ergebnisIDs(treffer []Result) []string {
ids := make([]string, len(treffer))
for i, t := range treffer {
ids[i] = t.DocID
}
return ids
}
func TestSearchFindsExactTerm(t *testing.T) {
treffer := baueIndex().Search("bastion", 10)
if len(treffer) != 1 || treffer[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v, erwartet nur server-zugang", ergebnisIDs(treffer))
}
}
// TestSearchRanksTitleHigher prüft die Gewichtung: Ein Wort im Titel wiegt
// schwerer als dasselbe Wort im Fließtext.
func TestSearchRanksTitleHigher(t *testing.T) {
idx := NewIndex()
idx.Index("im-titel", "Deployment", "Ein Text über etwas ganz anderes.", nil)
idx.Index("im-text", "Irgendwas", "Hier kommt das Wort Deployment einmal vor.", nil)
treffer := idx.Search("deployment", 10)
if len(treffer) != 2 {
t.Fatalf("%d Treffer, erwartet 2", len(treffer))
}
if treffer[0].DocID != "im-titel" {
t.Errorf("bester Treffer = %q, erwartet den Titel-Treffer", treffer[0].DocID)
}
}
// TestSearchFindsByPrefix prüft die Rückfallebene.
//
// Ohne sie liefert "deploy" kein Ergebnis, obwohl "Deployment-Prozess" existiert —
// bei einer KI-Anfrage ein stiller Fehltreffer: Das Modell erfährt schlicht
// nicht, dass es den Eintrag gäbe.
func TestSearchFindsByPrefix(t *testing.T) {
treffer := baueIndex().Search("deploy", 10)
if len(treffer) == 0 {
t.Fatal("die Präfix-Suche fand nichts")
}
if treffer[0].DocID != "deployment-prozess" {
t.Errorf("bester Treffer = %q, erwartet deployment-prozess", treffer[0].DocID)
}
}
// TestSearchPrefixNeedsThreeChars prüft, dass sehr kurze Bruchstücke nicht
// greifen — "de" würde sonst auf halbe Wörterbücher passen.
func TestSearchPrefixNeedsThreeChars(t *testing.T) {
if treffer := baueIndex().Search("de", 10); len(treffer) > 0 {
t.Errorf("zweibuchstabiges Präfix lieferte %d Treffer", len(treffer))
}
}
// TestSearchExactBeatsPrefix prüft, dass die Rückfallebene nur greift, wenn es
// keinen exakten Treffer gibt.
func TestSearchExactBeatsPrefix(t *testing.T) {
idx := NewIndex()
idx.Index("exakt", "Test", "Das Wort ssh kommt hier vor.", nil)
idx.Index("praefix", "Test", "Hier steht sshd, also nur ein Wortanfang.", nil)
treffer := idx.Search("ssh", 10)
if len(treffer) == 0 {
t.Fatal("keine Treffer")
}
if treffer[0].DocID != "exakt" {
t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID)
}
}
func TestSearchTagsAreIndexed(t *testing.T) {
treffer := baueIndex().Search("sicherheit", 10)
if len(treffer) != 1 || treffer[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", ergebnisIDs(treffer))
}
}
func TestSearchIsCaseInsensitive(t *testing.T) {
idx := baueIndex()
klein := idx.Search("bastion", 10)
gross := idx.Search("BASTION", 10)
if len(klein) != len(gross) || len(klein) == 0 {
t.Errorf("Groß-/Kleinschreibung macht einen Unterschied: %d vs. %d", len(klein), len(gross))
}
}
func TestSearchRespectsLimit(t *testing.T) {
idx := NewIndex()
for i := 0; i < 20; i++ {
idx.Index(strings.Repeat("x", i+1), "Titel", "gemeinsames Stichwort", nil)
}
if treffer := idx.Search("gemeinsames", 5); len(treffer) != 5 {
t.Errorf("%d Treffer, erwartet 5", len(treffer))
}
}
func TestSearchEmptyQuery(t *testing.T) {
if treffer := baueIndex().Search("", 10); len(treffer) != 0 {
t.Errorf("eine leere Anfrage lieferte %d Treffer", len(treffer))
}
}
// TestIndexUpdateReplaces prüft, dass ein erneutes Indizieren den alten Stand
// ersetzt und keine Reste zurücklässt.
func TestIndexUpdateReplaces(t *testing.T) {
idx := NewIndex()
idx.Index("eintrag", "Alt", "Das alte Stichwort lautet Apfelbaum.", nil)
idx.Index("eintrag", "Neu", "Das neue Stichwort lautet Birnbaum.", nil)
if treffer := idx.Search("apfelbaum", 10); len(treffer) != 0 {
t.Error("der alte Inhalt ist noch im Index")
}
if treffer := idx.Search("birnbaum", 10); len(treffer) != 1 {
t.Error("der neue Inhalt fehlt im Index")
}
}
func TestIndexRemove(t *testing.T) {
idx := baueIndex()
idx.Remove("server-zugang")
if treffer := idx.Search("bastion", 10); len(treffer) != 0 {
t.Error("der entfernte Eintrag ist noch auffindbar")
}
// Die übrigen Einträge müssen unberührt bleiben.
if treffer := idx.Search("onboarding", 10); len(treffer) == 0 {
t.Error("beim Entfernen wurde zu viel gelöscht")
}
}
func TestRemoveUnknownIsHarmless(t *testing.T) {
idx := baueIndex()
idx.Remove("gibtesnicht")
if treffer := idx.Search("bastion", 10); len(treffer) != 1 {
t.Error("das Entfernen eines unbekannten Eintrags hat den Index beschädigt")
}
}
// TestTokenizeIgnoresSingleChars prüft, dass Einzelzeichen nicht indiziert
// werden — sie tragen keine Bedeutung und blähen den Index auf.
func TestTokenizeIgnoresSingleChars(t *testing.T) {
got := tokenize("a bc def g")
for _, token := range got {
if len(token) < 2 {
t.Errorf("Einzelzeichen %q wurde indiziert", token)
}
}
if len(got) != 2 {
t.Errorf("tokenize = %v, erwartet [bc def]", got)
}
}
func TestTokenizeSplitsOnPunctuation(t *testing.T) {
got := tokenize("GitHub-Actions, SSH/SCP; done.")
erwartet := map[string]bool{"github": true, "actions": true, "ssh": true, "scp": true, "done": true}
for _, token := range got {
if !erwartet[token] {
t.Errorf("unerwartetes Token %q in %v", token, got)
}
}
if len(got) != len(erwartet) {
t.Errorf("tokenize = %v, erwartet %d Token", got, len(erwartet))
}
}
// ── Umlaute ──────────────────────────────────────────────────────────────────
// TestUmlautfaltung prüft, dass Umlaute und ß beim Suchen keine Rolle spielen.
//
// Ohne das findet „prufung" den Eintrag „Prüfung" nicht — für den Index sind es
// zwei verschiedene Wörter. Genau so tippt aber, wer auf einer fremden Tastatur
// sitzt.
func TestUmlautfaltung(t *testing.T) {
idx := NewIndex()
idx.Index("bericht", "Prüfung der Größe",
"Bei der Prüfung ergab sich eine Störung. Die Maßnahme läuft.", []string{"prüfung"})
// Jeweils: mit Umlaut geschrieben und ohne — beide müssen treffen.
paare := [][2]string{
{"prüfung", "prufung"},
{"größe", "grosse"},
{"störung", "storung"},
{"maßnahme", "massnahme"},
{"läuft", "lauft"},
}
for _, paar := range paare {
for _, anfrage := range paar {
if treffer := idx.Search(anfrage, 10); len(treffer) == 0 {
t.Errorf("Suche nach %q fand nichts", anfrage)
}
}
}
}
// TestUmlautfaltungImSchlagwort prüft, dass auch Schlagworte gefaltet werden.
func TestUmlautfaltungImSchlagwort(t *testing.T) {
idx := NewIndex()
idx.Index("eintrag", "Titel", "Inhalt", []string{"prüfung"})
if treffer := idx.Search("prufung", 10); len(treffer) == 0 {
t.Error("Schlagwort mit Umlaut ist ohne Umlaut nicht auffindbar")
}
}
// TestFaltungVerschontNormaleWoerter stellt sicher, dass die Faltung nicht mehr
// anfasst als nötig — „neue" darf nicht zu „nu" werden.
func TestFaltungVerschontNormaleWoerter(t *testing.T) {
cases := map[string]string{
"neue": "neue",
"steuer": "steuer",
"deployment": "deployment",
"grün": "grun",
"straße": "strasse",
"tür": "tur",
}
for eingabe, erwartet := range cases {
got := tokenize(eingabe)
if len(got) != 1 || got[0] != erwartet {
t.Errorf("tokenize(%q) = %v, erwartet [%s]", eingabe, got, erwartet)
}
}
}
// TestPraefixMitUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche.
func TestPraefixMitUmlaut(t *testing.T) {
idx := NewIndex()
idx.Index("eintrag", "Überwachung", "Die Überwachung meldet Störungen.", nil)
// "uberwach" ist Präfix von "uberwachung" — beides gefaltet.
if treffer := idx.Search("uberwach", 10); len(treffer) == 0 {
t.Error("Präfix ohne Umlaut fand den Eintrag nicht")
}
}

165
internal/store/audit.go Normal file
View file

@ -0,0 +1,165 @@
package store
import (
"context"
"fmt"
"log"
"strings"
)
// Aktionen, die im Protokoll auftauchen. Als Konstanten, damit Auswertungen
// nicht an Tippfehlern scheitern.
const (
ActionEntryWrite = "entry_write"
ActionEntryDelete = "entry_delete"
ActionLoginSuccess = "login_success"
ActionAuthFailure = "auth_failure"
ActionProjectCreated = "project_created"
ActionProjectDeleted = "project_deleted"
ActionProjectUpdated = "project_updated"
ActionKeyCreated = "key_created"
ActionKeyRevoked = "key_revoked"
)
// Herkunft eines protokollierten Vorgangs.
const (
SourceWeb = "web"
SourceMCP = "mcp"
SourceSystem = "system"
)
// LogAudit schreibt ein Ereignis ins Protokoll.
//
// Ein Fehler beim Protokollieren lässt den eigentlichen Vorgang nicht scheitern —
// er wird nur ins Serverlog geschrieben. Das Protokoll ist eine Hilfe zur
// Nachvollziehbarkeit, kein Teil des kritischen Pfads: Wer einen Eintrag
// erfolgreich gespeichert hat, soll keine Fehlermeldung sehen, nur weil die
// Protokollzeile nicht geschrieben werden konnte.
func (s *Store) LogAudit(ctx context.Context, e AuditEvent) {
if e.ID == "" {
e.ID = NewID()
}
_, err := s.schreiben.ExecContext(ctx,
`INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, NowString(), e.ProjectID, e.APIKeyID, e.Actor, e.Action, e.EntrySlug, e.Source, e.IP, e.Detail,
)
if err != nil {
log.Printf("Audit-Log: Ereignis %q konnte nicht geschrieben werden: %v", e.Action, err)
}
}
// AuditFilter schränkt ListAudit ein.
type AuditFilter struct {
ProjectID string // leer = alle Projekte
Limit int
}
const defaultAuditLimit = 200
// ListAudit gibt die jüngsten Protokolleinträge zurück, neueste zuerst.
func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, error) {
limit := f.Limit
if limit <= 0 {
limit = defaultAuditLimit
}
abfrage := `SELECT id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail
FROM audit_events`
werte := []any{}
if f.ProjectID != "" {
abfrage += ` WHERE project_id = ?`
werte = append(werte, f.ProjectID)
}
abfrage += ` ORDER BY ts DESC LIMIT ?`
werte = append(werte, limit)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
if err != nil {
return nil, fmt.Errorf("Audit-Log konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var ereignisse []AuditEvent
for rows.Next() {
var e AuditEvent
var ts string
if err := rows.Scan(&e.ID, &ts, &e.ProjectID, &e.APIKeyID, &e.Actor,
&e.Action, &e.EntrySlug, &e.Source, &e.IP, &e.Detail); err != nil {
return nil, err
}
e.Timestamp = parseTime(ts)
ereignisse = append(ereignisse, e)
}
return ereignisse, rows.Err()
}
// PruneAudit löscht Protokolleinträge, die älter als die angegebene Grenze sind.
//
// # Warum das nötig ist
//
// Das Protokoll wächst mit jedem Zugriff und wird nie kleiner. Bei Agenten, die
// im Minutentakt schreiben, sind das schnell Millionen Zeilen.
//
// Es gibt außerdem einen datenschutzrechtlichen Grund: Das Protokoll enthält
// IP-Adressen und Key-Namen, aus denen sich Personen ableiten lassen. Solche
// Daten dürfen nicht unbegrenzt aufbewahrt werden — eine Aufbewahrungsfrist ist
// keine Kür, sondern die Voraussetzung dafür, das Protokoll überhaupt führen zu dürfen.
//
// Übergeben wird die Grenze als Zeitstempel im Datenbankformat; alles davor fällt weg.
func (s *Store) PruneAudit(ctx context.Context, aelterAls string) (int64, error) {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM audit_events WHERE ts < ?`, aelterAls)
if err != nil {
return 0, fmt.Errorf("Audit-Log konnte nicht aufgeräumt werden: %w", err)
}
return res.RowsAffected()
}
// ProjectNames gibt eine Zuordnung von Projekt-ID zu Anzeigename zurück.
// Wird in der Audit-Ansicht gebraucht, damit dort nicht rohe IDs stehen.
func (s *Store) ProjectNames(ctx context.Context, workspaceID string) (map[string]string, error) {
projekte, err := s.ListProjects(ctx, workspaceID)
if err != nil {
return nil, err
}
namen := make(map[string]string, len(projekte))
for _, p := range projekte {
namen[p.ID] = p.Name
}
return namen, nil
}
// KeyNames gibt eine Zuordnung von Key-ID zu Key-Name zurück — analog zu
// ProjectNames, damit die Audit-Ansicht "tom-cline" statt einer ID zeigt.
func (s *Store) KeyNames(ctx context.Context, projectIDs []string) (map[string]string, error) {
if len(projectIDs) == 0 {
return map[string]string{}, nil
}
platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(projectIDs)), ",")
werte := make([]any, len(projectIDs))
for i, id := range projectIDs {
werte[i] = id
}
rows, err := s.lesen.QueryContext(ctx,
`SELECT id, name FROM api_keys WHERE project_id IN (`+platzhalter+`)`, werte...)
if err != nil {
return nil, fmt.Errorf("Key-Namen konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
namen := make(map[string]string)
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return nil, err
}
namen[id] = name
}
return namen, rows.Err()
}

502
internal/store/entries.go Normal file
View file

@ -0,0 +1,502 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
)
// EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag.
//
// Ein eigener Typ statt vieler Parameter: Die Anzahl der Felder wächst mit der
// Zeit, und ein benanntes Feld am Aufrufort ist beim Lesen sofort verständlich —
// anders als das fünfte Argument einer langen Parameterliste.
type EntryWrite struct {
ProjectID string
Slug string
Title string
Content string
Tags []string
Source string // SourceHuman oder SourceAgent
Author string // Name des verwendeten API-Keys
Curated bool
// ExpectedVersion aktiviert das optimistische Sperren: Ist der Wert größer
// als 0 und stimmt nicht mit dem gespeicherten Stand überein, schlägt der
// Schreibvorgang mit ErrConflict fehl.
//
// Damit können zwei Agenten, die denselben Eintrag bearbeiten, sich nicht
// mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass
// sich etwas geändert hat, und kann neu lesen.
ExpectedVersion int
}
// entryColumns steht in jeder Eintrags-Abfrage.
const entryColumns = `id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at`
// scanEntry liest eine Eintragszeile. Die Schlagworte kommen aus einer eigenen
// Tabelle und werden separat nachgeladen (siehe attachTags).
func scanEntry(scan func(...any) error) (Entry, error) {
var e Entry
var curated int
var createdAt, updatedAt string
if err := scan(&e.ID, &e.ProjectID, &e.Slug, &e.Title, &e.Content,
&e.Source, &e.Author, &curated, &e.Version, &createdAt, &updatedAt); err != nil {
return Entry{}, err
}
e.Curated = curated == 1
e.CreatedAt = parseTime(createdAt)
e.UpdatedAt = parseTime(updatedAt)
return e, nil
}
// NormalizeTags bringt Schlagworte auf eine einheitliche Form: klein, ohne
// Leerzeichen am Rand, ohne Dubletten, sortiert.
//
// Ohne das hätte man "Deployment", "deployment" und " deployment " als drei
// verschiedene Schlagworte in der Datenbank — und keine Filterung würde je
// vollständig treffen.
func NormalizeTags(tags []string) []string {
gesehen := make(map[string]bool, len(tags))
var sauber []string
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || gesehen[t] {
continue
}
gesehen[t] = true
sauber = append(sauber, t)
}
sort.Strings(sauber)
return sauber
}
// PutEntry legt einen Eintrag an oder aktualisiert ihn — der Slug entscheidet.
//
// Genau das macht den Slug zum Werkzeug gegen Dubletten: Ein Agent, der zum
// zweiten Mal etwas über dasselbe Thema ablegt, verwendet denselben Slug und
// aktualisiert damit, statt einen Widerspruch daneben zu stellen.
//
// Alles läuft in einer Transaktion: Eintrag, Schlagworte und Verweise sind
// entweder vollständig geschrieben oder gar nicht.
func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
if !ValidSlug(w.Slug) {
return Entry{}, fmt.Errorf("ungültiger Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", w.Slug)
}
if strings.TrimSpace(w.Title) == "" {
return Entry{}, errors.New("der Eintrag braucht einen Titel")
}
if w.Source != SourceHuman && w.Source != SourceAgent {
return Entry{}, fmt.Errorf("unbekannte Herkunft %q", w.Source)
}
tags := NormalizeTags(w.Tags)
links := ExtractLinks(w.Content)
jetzt := NowString()
var ergebnis Entry
err := s.tx(ctx, func(tx *sql.Tx) error {
// Bestehenden Eintrag suchen, um zwischen Anlegen und Ändern zu unterscheiden.
var id, createdAt string
var version int
err := tx.QueryRowContext(ctx,
`SELECT id, version, created_at FROM entries WHERE project_id = ? AND slug = ?`,
w.ProjectID, w.Slug,
).Scan(&id, &version, &createdAt)
switch {
case errors.Is(err, sql.ErrNoRows):
// Neuer Eintrag.
//
// Ein ExpectedVersion > 0 bedeutet: "ich habe Stand N gelesen und
// will ihn fortschreiben". Existiert der Eintrag nicht (mehr), ist
// das ein Konflikt und kein stillschweigendes Neuanlegen.
if w.ExpectedVersion > 0 {
return ErrConflict
}
id = NewID()
version = 1
createdAt = jetzt
if _, err := tx.ExecContext(ctx,
`INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, w.ProjectID, w.Slug, w.Title, w.Content, w.Source, w.Author,
boolToInt(w.Curated), version, createdAt, jetzt,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht angelegt werden: %w", err)
}
case err != nil:
return fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
default:
// Vorhandener Eintrag.
if w.ExpectedVersion > 0 && w.ExpectedVersion != version {
return fmt.Errorf("%w (gelesen: %d, aktuell: %d)", ErrConflict, w.ExpectedVersion, version)
}
version++
if _, err := tx.ExecContext(ctx,
`UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?
WHERE id = ?`,
w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, jetzt, id,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht geändert werden: %w", err)
}
}
// Schlagworte und Verweise werden nicht abgeglichen, sondern ersetzt.
// Das ist einfacher als ein Differenzabgleich und bei dieser Datenmenge
// genauso schnell — der Eintrag ist die Wahrheit, nicht die Historie.
if err := replaceTags(ctx, tx, w.ProjectID, id, tags); err != nil {
return err
}
if err := replaceLinks(ctx, tx, w.ProjectID, id, links); err != nil {
return err
}
ergebnis = Entry{
ID: id,
ProjectID: w.ProjectID,
Slug: w.Slug,
Title: w.Title,
Content: w.Content,
Tags: tags,
Source: w.Source,
Author: w.Author,
Curated: w.Curated,
Version: version,
CreatedAt: parseTime(createdAt),
UpdatedAt: parseTime(jetzt),
}
return nil
})
if err != nil {
return Entry{}, err
}
return ergebnis, nil
}
// replaceTags ersetzt die Schlagworte eines Eintrags.
//
// project_id wird mitgeschrieben, obwohl es schon am Eintrag steht — siehe die
// Begründung in migrations/002_projekt_in_nebentabellen.sql. Diese Funktion und
// replaceLinks sind die einzigen Schreibwege in diese Tabellen, und beide laufen
// in derselben Transaktion wie der Eintrag. Die Redundanz kann deshalb nicht
// auseinanderlaufen.
func replaceTags(ctx context.Context, tx *sql.Tx, projectID, entryID string, tags []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM entry_tags WHERE entry_id = ?`, entryID); err != nil {
return fmt.Errorf("Schlagworte konnten nicht ersetzt werden: %w", err)
}
for _, tag := range tags {
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_tags (entry_id, project_id, tag) VALUES (?, ?, ?)`, entryID, projectID, tag,
); err != nil {
return fmt.Errorf("Schlagwort %q konnte nicht gespeichert werden: %w", tag, err)
}
}
return nil
}
// replaceLinks ersetzt die ausgehenden Verweise eines Eintrags.
func replaceLinks(ctx context.Context, tx *sql.Tx, projectID, entryID string, links []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM entry_links WHERE from_entry_id = ?`, entryID); err != nil {
return fmt.Errorf("Verweise konnten nicht ersetzt werden: %w", err)
}
for _, ziel := range links {
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_links (from_entry_id, project_id, to_slug) VALUES (?, ?, ?)`,
entryID, projectID, ziel,
); err != nil {
return fmt.Errorf("Verweis auf %q konnte nicht gespeichert werden: %w", ziel, err)
}
}
return nil
}
// 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 = ?`,
projectID, slug,
)
e, err := scanEntry(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return Entry{}, ErrNotFound
}
if err != nil {
return Entry{}, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
}
if err := s.attachTags(ctx, []*Entry{&e}); err != nil {
return Entry{}, err
}
return e, nil
}
// EntryFilter schränkt ListEntries ein.
type EntryFilter struct {
Tag string // leer = alle
Limit int // 0 = Standardwert
Offset int
Curated bool // true = nur kuratierte Einträge
}
const defaultEntryLimit = 50
// ListEntries gibt Einträge eines Projekts zurück, zuletzt geänderte zuerst.
func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter) ([]Entry, error) {
limit := f.Limit
if limit <= 0 {
limit = defaultEntryLimit
}
// 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 = ?`
werte := []any{projectID}
if f.Tag != "" {
// project_id gehört mit in die Unterabfrage, damit der Index
// (project_id, tag) greift statt nur über das Schlagwort zu suchen.
abfrage += ` AND id IN (SELECT entry_id FROM entry_tags WHERE project_id = ? AND tag = ?)`
werte = append(werte, projectID, strings.ToLower(strings.TrimSpace(f.Tag)))
}
if f.Curated {
abfrage += ` AND curated = 1`
}
abfrage += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`
werte = append(werte, limit, f.Offset)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var eintraege []Entry
for rows.Next() {
e, err := scanEntry(rows.Scan)
if err != nil {
return nil, err
}
eintraege = append(eintraege, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return eintraege, s.attachTagsToSlice(ctx, eintraege)
}
// AllEntries gibt alle Einträge eines Projekts zurück — für den Aufbau des
// Suchindex beim Start und für den Export.
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`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var eintraege []Entry
for rows.Next() {
e, err := scanEntry(rows.Scan)
if err != nil {
return nil, err
}
eintraege = append(eintraege, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
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.
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,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err)
}
return n, nil
}
// ── Schlagworte ──────────────────────────────────────────────────────────────
// ListTags gibt alle Schlagworte eines Projekts mit ihrer Häufigkeit zurück,
// häufigste zuerst.
func (s *Store) ListTags(ctx context.Context, projectID string) ([]TagCount, error) {
// Eine einzige Tabelle statt einer Verknüpfung über entries: Der Aufwand
// 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.
rows, err := s.lesen.QueryContext(ctx,
`SELECT tag, COUNT(*) AS anzahl
FROM entry_tags
WHERE project_id = ?
GROUP BY tag
ORDER BY anzahl DESC, tag`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var tags []TagCount
for rows.Next() {
var tc TagCount
if err := rows.Scan(&tc.Tag, &tc.Count); err != nil {
return nil, err
}
tags = append(tags, tc)
}
return tags, rows.Err()
}
// TagCount ist ein Schlagwort mit der Anzahl seiner Einträge.
type TagCount struct {
Tag string
Count int
}
// attachTagsToSlice lädt die Schlagworte für eine Ergebnisliste nach.
func (s *Store) attachTagsToSlice(ctx context.Context, eintraege []Entry) error {
zeiger := make([]*Entry, len(eintraege))
for i := range eintraege {
zeiger[i] = &eintraege[i]
}
return s.attachTags(ctx, zeiger)
}
// attachTags lädt die Schlagworte mehrerer Einträge in EINER Abfrage nach.
//
// Die naheliegende Lösung wäre, pro Eintrag einmal nachzufragen — das ist das
// klassische "N+1-Problem": bei 50 Einträgen 51 Abfragen. Stattdessen wird eine
// Abfrage mit einer IN-Liste gestellt und das Ergebnis in Go zugeordnet.
func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error {
if len(eintraege) == 0 {
return nil
}
// Platzhalter "?, ?, ?" in der passenden Anzahl bauen.
platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(eintraege)), ",")
werte := make([]any, len(eintraege))
nachID := make(map[string]*Entry, len(eintraege))
for i, e := range eintraege {
werte[i] = e.ID
nachID[e.ID] = e
}
rows, err := s.lesen.QueryContext(ctx,
`SELECT entry_id, tag FROM entry_tags WHERE entry_id IN (`+platzhalter+`) ORDER BY tag`,
werte...,
)
if err != nil {
return fmt.Errorf("Schlagworte konnten nicht geladen werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var entryID, tag string
if err := rows.Scan(&entryID, &tag); err != nil {
return err
}
if e, ok := nachID[entryID]; ok {
e.Tags = append(e.Tags, tag)
}
}
return rows.Err()
}
// ── Verweise ─────────────────────────────────────────────────────────────────
// Backlink ist ein Eintrag, der auf einen anderen verweist.
type Backlink struct {
Slug string
Title string
}
// Backlinks gibt alle Einträge zurück, die auf den angegebenen Slug verweisen.
//
// Das ist die Gegenrichtung der [[…]]-Verweise und ersetzt die frühere
// Ordnerhierarchie: Statt "wo liegt diese Seite im Baum" beantwortet es die
// nützlichere Frage "was hat mit dieser Seite zu tun". Für ein KI-Werkzeug ist
// das zugleich der Weg zu verwandtem Kontext.
func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backlink, error) {
// Ohne ORDER BY in der Abfrage — und das ist kein Schönheitsfehler:
// Mit "ORDER BY e.title" kippt SQLite die Reihenfolge der Verknüpfung und
// läuft durch alle Einträge des Projekts, statt vom kleinen Verweis-Index
// auszugehen. Gemessen bei 5.000 Einträgen: 3,1 ms statt 9 µs.
//
// Sortiert wird deshalb unten in Go. Rückverweise sind erfahrungsgemäß eine
// Handvoll — dort kostet das Sortieren nichts.
rows, err := s.lesen.QueryContext(ctx,
`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 = ?`,
projectID, slug,
)
if err != nil {
return nil, fmt.Errorf("Rückverweise konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var links []Backlink
for rows.Next() {
var b Backlink
if err := rows.Scan(&b.Slug, &b.Title); err != nil {
return nil, err
}
links = append(links, b)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(links, func(i, j int) bool { return links[i].Title < links[j].Title })
return links, nil
}
// boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

179
internal/store/keys.go Normal file
View file

@ -0,0 +1,179 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// ResolvedKey ist das Ergebnis einer erfolgreichen Key-Auflösung: der Schlüssel
// selbst und das Projekt, zu dem er gehört.
//
// Beides zusammen, weil praktisch jeder Aufrufer beides braucht und ein zweiter
// Datenbankzugriff pro Request unnötig wäre.
type ResolvedKey struct {
Key APIKey
Project Project
}
// keyColumns steht in jeder Key-Abfrage.
const keyColumns = `id, project_id, name, hash, role, created_at, last_used_at, revoked_at`
// scanKey liest eine Key-Zeile.
func scanKey(scan func(...any) error) (APIKey, error) {
var k APIKey
var createdAt, lastUsedAt, revokedAt string
if err := scan(&k.ID, &k.ProjectID, &k.Name, &k.Hash, &k.Role,
&createdAt, &lastUsedAt, &revokedAt); err != nil {
return APIKey{}, err
}
k.CreatedAt = parseTime(createdAt)
k.LastUsedAt = parseTime(lastUsedAt)
k.RevokedAt = parseTime(revokedAt)
return k, nil
}
// CreateAPIKey legt einen Key an. Übergeben wird der Hash, nie der Klartext —
// den kennt nur der Aufrufer, und zwar genau einmal.
func (s *Store) CreateAPIKey(ctx context.Context, projectID, name, hash, role string) (APIKey, error) {
if strings.TrimSpace(name) == "" {
return APIKey{}, fmt.Errorf("der Key braucht einen Namen — daran erkennt man später, wer oder was ihn benutzt")
}
k := APIKey{
ID: NewID(),
ProjectID: projectID,
Name: strings.TrimSpace(name),
Hash: hash,
Role: role,
}
jetzt := NowString()
_, err := s.schreiben.ExecContext(ctx,
`INSERT INTO api_keys (id, project_id, name, hash, role, created_at, last_used_at, revoked_at)
VALUES (?, ?, ?, ?, ?, ?, '', '')`,
k.ID, k.ProjectID, k.Name, k.Hash, k.Role, jetzt,
)
if err != nil {
return APIKey{}, fmt.Errorf("Key konnte nicht angelegt werden: %w", err)
}
k.CreatedAt = parseTime(jetzt)
return k, nil
}
// ResolveKeyHash schlägt einen Key-Hash nach und gibt Key samt Projekt zurück.
//
// Das ist der heißeste Pfad im ganzen Programm — er läuft bei jedem API- und
// MCP-Request. Deshalb ein einziger indizierter Lookup über die UNIQUE-Spalte
// "hash" statt einer Schleife über alle Keys.
//
// Zurückgezogene Keys gelten als nicht vorhanden: der Aufrufer erfährt nicht, ob
// es den Key nie gab oder ob er gesperrt wurde.
func (s *Store) ResolveKeyHash(ctx context.Context, hash string) (ResolvedKey, error) {
row := s.lesen.QueryRowContext(ctx,
`SELECT k.id, k.project_id, k.name, k.hash, k.role, k.created_at, k.last_used_at, k.revoked_at,
p.id, p.workspace_id, p.slug, p.name, p.description, p.created_at
FROM api_keys k
JOIN projects p ON p.id = k.project_id
WHERE k.hash = ? AND k.revoked_at = ''`,
hash,
)
var k APIKey
var p Project
var kCreated, kLastUsed, kRevoked, pCreated string
err := row.Scan(
&k.ID, &k.ProjectID, &k.Name, &k.Hash, &k.Role, &kCreated, &kLastUsed, &kRevoked,
&p.ID, &p.WorkspaceID, &p.Slug, &p.Name, &p.Description, &pCreated,
)
if errors.Is(err, sql.ErrNoRows) {
return ResolvedKey{}, ErrNotFound
}
if err != nil {
return ResolvedKey{}, fmt.Errorf("Key konnte nicht aufgelöst werden: %w", err)
}
k.CreatedAt = parseTime(kCreated)
k.LastUsedAt = parseTime(kLastUsed)
k.RevokedAt = parseTime(kRevoked)
p.CreatedAt = parseTime(pCreated)
return ResolvedKey{Key: k, Project: p}, nil
}
// APIKeyByID liest einen Key anhand seiner ID — auch einen zurückgezogenen,
// damit die Verwaltungsoberfläche ihn noch anzeigen kann.
func (s *Store) APIKeyByID(ctx context.Context, id string) (APIKey, error) {
row := s.lesen.QueryRowContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE id = ?`, id)
k, err := scanKey(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return APIKey{}, ErrNotFound
}
if err != nil {
return APIKey{}, fmt.Errorf("Key konnte nicht gelesen werden: %w", err)
}
return k, nil
}
// ListAPIKeys gibt alle Keys eines Projekts zurück, neueste zuerst.
// Zurückgezogene sind dabei — sie bleiben als Beleg im Audit-Log referenziert.
func (s *Store) ListAPIKeys(ctx context.Context, projectID string) ([]APIKey, error) {
rows, err := s.lesen.QueryContext(ctx,
`SELECT `+keyColumns+` FROM api_keys WHERE project_id = ? ORDER BY created_at DESC`,
projectID,
)
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()
}
// RevokeAPIKey zieht einen Key zurück.
//
// Der Datensatz bleibt bestehen und wird nur markiert — sonst verlöre das
// Audit-Log die Zuordnung zu allem, was dieser Key jemals geschrieben hat.
func (s *Store) RevokeAPIKey(ctx context.Context, id string) error {
res, err := s.schreiben.ExecContext(ctx,
`UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at = ''`,
NowString(), id,
)
if err != nil {
return fmt.Errorf("Key konnte nicht zurückgezogen werden: %w", err)
}
return requireOneRow(res)
}
// TouchAPIKey vermerkt, dass ein Key gerade benutzt wurde.
//
// Damit sieht man in der Verwaltung, welche Keys noch aktiv sind und welche man
// gefahrlos zurückziehen kann. Ein Fehler hierbei ist unerheblich — der Aufrufer
// ignoriert ihn bewusst, denn der eigentliche Request soll deswegen nicht scheitern.
//
// Geschrieben wird höchstens einmal pro Stunde je Key: bei einem Agenten, der im
// Sekundentakt anfragt, wäre ein Schreibvorgang pro Request pure Verschwendung —
// und SQLite verträgt ohnehin nur einen Schreiber gleichzeitig.
func (s *Store) TouchAPIKey(ctx context.Context, id string, lastUsed time.Time) error {
if time.Since(lastUsed) < time.Hour {
return nil
}
_, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, NowString(), id)
return err
}

55
internal/store/links.go Normal file
View file

@ -0,0 +1,55 @@
package store
import (
"regexp"
"sort"
"strings"
)
// linkPattern findet Verweise der Form [[slug]] im Inhalt eines Eintrags.
//
// Die Schreibweise stammt aus Wiki-Systemen und ist Menschen wie KI-Werkzeugen
// vertraut. Sie hat einen praktischen Vorzug gegenüber Markdown-Links: Sie
// enthält nur den Bezeichner, keine URL — die Verweise bleiben also gültig,
// wenn sich Adressen ändern, und sie sind ohne Kontextwissen erkennbar.
//
// Erlaubt ist derselbe Zeichenvorrat wie bei Slugs (siehe ValidSlug). Damit wird
// aus "[[siehe hier]]" kein Verweis — das ist gewollt, sonst würde jede eckige
// Klammer im Text zu einem Falschtreffer.
var linkPattern = regexp.MustCompile(`\[\[([a-z0-9][a-z0-9_-]{0,63})\]\]`)
// ExtractLinks liest alle [[slug]]-Verweise aus einem Text.
// Das Ergebnis ist sortiert und ohne Dubletten — ein zweiter Verweis auf dasselbe
// Ziel ist keine zusätzliche Information.
func ExtractLinks(content string) []string {
treffer := linkPattern.FindAllStringSubmatch(content, -1)
if len(treffer) == 0 {
return nil
}
gesehen := make(map[string]bool, len(treffer))
var ziele []string
for _, t := range treffer {
ziel := t[1]
if gesehen[ziel] {
continue
}
gesehen[ziel] = true
ziele = append(ziele, ziel)
}
sort.Strings(ziele)
return ziele
}
// ReplaceLinks ersetzt jeden [[slug]]-Verweis durch das Ergebnis von fn.
//
// Wird beim Rendern verwendet, um aus den Verweisen echte Links zu machen. Die
// Ersetzungsfunktion bekommt den Slug und liefert den Ersatztext.
func ReplaceLinks(content string, fn func(slug string) string) string {
return linkPattern.ReplaceAllStringFunc(content, func(treffer string) string {
slug := strings.TrimSuffix(strings.TrimPrefix(treffer, "[["), "]]")
return fn(slug)
})
}

View file

@ -0,0 +1,126 @@
-- Erste Fassung des Schemas.
--
-- Konventionen, die für alle Migrationen gelten:
--
-- * IDs sind TEXT und werden in Go erzeugt (siehe ids.go), nicht von der
-- Datenbank. Damit gibt es keine Sequenzen, die bei einem Umzug nach
-- PostgreSQL nachgezogen werden müssten.
-- * Zeitstempel sind TEXT im Format RFC3339 in UTC. SQLite kennt keinen
-- echten Datumstyp, und Textvergleiche sortieren bei diesem Format korrekt.
-- * Wahrheitswerte sind INTEGER 0/1 — funktioniert in SQLite wie in PostgreSQL.
-- * 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
);
-- 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,
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE (workspace_id, slug)
);
-- API-Keys gehören zu genau einem Projekt.
--
-- Gespeichert wird nur der SHA-256-Hash (siehe auth.HashKey) — der Klartext wird
-- genau einmal beim Anlegen angezeigt und danach nie wieder. Der Hash ist UNIQUE
-- und damit indiziert: die Auflösung eines Keys ist ein Lookup, keine Schleife.
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT NOT NULL DEFAULT '',
revoked_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_api_keys_project ON api_keys (project_id, created_at);
-- Ein Eintrag ist die einzige Wissenseinheit — egal ob ein Mensch ihn über die
-- Weboberfläche geschrieben hat oder ein Agent über MCP. Der Unterschied steckt
-- in "source" und "curated", nicht in einer zweiten Tabelle.
--
-- "slug" ist der stabile Bezeichner innerhalb eines Projekts. Er ist zugleich der
-- Schlüssel für die Dublettenvermeidung: schreibt ein Agent zweimal unter
-- demselben Slug, wird aktualisiert statt ein zweiter Eintrag angelegt.
--
-- "version" zählt bei jeder Änderung hoch und dient dem optimistischen Sperren:
-- ein Schreiber kann angeben, welchen Stand er gelesen hat, und bekommt einen
-- Konflikt statt eines stillen Überschreibens.
CREATE TABLE entries (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL,
author TEXT NOT NULL DEFAULT '',
curated INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (project_id, slug)
);
-- Für die Übersichtsliste "zuletzt geändert zuerst".
CREATE INDEX idx_entries_project_updated ON entries (project_id, updated_at);
-- Schlagworte in einer eigenen Tabelle statt als kommagetrennte Spalte:
-- so lässt sich ohne LIKE-Gebastel nach ihnen filtern, und es gibt keine
-- doppelte Wahrheit über denselben Wert.
CREATE TABLE entry_tags (
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (entry_id, tag)
);
CREATE INDEX idx_entry_tags_tag ON entry_tags (tag);
-- Verweise zwischen Einträgen, geparst aus [[slug]] im Inhalt.
--
-- Das ersetzt die frühere Ordnerhierarchie: Struktur entsteht durch Verlinkung,
-- nicht durch einen Baum. Gespeichert wird der Ziel-Slug als Text, nicht als
-- Fremdschlüssel — ein Verweis darf auch auf einen noch nicht existierenden
-- Eintrag zeigen (und bleibt gültig, wenn dieser später angelegt wird).
CREATE TABLE entry_links (
from_entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
to_slug TEXT NOT NULL,
PRIMARY KEY (from_entry_id, to_slug)
);
CREATE INDEX idx_entry_links_to ON entry_links (to_slug);
-- Nachvollziehbarkeitsprotokoll.
--
-- Bewusst mit api_key_id statt nur einem Namen: kommt später eine
-- Nutzerverwaltung dazu, lässt sich eine user_id danebenstellen und die
-- Historie bleibt auswertbar.
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
ts TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
api_key_id TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL,
action TEXT NOT NULL,
entry_slug TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_audit_ts ON audit_events (ts);
CREATE INDEX idx_audit_project ON audit_events (project_id, ts);

View file

@ -0,0 +1,47 @@
-- Die Projektzugehörigkeit auch in den Nebentabellen führen.
--
-- ── Warum? ───────────────────────────────────────────────────────────────────
--
-- Bisher stand project_id nur an "entries". Wer die Schlagworte eines Projekts
-- zählen wollte, musste deshalb über entries verbinden:
--
-- SELECT t.tag, COUNT(*) FROM entry_tags t
-- JOIN entries e ON e.id = t.entry_id
-- WHERE e.project_id = ? GROUP BY t.tag
--
-- SQLite läuft dafür durch ALLE Einträge des Projekts und schlägt zu jedem die
-- Schlagworte nach — der Aufwand wächst also mit der Zahl der Einträge, obwohl
-- am Ende nur ein paar Dutzend Schlagworte herauskommen. Gemessen bei 5.000
-- Einträgen: 6,7 ms. Seit die Schlagworte in der Seitenleiste stehen, läuft
-- diese Abfrage bei JEDEM Seitenaufruf.
--
-- Dasselbe bei den Rückverweisen: Dort kippte zusätzlich die Reihenfolge der
-- Verknüpfung, sobald ein ORDER BY dazukam — statt vom kleinen Verweis-Index
-- auszugehen, lief SQLite wieder durch alle Einträge (3,1 ms statt 9 µs).
--
-- ── Der Handel ───────────────────────────────────────────────────────────────
--
-- Eine Spalte doppelt zu führen ist eine Redundanz, und Redundanz kann
-- auseinanderlaufen. Hier ist das vertretbar: Beide Tabellen werden
-- ausschließlich in replaceTags/replaceLinks geschrieben, innerhalb derselben
-- Transaktion wie der Eintrag selbst. Es gibt keinen zweiten Schreibweg.
-- ALTER TABLE ... ADD COLUMN können SQLite und PostgreSQL gleichermaßen.
-- NOT NULL braucht dabei einen Vorgabewert, sonst wüssten bestehende Zeilen
-- nicht, was hineingehört.
ALTER TABLE entry_tags ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
UPDATE entry_tags SET project_id = (SELECT e.project_id FROM entries e WHERE e.id = entry_tags.entry_id);
ALTER TABLE entry_links ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
UPDATE entry_links SET project_id = (SELECT e.project_id FROM entries e WHERE e.id = entry_links.from_entry_id);
-- Die alten Indizes werden nicht mehr gebraucht: Jede Abfrage kennt jetzt das
-- Projekt und kann den zusammengesetzten Index nutzen.
DROP INDEX idx_entry_tags_tag;
DROP INDEX idx_entry_links_to;
-- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index
-- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort
-- in einem Projekt" ab.
CREATE INDEX idx_entry_tags_projekt ON entry_tags (project_id, tag);
CREATE INDEX idx_entry_links_projekt ON entry_links (project_id, to_slug);

View file

@ -0,0 +1,26 @@
-- Ein eigenes Schreibbudget je Projekt.
--
-- ── Warum? ───────────────────────────────────────────────────────────────────
--
-- Ein gültiger API-Key umgeht die IP-Sperre bewusst (siehe APIAuth in
-- internal/web/middleware.go). Damit hat ein Agent, der in einer Schleife hängt,
-- überhaupt keine Grenze mehr — er schreibt, bis die Platte voll ist.
--
-- Die Obergrenze hängt am Projekt und nicht am Key: Ein Limit je Key ließe sich
-- umgehen, indem man einen zweiten Key anlegt, und das darf jeder Projekt-Admin
-- selbst.
--
-- ── Warum die Vorgabe 0 und nicht die eigentliche Zahl? ──────────────────────
--
-- 0 heißt hier nicht "darf nicht schreiben", sondern "keine eigene Angabe —
-- es gilt der Standard aus WRITE_LIMIT_HOURLY". Damit muss diese Migration
-- keine Daten anfassen: Alle bestehenden Projekte bekommen automatisch den
-- Standardwert, und der lässt sich später in der Umgebung ändern, ohne jede
-- Zeile nachzuziehen.
--
-- Die eigentliche Zahl steht bewusst NICHT als Vorgabewert im Schema. Sonst
-- gäbe es zwei Orte, an denen ein Standard festgelegt ist, und irgendwann
-- widersprechen sie sich.
-- ALTER TABLE ... ADD COLUMN können SQLite und PostgreSQL gleichermaßen.
ALTER TABLE projects ADD COLUMN write_limit_hourly INTEGER NOT NULL DEFAULT 0;

186
internal/store/models.go Normal file
View file

@ -0,0 +1,186 @@
package store
import (
"crypto/rand"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
)
// ErrNotFound meldet, dass ein gesuchter Datensatz nicht existiert.
//
// Ein eigener Fehlerwert statt sql.ErrNoRows: die aufrufenden Schichten sollen
// nicht wissen müssen, dass dahinter überhaupt SQL steckt. Geprüft wird mit
// errors.Is(err, store.ErrNotFound).
var ErrNotFound = errors.New("nicht gefunden")
// ErrConflict meldet einen Schreibkonflikt beim optimistischen Sperren:
// der Eintrag wurde verändert, seit der Schreiber ihn gelesen hat.
var ErrConflict = errors.New("Eintrag wurde zwischenzeitlich geändert")
// ErrDuplicate meldet, dass ein eindeutiger Wert schon vergeben ist
// (Projekt-Slug, Eintrags-Slug).
var ErrDuplicate = errors.New("existiert bereits")
// ── Datentypen ───────────────────────────────────────────────────────────────
// Workspace ist die oberste Klammer. Vorerst gibt es genau einen.
type Workspace struct {
ID string
Name string
CreatedAt time.Time
}
// Project ist die Einheit der Abschottung: eigene Einträge, eigene Keys.
type Project struct {
ID string
WorkspaceID string
Slug string
Name string
Description string
CreatedAt time.Time
// WriteLimitHourly ist die eigene Obergrenze für schreibende MCP-Aufrufe je
// Stunde. 0 bedeutet nicht "gesperrt", sondern "keine eigene Angabe" — dann
// gilt der Standard aus WRITE_LIMIT_HOURLY (siehe auth.WriteBudget).
WriteLimitHourly int
}
// APIKey ist ein Zugangsschlüssel für genau ein Projekt.
// Das Feld für den Klartext-Key fehlt bewusst — gespeichert wird nur der Hash.
type APIKey struct {
ID string
ProjectID string
Name string
Hash string
Role string
CreatedAt time.Time
LastUsedAt time.Time // Nullzeitpunkt = noch nie benutzt
RevokedAt time.Time // Nullzeitpunkt = aktiv
}
// Revoked sagt, ob der Key zurückgezogen wurde.
func (k APIKey) Revoked() bool {
return !k.RevokedAt.IsZero()
}
// Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben.
type Entry struct {
ID string
ProjectID string
Slug string
Title string
Content string
Tags []string
Source string // SourceHuman oder SourceAgent
Author string // Name des verwendeten API-Keys
Curated bool // vom Menschen bestätigtes Wissen statt Beobachtung eines Agenten
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
// Herkunft eines Eintrags.
const (
SourceHuman = "human"
SourceAgent = "agent"
)
// AuditEvent ist ein Eintrag im Nachvollziehbarkeitsprotokoll.
type AuditEvent struct {
ID string
Timestamp time.Time
ProjectID string
APIKeyID string
Actor string // Key-Name, "platform-admin" oder "unbekannt"
Action string // z.B. "entry_write", "key_created", "auth_failure"
EntrySlug string
Source string // "web", "mcp" oder "system"
IP string
Detail string
}
// ── Hilfsfunktionen für IDs und Zeit ─────────────────────────────────────────
// NewID erzeugt einen neuen Primärschlüssel.
//
// # Warum nicht die Datenbank zählen lassen?
//
// Ein AUTOINCREMENT wäre bequem, bringt aber drei Nachteile: Beim Umzug auf eine
// andere Datenbank müssen die Sequenzen nachgezogen werden, die IDs verraten die
// Anzahl der Datensätze, und man kennt die ID erst nach dem INSERT — was das
// Anlegen zusammenhängender Datensätze umständlich macht.
//
// # Aufbau
//
// Zeitanteil in Basis 36 + 8 zufällige Hex-Zeichen, z.B. "m1x2p3q4-a3f9c1d2".
// Der Zeitanteil steht vorne, damit die IDs in ihrer natürlichen Sortierung
// zugleich chronologisch sind — praktisch fürs Audit-Log und für Indizes, weil
// neue Werte immer hinten angehängt werden statt den Baum zu zerstreuen.
func NewID() string {
zeit := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36)
zufall := make([]byte, 4)
if _, err := rand.Read(zufall); err != nil {
// crypto/rand schlägt praktisch nie fehl. Falls doch, reicht der
// Zeitanteil zur Not aus — er ist auf die Millisekunde genau.
return zeit
}
return zeit + "-" + hex.EncodeToString(zufall)
}
// timeFormat ist das Format aller Zeitstempel in der Datenbank.
//
// RFC3339 in UTC, weil es sich als Text korrekt sortieren lässt: "2026-01-02T…"
// kommt alphabetisch vor "2026-01-03T…". Damit funktionieren ORDER BY und
// Bereichsabfragen ohne Datumsfunktionen der Datenbank — und die bleiben damit
// austauschbar.
const timeFormat = time.RFC3339Nano
// NowString gibt den aktuellen Zeitpunkt im Datenbankformat zurück.
func NowString() string {
return time.Now().UTC().Format(timeFormat)
}
// formatTime wandelt einen Zeitpunkt ins Datenbankformat.
// Der Nullzeitpunkt wird zum leeren String — so kommt die Anwendung ohne
// NULL-Behandlung aus, was den Go-Code deutlich schlanker hält.
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(timeFormat)
}
// parseTime wandelt einen Datenbankwert zurück in einen Zeitpunkt.
// Unlesbare oder leere Werte ergeben den Nullzeitpunkt statt eines Fehlers —
// ein kaputter Zeitstempel soll keine ganze Seite scheitern lassen.
func parseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(timeFormat, s)
if err != nil {
return time.Time{}
}
return t
}
// isUniqueViolation erkennt, ob ein Fehler von einer verletzten
// Eindeutigkeitsbedingung stammt.
//
// Leider gibt es dafür keinen einheitlichen Weg über verschiedene Datenbanken
// hinweg, ohne treiberspezifische Fehlertypen einzubinden. Die Textprüfung ist
// unschön, aber sie ist an genau einer Stelle gekapselt und deckt sowohl SQLite
// ("UNIQUE constraint failed") als auch PostgreSQL ("duplicate key value") ab.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "unique constraint") ||
strings.Contains(text, "duplicate key")
}

275
internal/store/projects.go Normal file
View file

@ -0,0 +1,275 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
)
// slugPattern beschreibt, wie ein Bezeichner aussehen darf: Kleinbuchstaben,
// Ziffern, Bindestrich, Unterstrich.
//
// Slugs tauchen in URLs und in MCP-Aufrufen auf. Die enge Zeichenmenge nimmt von
// vornherein alle Fragen nach Kodierung, Groß-/Kleinschreibung und Sonderzeichen
// aus dem Weg.
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
// ValidSlug prüft einen Bezeichner.
func ValidSlug(slug string) bool {
return slugPattern.MatchString(slug)
}
// Slugify macht aus beliebigem Text einen brauchbaren Bezeichner.
// Wird als Vorschlag verwendet, wenn ein Mensch nur einen Titel eingibt.
func Slugify(text string) string {
var b strings.Builder
letzterStrich := false
for _, r := range strings.ToLower(strings.TrimSpace(text)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
letzterStrich = false
case r == 'ä':
b.WriteString("ae")
letzterStrich = false
case r == 'ö':
b.WriteString("oe")
letzterStrich = false
case r == 'ü':
b.WriteString("ue")
letzterStrich = false
case r == 'ß':
b.WriteString("ss")
letzterStrich = false
default:
// Alles andere wird zu einem Bindestrich, aber nie zwei am Stück.
if !letzterStrich && b.Len() > 0 {
b.WriteRune('-')
letzterStrich = true
}
}
}
slug := strings.Trim(b.String(), "-")
if len(slug) > 64 {
slug = strings.Trim(slug[:64], "-")
}
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 ─────────────────────────────────────────────────────────────────
// CreateProject legt ein neues Projekt an.
func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, description string) (Project, error) {
if !ValidSlug(slug) {
return Project{}, fmt.Errorf("ungültiger Projekt-Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", slug)
}
if strings.TrimSpace(name) == "" {
name = slug
}
p := Project{
ID: NewID(),
WorkspaceID: workspaceID,
Slug: slug,
Name: name,
Description: description,
}
jetzt := NowString()
_, err := s.schreiben.ExecContext(ctx,
`INSERT INTO projects (id, workspace_id, slug, name, description, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
p.ID, p.WorkspaceID, p.Slug, p.Name, p.Description, jetzt,
)
if isUniqueViolation(err) {
return Project{}, fmt.Errorf("Projekt %q: %w", slug, ErrDuplicate)
}
if err != nil {
return Project{}, fmt.Errorf("Projekt konnte nicht angelegt werden: %w", err)
}
p.CreatedAt = parseTime(jetzt)
return p, nil
}
// projectColumns steht in jeder Projekt-Abfrage — einmal definiert, damit
// Spaltenreihenfolge und scanProject nicht auseinanderlaufen können.
const projectColumns = `id, workspace_id, slug, name, description, created_at, write_limit_hourly`
// scanProject liest eine Projektzeile.
// Nimmt ein Interface statt *sql.Row oder *sql.Rows, damit dieselbe Funktion für
// Einzelabfragen und für Schleifen über Ergebnismengen taugt.
func scanProject(scan func(...any) error) (Project, error) {
var p Project
var createdAt string
if err := scan(&p.ID, &p.WorkspaceID, &p.Slug, &p.Name, &p.Description, &createdAt, &p.WriteLimitHourly); err != nil {
return Project{}, err
}
p.CreatedAt = parseTime(createdAt)
return p, nil
}
// ProjectByID liest ein Projekt anhand seiner ID.
func (s *Store) ProjectByID(ctx context.Context, id string) (Project, error) {
row := s.lesen.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id)
p, err := scanProject(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, ErrNotFound
}
if err != nil {
return Project{}, fmt.Errorf("Projekt konnte nicht gelesen werden: %w", err)
}
return p, nil
}
// ProjectBySlug liest ein Projekt anhand seines Bezeichners innerhalb eines Workspace.
func (s *Store) ProjectBySlug(ctx context.Context, workspaceID, slug string) (Project, error) {
row := s.lesen.QueryRowContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? AND slug = ?`,
workspaceID, slug,
)
p, err := scanProject(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, ErrNotFound
}
if err != nil {
return Project{}, fmt.Errorf("Projekt konnte nicht gelesen werden: %w", err)
}
return p, nil
}
// ListProjects gibt alle Projekte eines Workspace zurück, alphabetisch sortiert.
func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project, error) {
rows, err := s.lesen.QueryContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? ORDER BY name`,
workspaceID,
)
if err != nil {
return nil, fmt.Errorf("Projekte konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var projekte []Project
for rows.Next() {
p, err := scanProject(rows.Scan)
if err != nil {
return nil, err
}
projekte = append(projekte, p)
}
return projekte, rows.Err()
}
// ProjectUpdate sind die änderbaren Felder eines Projekts.
//
// Ein Struct statt einer wachsenden Parameterliste: Beim Aufruf steht dann an
// jedem Wert, was er bedeutet — bei UpdateProject(ctx, id, "Team", "", 0) wüsste
// das niemand mehr. Dasselbe Muster wie bei EntryWrite.
//
// Was hier NICHT drinsteht, ist der Bezeichner. Der bleibt fest: Er steckt in
// URLs und in der MCP-Konfiguration jedes Clients — ihn zu ändern hieße, alle
// Agenten stillschweigend abzuhängen.
type ProjectUpdate struct {
Name string
Description string
// WriteLimitHourly ist die eigene Obergrenze für schreibende Aufrufe.
// 0 nimmt eine gesetzte Sonderregel zurück — dann gilt wieder der Standard
// aus der Umgebung.
WriteLimitHourly int
}
// UpdateProject ändert Name, Beschreibung und Schreibbudget eines Projekts.
//
// Alle drei Felder in einer Anweisung: Ein Speichern in der Verwaltung ist ein
// Vorgang, also soll er auch einer sein — sonst kann die Hälfte davon gelingen
// und die andere scheitern.
func (s *Store) UpdateProject(ctx context.Context, id string, u ProjectUpdate) error {
if strings.TrimSpace(u.Name) == "" {
return fmt.Errorf("der Name darf nicht leer sein")
}
// Negative Werte gibt es nicht: Sie würden im Schreibbudget als
// "abgeschaltet" gelesen und wären damit das Gegenteil einer Grenze.
if u.WriteLimitHourly < 0 {
return fmt.Errorf("Obergrenze darf nicht negativ sein (%d)", u.WriteLimitHourly)
}
res, err := s.schreiben.ExecContext(ctx,
`UPDATE projects SET name = ?, description = ?, write_limit_hourly = ? WHERE id = ?`,
u.Name, u.Description, u.WriteLimitHourly, id,
)
if err != nil {
return fmt.Errorf("Projekt konnte nicht geändert werden: %w", err)
}
return requireOneRow(res)
}
// DeleteProject löscht ein Projekt mit allen Keys, Einträgen und Verweisen.
//
// Das Aufräumen übernehmen die ON DELETE CASCADE-Regeln im Schema — deshalb ist
// wichtig, dass foreign_keys eingeschaltet ist (siehe Open).
func (s *Store) DeleteProject(ctx context.Context, id string) error {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("Projekt konnte nicht gelöscht werden: %w", err)
}
return requireOneRow(res)
}
// requireOneRow macht aus "keine Zeile betroffen" ein ErrNotFound.
func requireOneRow(res sql.Result) error {
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}

278
internal/store/store.go Normal file
View file

@ -0,0 +1,278 @@
// Package store ist die einzige Stelle im Programm, die SQL kennt.
//
// # Warum ein eigenes Package?
//
// Alles andere arbeitet mit Go-Typen (Project, Entry, APIKey) und ruft Methoden
// auf. Wenn die Datenbank später von SQLite auf PostgreSQL wechselt, ändert sich
// nur dieses Package — kein Handler, kein MCP-Tool, kein Template muss angefasst
// werden.
//
// # Portabilität
//
// Das SQL hier ist bewusst schlicht gehalten und vermeidet alles, was nur SQLite
// kann. Die Regeln stehen als Kommentar in migrations/001_init.sql.
//
// # Warum modernc.org/sqlite?
//
// Es gibt zwei verbreitete SQLite-Treiber für Go. Der bekanntere
// (mattn/go-sqlite3) bindet die originale C-Bibliothek ein und braucht dafür CGO.
// Damit ließe sich das Programm nicht mehr als statisches Binary bauen — und
// genau das macht das Deployment hier so einfach (ein Alpine-Image ohne Runtime).
//
// modernc.org/sqlite ist eine nach Go übersetzte Fassung derselben Bibliothek:
// etwas langsamer, dafür reines Go. Für ein internes Werkzeug ist das der
// richtige Handel.
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"sort"
"strings"
_ "modernc.org/sqlite" // meldet sich als Treiber "sqlite" an
)
// migrationsFS enthält die .sql-Dateien, die das Schema aufbauen.
// Durch das Einbetten wandern sie mit ins Binary — beim Start ist kein
// zusätzlicher Ordner nötig.
//
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Store hält die Datenbankverbindungen und stellt alle Abfragen bereit.
//
// # Warum zwei Verbindungspools?
//
// SQLite erlaubt im WAL-Modus beliebig viele gleichzeitige LESER neben genau
// einem Schreiber. Wer beides über einen Pool laufen lässt, muss sich zwischen
// zwei Übeln entscheiden:
//
// - Eine Verbindung für alles: Schreibvorgänge kollidieren nie, aber auch
// Lesezugriffe stehen Schlange. Gemessen bei 8 gleichzeitigen Agenten:
// 2.700 Abläufe/s statt 9.700 — der Löwenanteil der Anfragen an
// ein Gedächtnis sind Lesezugriffe, das ist also genau der falsche Handel.
//
// - Mehrere Verbindungen für alles: Lesen wird schnell, aber zwei gleichzeitig
// begonnene Schreibtransaktionen laufen in "database is locked". Gemessen:
// von 320 gleichzeitigen Schreibvorgängen scheiterten 296.
//
// Deshalb getrennt: viele Verbindungen zum Lesen, genau eine zum Schreiben. Die
// Schreiber warten damit in Go aufeinander statt in SQLite zu kollidieren, und
// die Leser sind davon nicht betroffen.
type Store struct {
lesen *sql.DB // mehrere Verbindungen — nur lesende Abfragen
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.
func Open(path string) (*Store, error) {
// Die Parameter in der URL setzen drei Dinge, ohne die SQLite unter
// gleichzeitigen Zugriffen unangenehm wird:
//
// _pragma=journal_mode(WAL)
// "Write-Ahead Logging". Ohne WAL sperrt jeder Schreibvorgang die ganze
// Datei, auch für Leser. Mit WAL können Leser weiterarbeiten, während
// geschrieben wird — bei einem Server mit vielen parallelen Requests
// ist das der Unterschied zwischen "läuft" und "hängt".
//
// _pragma=busy_timeout(5000)
// Trifft ein Schreiber auf eine Sperre, wartet er bis zu 5 Sekunden,
// statt sofort mit "database is locked" abzubrechen.
//
// _pragma=foreign_keys(1)
// SQLite prüft Fremdschlüssel nur, wenn man es ausdrücklich verlangt.
// Ohne das wären die REFERENCES im Schema reine Dokumentation, und ein
// gelöschtes Projekt würde verwaiste Einträge hinterlassen.
dsn := path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
// Der Schreibpool zuerst: Er legt die Datei an und wendet die Migrationen an.
//
// _txlock=immediate lässt jede Transaktion die Schreibsperre sofort nehmen
// 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")
if err != nil {
return 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)
}
s := &Store{schreiben: schreiben}
if err := s.migrate(); err != nil {
schreiben.Close()
return nil, err
}
// Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits.
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)
}
lesen.SetMaxOpenConns(maxLeseverbindungen)
if err := lesen.Ping(); err != nil {
schreiben.Close()
lesen.Close()
return nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err)
}
s.lesen = lesen
return s, nil
}
// maxLeseverbindungen begrenzt die gleichzeitigen Lesezugriffe.
//
// Mehr als eine Handvoll bringt nichts: Ab etwa acht Verbindungen ist der
// Durchsatz erreicht, weil dann die Rechenkerne und nicht mehr das Warten die
// Grenze setzen. Jede Verbindung kostet dafür Arbeitsspeicher.
const maxLeseverbindungen = 8
// Close schließt beide Verbindungspools.
func (s *Store) Close() error {
fehlerLesen := s.lesen.Close()
if err := s.schreiben.Close(); err != nil {
return err
}
return fehlerLesen
}
// migrate wendet alle noch nicht ausgeführten Migrationen 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.
//
// 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 (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`)
if err != nil {
return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err)
}
angewandt, err := s.appliedMigrations()
if err != nil {
return err
}
dateien, err := migrationFiles()
if err != nil {
return err
}
for _, name := range dateien {
if angewandt[name] {
continue
}
inhalt, err := migrationsFS.ReadFile("migrations/" + 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 {
return err
}
}
return nil
}
// 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`)
if err != nil {
return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
angewandt := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
angewandt[name] = true
}
return angewandt, rows.Err()
}
// migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge.
func migrationFiles() ([]string, error) {
eintraege, err := migrationsFS.ReadDir("migrations")
if err != nil {
return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err)
}
var namen []string
for _, e := range eintraege {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
namen = append(namen, e.Name())
}
}
// Die Nummerierung am Anfang macht die alphabetische Sortierung zur
// richtigen Reihenfolge.
sort.Strings(namen)
return namen, nil
}
// applyMigration führt eine einzelne Migration in einer Transaktion aus.
func (s *Store) applyMigration(name, sqlText string) error {
tx, err := s.schreiben.Begin()
if err != nil {
return fmt.Errorf("Transaktion für %s konnte nicht gestartet werden: %w", name, err)
}
// Rollback nach einem erfolgreichen Commit ist wirkungslos — deshalb kann
// das hier bedenkenlos per defer stehen und deckt jeden Fehlerpfad ab.
defer tx.Rollback()
if _, err := tx.Exec(sqlText); err != nil {
return fmt.Errorf("Migration %s fehlgeschlagen: %w", name, err)
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`,
name, NowString(),
); err != nil {
return fmt.Errorf("Migration %s konnte nicht vermerkt werden: %w", name, err)
}
return tx.Commit()
}
// tx führt fn innerhalb einer Transaktion aus und übernimmt Commit bzw. Rollback.
//
// Damit steht die Fehlerbehandlung einmal hier statt in jeder schreibenden
// Methode — und es kann nicht passieren, dass irgendwo ein Rollback vergessen wird.
func (s *Store) tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := s.schreiben.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("Transaktion konnte nicht gestartet werden: %w", err)
}
defer tx.Rollback()
if err := fn(tx); err != nil {
return err
}
return tx.Commit()
}

View file

@ -0,0 +1,808 @@
package store
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// testStore öffnet eine frische Datenbank in einem Testverzeichnis.
//
// Bewusst eine echte Datei statt ":memory:" — so wird auch geprüft, dass
// Migrationen, WAL-Modus und Fremdschlüssel wirklich funktionieren. t.TempDir
// räumt hinterher auf.
func testStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Datenbank konnte nicht geöffnet werden: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
// 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)
}
p, err := s.CreateProject(ctx, ws.ID, "test", "Testprojekt", "")
if err != nil {
t.Fatalf("Projekt: %v", err)
}
return p
}
// ── Migrationen ──────────────────────────────────────────────────────────────
// TestMigrationsAreIdempotent prüft, dass ein zweiter Start dieselbe Datenbank
// nicht kaputt macht — der Normalfall bei jedem Neustart des Servers.
func TestMigrationsAreIdempotent(t *testing.T) {
pfad := filepath.Join(t.TempDir(), "test.db")
s1, err := Open(pfad)
if err != nil {
t.Fatalf("erster Start: %v", err)
}
p := testProject(t, s1)
s1.Close()
s2, err := Open(pfad)
if err != nil {
t.Fatalf("zweiter Start: %v", err)
}
defer s2.Close()
// Die Daten aus dem ersten Start müssen noch da sein.
if _, err := s2.ProjectByID(context.Background(), p.ID); err != nil {
t.Errorf("Projekt nach Neustart nicht mehr auffindbar: %v", err)
}
}
// TestMigrationBestandsdaten prüft, dass eine mit Fassung 001 angelegte
// Datenbank sauber auf 002 gehoben wird — der Fall bei jedem Update.
func TestMigrationBestandsdaten(t *testing.T) {
pfad := filepath.Join(t.TempDir(), "alt.db")
s, err := Open(pfad)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
ws, _ := s.EnsureWorkspace(ctx, "M")
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})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle", Title: "Quelle",
Content: "siehe [[ziel]]", Tags: []string{"ops", "docs"}, Source: SourceHuman})
s.Close()
// Erneut öffnen — hier läuft die Migration bei Bestandsdaten.
s2, err := Open(pfad)
if err != nil {
t.Fatalf("Migration fehlgeschlagen: %v", err)
}
defer s2.Close()
// project_id muss in beiden Nebentabellen nachgetragen worden sein.
var leereTags, leereLinks int
s2.lesen.QueryRow(`SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&leereTags)
s2.lesen.QueryRow(`SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&leereLinks)
if leereTags != 0 || leereLinks != 0 {
t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", leereTags, leereLinks)
}
// Und die Abfragen liefern weiterhin das Richtige.
tags, err := s2.ListTags(ctx, p.ID)
if err != nil || len(tags) != 2 {
t.Errorf("ListTags nach Migration: %v (%d Schlagworte, erwartet 2)", err, len(tags))
}
links, err := s2.Backlinks(ctx, p.ID, "ziel")
if err != nil || len(links) != 1 {
t.Errorf("Backlinks nach Migration: %v (%d Verweise, erwartet 1)", err, len(links))
}
}
// TestGleichzeitigeSchreibvorgaenge ist der Regressionstest zu den zwei
// Verbindungspools (siehe Store in store.go).
//
// Mit einem gemeinsamen Pool für Lesen und Schreiben scheiterten hier 296 von
// 320 Schreibvorgängen mit "database is locked" — mehrere Verbindungen begannen
// gleichzeitig eine Schreibtransaktion und liefen mitten drin auf die Sperre,
// wo busy_timeout nicht mehr greift.
func TestGleichzeitigeSchreibvorgaenge(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
ctx := context.Background()
const agenten, jeAgent = 8, 40
var wg sync.WaitGroup
var mu sync.Mutex
var fehler []error
for a := 0; a < agenten; a++ {
wg.Add(1)
go func(a int) {
defer wg.Done()
for i := 0; i < jeAgent; i++ {
_, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID,
Slug: fmt.Sprintf("agent%d-%03d", a, i),
Title: "Eintrag",
Content: "Inhalt",
Source: SourceAgent,
Author: fmt.Sprintf("agent-%d", a),
})
if err != nil {
mu.Lock()
fehler = append(fehler, err)
mu.Unlock()
}
}
}(a)
}
wg.Wait()
if len(fehler) > 0 {
t.Errorf("%d von %d Schreibvorgängen fehlgeschlagen, erster: %v",
len(fehler), agenten*jeAgent, fehler[0])
}
n, err := s.CountEntries(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if n != agenten*jeAgent {
t.Errorf("%d Einträge angelegt, erwartet %d", n, agenten*jeAgent)
}
}
// TestGleichzeitigesLesenUndSchreiben prüft, dass Lesezugriffe nicht hinter
// Schreibvorgängen warten müssen — der Grund für den getrennten Lesepool.
func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
ctx := context.Background()
for i := 0; i < 50; i++ {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: fmt.Sprintf("e-%03d", i),
Title: "T", Content: "Inhalt", Source: SourceHuman})
}
var wg sync.WaitGroup
var leseFehler, schreibFehler int64
// Schreiber und Leser gleichzeitig laufen lassen.
for i := 0; i < 4; i++ {
wg.Add(2)
go func(i int) {
defer wg.Done()
for j := 0; j < 25; j++ {
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID,
Slug: fmt.Sprintf("neu-%d-%03d", i, j), Title: "T",
Content: "Inhalt", Source: SourceAgent}); err != nil {
atomic.AddInt64(&schreibFehler, 1)
}
}
}(i)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
if _, err := s.ListEntries(ctx, p.ID, EntryFilter{Limit: 20}); err != nil {
atomic.AddInt64(&leseFehler, 1)
}
}
}()
}
wg.Wait()
if leseFehler > 0 || schreibFehler > 0 {
t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", leseFehler, schreibFehler)
}
}
// ── 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")
}
}
func TestCreateProjectRejectsBadSlug(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ws, _ := s.EnsureWorkspace(ctx, "Test")
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 {
t.Errorf("ungültiger Bezeichner %q wurde akzeptiert", slug)
}
}
}
func TestCreateProjectRejectsDuplicateSlug(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ws, _ := s.EnsureWorkspace(ctx, "Test")
if _, err := s.CreateProject(ctx, ws.ID, "doppelt", "Erst", ""); err != nil {
t.Fatal(err)
}
_, err := s.CreateProject(ctx, ws.ID, "doppelt", "Zweit", "")
if !errors.Is(err, ErrDuplicate) {
t.Errorf("Fehler = %v, erwartet ErrDuplicate", err)
}
}
// TestProjectWriteLimitDefaultsToZero prüft die Vorgabe aus Migration 003.
// 0 heißt nicht "gesperrt", sondern "keine eigene Angabe" — nur so bekommen
// bestehende Projekte nach der Migration den Standard aus der Umgebung.
func TestProjectWriteLimitDefaultsToZero(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
if p.WriteLimitHourly != 0 {
t.Errorf("WriteLimitHourly = %d bei einem neuen Projekt, erwartet 0", p.WriteLimitHourly)
}
}
func TestUpdateProject(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
ctx := context.Background()
if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{
Name: "Neuer Name", Description: "Neue Beschreibung", WriteLimitHourly: 250,
}); err != nil {
t.Fatalf("Projekt ändern: %v", err)
}
gelesen, err := s.ProjectByID(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if gelesen.Name != "Neuer Name" || gelesen.Description != "Neue Beschreibung" {
t.Errorf("Name/Beschreibung = %q/%q", gelesen.Name, gelesen.Description)
}
if gelesen.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly)
}
// Der Bezeichner bleibt fest — er steckt in URLs und MCP-Konfigurationen.
if gelesen.Slug != p.Slug {
t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", gelesen.Slug, p.Slug)
}
// 0 nimmt die eigene Obergrenze zurück.
if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{Name: "Neuer Name"}); err != nil {
t.Fatal(err)
}
gelesen, _ = s.ProjectByID(ctx, p.ID)
if gelesen.WriteLimitHourly != 0 {
t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", gelesen.WriteLimitHourly)
}
}
// TestUpdateProjectRejectsNegativeLimit: Eine negative Zahl würde im
// Schreibbudget als "abgeschaltet" gelesen — also das Gegenteil einer Grenze.
func TestUpdateProjectRejectsNegativeLimit(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
err := s.UpdateProject(context.Background(), p.ID, ProjectUpdate{Name: "Test", WriteLimitHourly: -1})
if err == nil {
t.Error("eine negative Obergrenze wurde angenommen")
}
}
func TestUpdateProjectRejectsEmptyName(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
if err := s.UpdateProject(context.Background(), p.ID, ProjectUpdate{Name: " "}); err == nil {
t.Error("ein leerer Name wurde angenommen")
}
}
func TestUpdateProjectUnknownID(t *testing.T) {
s := testStore(t)
err := s.UpdateProject(context.Background(), "gibtesnicht", ProjectUpdate{Name: "Test"})
if !errors.Is(err, ErrNotFound) {
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
}
}
func TestProjectNotFound(t *testing.T) {
s := testStore(t)
if _, err := s.ProjectByID(context.Background(), "gibtsnicht"); !errors.Is(err, ErrNotFound) {
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
}
}
// TestDeleteProjectCascades prüft, dass mit dem Projekt auch Keys und Einträge
// verschwinden. Das funktioniert nur, wenn foreign_keys eingeschaltet ist —
// dieser Test schlägt fehl, falls das PRAGMA je verloren geht.
func TestDeleteProjectCascades(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
if _, err := s.CreateAPIKey(ctx, p.ID, "test", "hash1", "write"); err != nil {
t.Fatal(err)
}
if _, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "eintrag", Title: "T", Content: "C", Source: SourceHuman,
}); err != nil {
t.Fatal(err)
}
if err := s.DeleteProject(ctx, p.ID); err != nil {
t.Fatal(err)
}
if _, err := s.ResolveKeyHash(ctx, "hash1"); !errors.Is(err, ErrNotFound) {
t.Error("der Key des gelöschten Projekts existiert noch")
}
if n, _ := s.CountEntries(ctx, p.ID); n != 0 {
t.Errorf("%d Einträge des gelöschten Projekts sind übrig", n)
}
}
// ── API-Keys ─────────────────────────────────────────────────────────────────
func TestResolveKeyHash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
k, err := s.CreateAPIKey(ctx, p.ID, "tom-cline", "hash-abc", "write")
if err != nil {
t.Fatal(err)
}
auf, err := s.ResolveKeyHash(ctx, "hash-abc")
if err != nil {
t.Fatalf("gültiger Key wurde nicht aufgelöst: %v", err)
}
if auf.Key.ID != k.ID {
t.Error("falscher Key aufgelöst")
}
// Das Projekt kommt in derselben Abfrage mit — sonst bräuchte jeder Request
// einen zweiten Datenbankzugriff.
if auf.Project.ID != p.ID {
t.Error("das Projekt wurde nicht mitgeliefert")
}
}
// TestRevokedKeyIsGone prüft, dass ein zurückgezogener Key sofort nicht mehr
// gilt — das ist der Ersatz für den früheren Fingerabdruck-Mechanismus.
func TestRevokedKeyIsGone(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
k, _ := s.CreateAPIKey(ctx, p.ID, "alt", "hash-alt", "write")
if _, err := s.ResolveKeyHash(ctx, "hash-alt"); err != nil {
t.Fatalf("Key galt von Anfang an nicht: %v", err)
}
if err := s.RevokeAPIKey(ctx, k.ID); err != nil {
t.Fatal(err)
}
if _, err := s.ResolveKeyHash(ctx, "hash-alt"); !errors.Is(err, ErrNotFound) {
t.Error("zurückgezogener Key gilt weiterhin")
}
// Der Datensatz bleibt aber erhalten — sonst verlöre das Audit-Log die Spur.
if _, err := s.APIKeyByID(ctx, k.ID); err != nil {
t.Errorf("der Key-Datensatz wurde gelöscht statt markiert: %v", err)
}
}
func TestCreateAPIKeyRequiresName(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
if _, err := s.CreateAPIKey(context.Background(), p.ID, " ", "hash", "read"); err == nil {
t.Error("ein Key ohne Namen wurde angelegt")
}
}
// ── Einträge ─────────────────────────────────────────────────────────────────
func TestPutEntryCreatesAndUpdates(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
erst, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "deployment", Title: "Deployment",
Content: "Erste Fassung", Tags: []string{"Ops", "ops", " Deploy "},
Source: SourceAgent, Author: "agent-1",
})
if err != nil {
t.Fatal(err)
}
if erst.Version != 1 {
t.Errorf("Version = %d, erwartet 1", erst.Version)
}
// Schlagworte werden vereinheitlicht: klein, ohne Dubletten, sortiert.
if len(erst.Tags) != 2 || erst.Tags[0] != "deploy" || erst.Tags[1] != "ops" {
t.Errorf("Tags = %v, erwartet [deploy ops]", erst.Tags)
}
// Derselbe Slug aktualisiert statt anzulegen — das ist der Kern der
// Dublettenvermeidung.
zweit, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "deployment", Title: "Deployment",
Content: "Zweite Fassung", Source: SourceAgent, Author: "agent-2",
})
if err != nil {
t.Fatal(err)
}
if zweit.Version != 2 {
t.Errorf("Version = %d, erwartet 2", zweit.Version)
}
if zweit.ID != erst.ID {
t.Error("es wurde ein zweiter Eintrag angelegt statt zu aktualisieren")
}
if n, _ := s.CountEntries(ctx, p.ID); n != 1 {
t.Errorf("%d Einträge, erwartet 1", n)
}
// Die Schlagworte des ersten Schreibvorgangs sind weg, weil der zweite keine
// mitgebracht hat — der Eintrag ist die Wahrheit, nicht die Summe der Änderungen.
if len(zweit.Tags) != 0 {
t.Errorf("Tags = %v, erwartet leer", zweit.Tags)
}
}
// TestPutEntryOptimisticLocking prüft den Schutz vor stillem Überschreiben.
func TestPutEntryOptimisticLocking(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
basis := EntryWrite{ProjectID: p.ID, Slug: "geteilt", Title: "T", Source: SourceAgent}
e, err := s.PutEntry(ctx, basis)
if err != nil {
t.Fatal(err)
}
// Agent A schreibt auf Basis von Version 1 — geht durch.
a := basis
a.Content = "von A"
a.ExpectedVersion = e.Version
if _, err := s.PutEntry(ctx, a); err != nil {
t.Fatalf("erster Schreibvorgang scheiterte: %v", err)
}
// Agent B hatte ebenfalls Version 1 gelesen — muss jetzt einen Konflikt bekommen.
b := basis
b.Content = "von B"
b.ExpectedVersion = e.Version
if _, err := s.PutEntry(ctx, b); !errors.Is(err, ErrConflict) {
t.Errorf("Fehler = %v, erwartet ErrConflict — B hat A stillschweigend überschrieben", err)
}
}
// TestPutEntryExpectedVersionOnMissingEntry: Wer einen Stand fortschreiben will,
// den es nicht mehr gibt, bekommt einen Konflikt statt eines neuen Eintrags.
func TestPutEntryExpectedVersionOnMissingEntry(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
_, err := s.PutEntry(context.Background(), EntryWrite{
ProjectID: p.ID, Slug: "geloescht", Title: "T", Source: SourceAgent, ExpectedVersion: 3,
})
if !errors.Is(err, ErrConflict) {
t.Errorf("Fehler = %v, erwartet ErrConflict", err)
}
}
func TestPutEntryValidation(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
cases := []struct {
name string
w EntryWrite
}{
{"leerer Slug", EntryWrite{ProjectID: p.ID, Title: "T", Source: SourceHuman}},
{"Slug mit Pfad", EntryWrite{ProjectID: p.ID, Slug: "../raus", Title: "T", Source: SourceHuman}},
{"Slug groß", EntryWrite{ProjectID: p.ID, Slug: "Groß", Title: "T", Source: SourceHuman}},
{"kein Titel", EntryWrite{ProjectID: p.ID, Slug: "ok", Source: SourceHuman}},
{"unbekannte Herkunft", EntryWrite{ProjectID: p.ID, Slug: "ok", Title: "T", Source: "irgendwas"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := s.PutEntry(ctx, tc.w); err == nil {
t.Error("ungültiger Schreibvorgang wurde akzeptiert")
}
})
}
}
// TestEntriesAreProjectScoped ist der Isolationstest: derselbe Slug in zwei
// Projekten sind zwei verschiedene Einträge, und keiner sieht den anderen.
func TestEntriesAreProjectScoped(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ws, _ := s.EnsureWorkspace(ctx, "Test")
a, _ := s.CreateProject(ctx, ws.ID, "projekt-a", "A", "")
b, _ := s.CreateProject(ctx, ws.ID, "projekt-b", "B", "")
if _, err := s.PutEntry(ctx, EntryWrite{
ProjectID: a.ID, Slug: "geheim", Title: "A-Wissen", Content: "nur für A", Source: SourceHuman,
}); err != nil {
t.Fatal(err)
}
if _, err := s.EntryBySlug(ctx, b.ID, "geheim"); !errors.Is(err, ErrNotFound) {
t.Error("Projekt B sieht einen Eintrag von Projekt A")
}
// Derselbe Slug muss in B trotzdem frei sein.
if _, err := s.PutEntry(ctx, EntryWrite{
ProjectID: b.ID, Slug: "geheim", Title: "B-Wissen", Content: "nur für B", Source: SourceHuman,
}); err != nil {
t.Errorf("derselbe Slug ließ sich in Projekt B nicht anlegen: %v", err)
}
}
func TestListEntriesFilterByTag(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{"ops"}, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"docs"}, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Tags: []string{"ops", "docs"}, Source: SourceHuman})
gefiltert, err := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "ops"})
if err != nil {
t.Fatal(err)
}
if len(gefiltert) != 2 {
t.Errorf("%d Treffer, erwartet 2", len(gefiltert))
}
// Groß-/Kleinschreibung darf keine Rolle spielen.
grossGeschrieben, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"})
if len(grossGeschrieben) != 2 {
t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(grossGeschrieben))
}
}
func TestListTagsCounts(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{"ops"}, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"ops", "docs"}, Source: SourceHuman})
tags, err := s.ListTags(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if len(tags) != 2 {
t.Fatalf("%d Schlagworte, erwartet 2", len(tags))
}
// Häufigste zuerst.
if tags[0].Tag != "ops" || tags[0].Count != 2 {
t.Errorf("erstes Schlagwort = %+v, erwartet {ops 2}", tags[0])
}
}
func TestDeleteEntry(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 {
t.Fatal(err)
}
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) {
t.Error("der Eintrag existiert noch")
}
if err := s.DeleteEntry(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) {
t.Error("zweimaliges Löschen meldete keinen Fehler")
}
}
// ── Verweise ─────────────────────────────────────────────────────────────────
func TestExtractLinks(t *testing.T) {
cases := map[string][]string{
"": nil,
"ohne Verweise": nil,
"siehe [[deployment]]": {"deployment"},
"[[b]] und [[a]] und nochmal [[a]]": {"a", "b"},
"[[Großbuchstaben]] zählen nicht": nil,
"[[mit leerzeichen]] auch nicht": nil,
"[[a-b_c1]] ist erlaubt": {"a-b_c1"},
"einfache [klammern] sind kein Verweis": nil,
}
for input, erwartet := range cases {
got := ExtractLinks(input)
if len(got) != len(erwartet) {
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet)
continue
}
for i := range got {
if got[i] != erwartet[i] {
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet)
break
}
}
}
}
// TestBacklinks prüft die Gegenrichtung der Verweise — sie ersetzt die frühere
// Ordnerhierarchie als Struktur zwischen Einträgen.
func TestBacklinks(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-a", Title: "Quelle A",
Content: "siehe [[ziel]]", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-b", Title: "Quelle B",
Content: "auch [[ziel]]", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ohne", Title: "Ohne", Source: SourceHuman})
links, err := s.Backlinks(ctx, p.ID, "ziel")
if err != nil {
t.Fatal(err)
}
if len(links) != 2 {
t.Fatalf("%d Rückverweise, erwartet 2", len(links))
}
// Verweise verschwinden, wenn sie aus dem Inhalt entfernt werden.
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-a", Title: "Quelle A",
Content: "ohne Verweis", Source: SourceHuman})
links, _ = s.Backlinks(ctx, p.ID, "ziel")
if len(links) != 1 {
t.Errorf("%d Rückverweise nach dem Entfernen, erwartet 1", len(links))
}
}
// ── Audit ────────────────────────────────────────────────────────────────────
func TestAuditRoundTrip(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "tom", Action: ActionEntryWrite,
EntrySlug: "a", Source: SourceWeb, IP: "10.0.0.1"})
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryDelete,
EntrySlug: "b", Source: SourceMCP})
ereignisse, err := s.ListAudit(ctx, AuditFilter{ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
if len(ereignisse) != 2 {
t.Fatalf("%d Ereignisse, erwartet 2", len(ereignisse))
}
// Neueste zuerst.
if ereignisse[0].Action != ActionEntryDelete {
t.Errorf("erstes Ereignis = %q, erwartet %q", ereignisse[0].Action, ActionEntryDelete)
}
if ereignisse[0].Timestamp.IsZero() {
t.Error("Zeitstempel wurde nicht gesetzt")
}
}
// TestPruneAudit prüft die Aufbewahrungsfrist — ohne sie wächst das Protokoll
// unbegrenzt, und personenbezogene Daten dürften gar nicht so lange liegen.
func TestPruneAudit(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: "test", Source: SourceSystem})
// Grenze in der Zukunft — alles muss weg.
zukunft := time.Now().UTC().Add(time.Hour).Format(timeFormat)
n, err := s.PruneAudit(ctx, zukunft)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("%d Zeilen gelöscht, erwartet 1", n)
}
rest, _ := s.ListAudit(ctx, AuditFilter{})
if len(rest) != 0 {
t.Errorf("%d Ereignisse übrig", len(rest))
}
}
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
func TestSlugify(t *testing.T) {
cases := map[string]string{
"Deployment Prozess": "deployment-prozess",
"Größe & Übersicht": "groesse-uebersicht",
" mehrere Wörter ": "mehrere-woerter",
"MIT-Bindestrich": "mit-bindestrich",
"!!!": "",
"straße": "strasse",
}
for input, erwartet := range cases {
if got := Slugify(input); got != erwartet {
t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, erwartet)
}
}
}
func TestNewIDIsSortableAndUnique(t *testing.T) {
gesehen := make(map[string]bool)
var vorher string
for i := 0; i < 200; i++ {
id := NewID()
if gesehen[id] {
t.Fatalf("doppelte ID: %s", id)
}
gesehen[id] = true
// Der Zeitanteil steht vorne, IDs sind also grob chronologisch sortierbar.
if vorher != "" && id < vorher {
zeitVorher, _, _ := strings.Cut(vorher, "-")
zeitJetzt, _, _ := strings.Cut(id, "-")
if zeitJetzt < zeitVorher {
t.Errorf("IDs sind nicht chronologisch: %s vor %s", vorher, id)
}
}
vorher = id
}
}
func TestNormalizeTags(t *testing.T) {
got := NormalizeTags([]string{" Ops ", "ops", "DOCS", "", " "})
if len(got) != 2 || got[0] != "docs" || got[1] != "ops" {
t.Errorf("NormalizeTags = %v, erwartet [docs ops]", got)
}
}

147
internal/web/context.go Normal file
View file

@ -0,0 +1,147 @@
// Package web enthält die HTTP-Schicht: Routen, Middleware, Handler und die
// Templates der Weboberfläche.
package web
import (
"context"
"net/http"
"munin/internal/auth"
"munin/internal/store"
)
// Session beschreibt, wer gerade zugreift.
//
// 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.
// - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem
// Projekt gehört. Was er darf, sagt Role.
type Session struct {
Subject string // für die CSRF-Token-Berechnung
IsPlatformAdmin bool
Key store.APIKey
Project store.Project
Role auth.Role
}
// CanAccessProject sagt, ob diese Sitzung auf ein Projekt zugreifen darf.
//
// Hier steckt die Mandantentrennung der Weboberfläche: Ein Projekt-Key kommt
// ausschließlich an sein eigenes Projekt, ganz gleich welche URL aufgerufen wird.
func (s Session) CanAccessProject(projectID string) bool {
if s.IsPlatformAdmin {
return true
}
return s.Key.ProjectID == projectID
}
// CanWrite sagt, ob Einträge angelegt oder geändert werden dürfen.
func (s Session) CanWrite() bool {
return s.IsPlatformAdmin || s.Role.CanWrite()
}
// CanManageKeys sagt, ob die Keys eines Projekts verwaltet werden dürfen.
func (s Session) CanManageKeys() bool {
return s.IsPlatformAdmin || s.Role.CanManageKeys()
}
// ActorName ist der Name, unter dem diese Sitzung im Audit-Log auftaucht.
func (s Session) ActorName() string {
if s.IsPlatformAdmin {
return "platform-admin"
}
return s.Key.Name
}
// contextKey ist ein eigener Typ für Context-Schlüssel.
//
// Ein eigener Typ (statt eines Strings) verhindert, dass zwei Pakete
// versehentlich denselben Schlüssel verwenden und sich gegenseitig die Werte
// überschreiben — der Typ ist hier nicht exportiert, also kann ihn niemand sonst
// erzeugen.
type contextKey int
const (
sessionContextKey contextKey = iota
projectContextKey
aktorContextKey
)
// aktorHalter ist ein veränderbarer Platz im Context, in den die
// Anmelde-Middleware den Namen des Zugangs schreibt.
//
// # Warum dieser Umweg?
//
// Das Zugriffsprotokoll liegt ganz außen, damit es auch abgewiesene Anfragen
// erfasst. Zu diesem Zeitpunkt ist aber noch niemand angemeldet — und die
// Sitzung landet später in einer ABGELEITETEN Kopie des Requests
// (r.WithContext), die das äußere Stück nie zu sehen bekommt.
//
// Deshalb legt die äußere Middleware einen leeren Halter ab, den die innere
// befüllt. Ein Zeiger im Context ist der übliche Weg dafür.
//
// Ohne Absicherung gegen gleichzeitigen Zugriff: Geschrieben und gelesen wird in
// derselben Goroutine, das Lesen erst nachdem der Handler zurückgekehrt ist.
type aktorHalter struct {
name string
}
// withAktorHalter legt einen leeren Halter im Context ab.
func withAktorHalter(ctx context.Context, h *aktorHalter) context.Context {
return context.WithValue(ctx, aktorContextKey, h)
}
// merkeAktor trägt den Namen des Zugangs in den Halter ein, falls einer da ist.
func merkeAktor(ctx context.Context, name string) {
if h, ok := ctx.Value(aktorContextKey).(*aktorHalter); ok {
h.name = name
}
}
// withSession legt die Sitzung im Request-Context ab.
//
// Nebenbei wird der Name des Zugangs für das Zugriffsprotokoll vermerkt — das
// liegt weiter außen und käme sonst nicht an ihn heran (siehe aktorHalter).
func withSession(ctx context.Context, s Session) context.Context {
merkeAktor(ctx, s.ActorName())
return context.WithValue(ctx, sessionContextKey, s)
}
// SessionFrom liest die Sitzung aus dem Request.
// Ohne durchlaufene Auth-Middleware ist ok false.
func SessionFrom(r *http.Request) (Session, bool) {
s, ok := r.Context().Value(sessionContextKey).(Session)
return s, ok
}
// withProject legt das Projekt der aktuellen Route im Request-Context ab.
func withProject(ctx context.Context, p store.Project) context.Context {
return context.WithValue(ctx, projectContextKey, p)
}
// ProjectFrom liest das Projekt der aktuellen Route.
func ProjectFrom(r *http.Request) (store.Project, bool) {
p, ok := r.Context().Value(projectContextKey).(store.Project)
return p, ok
}
// SessionFromContext liest die Sitzung direkt aus einem Context.
//
// Die MCP-Werkzeuge bekommen vom SDK nur den Context übergeben, keinen
// *http.Request — deshalb neben SessionFrom auch diese Variante.
func SessionFromContext(ctx context.Context) (Session, bool) {
s, ok := ctx.Value(sessionContextKey).(Session)
return s, ok
}
// WithSessionForTest legt eine Sitzung im Context ab.
//
// Im Betrieb macht das die Middleware. Andere Packages (etwa die Tests von
// internal/mcpserver) brauchen aber einen Weg, eine Sitzung zu setzen, ohne
// einen kompletten HTTP-Server aufzubauen — und der Context-Schlüssel ist
// bewusst nicht exportiert.
func WithSessionForTest(ctx context.Context, s Session) context.Context {
return withSession(ctx, s)
}

View file

@ -0,0 +1,494 @@
package web
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"munin/internal/auth"
"munin/internal/store"
)
// projectListData sind die Daten der Projektübersicht.
type projectListData struct {
Projects []projectListItem
}
// projectListItem ist eine Zeile der Projektübersicht.
type projectListItem struct {
Project store.Project
Entries int
// Limit ist die geltende Obergrenze für schreibende MCP-Aufrufe je Stunde,
// Frei der Rest im laufenden Fenster. Limit 0 heißt "unbegrenzt".
//
// Die geltende Grenze steht hier und nicht im Template, weil erst das
// Schreibbudget die Auflösung kennt: eigene Angabe des Projekts, sonst der
// Standard aus der Umgebung.
Limit int
Frei int
// EigenesLimit ist die im Projekt hinterlegte Zahl — 0 bedeutet "keine
// eigene Angabe". Steht im Formularfeld, damit dort der gesetzte Wert
// erscheint und nicht die aufgelöste Grenze.
EigenesLimit int
}
// 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)
if err != nil {
s.serverError(w, "Projekte konnten nicht geladen werden", err)
return
}
items, err := s.projectListItems(r, projekte)
if err != nil {
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
return
}
s.render(w, r, "projects.html", http.StatusOK, pageData{
Title: "Projekte",
Bereich: "projekte",
Flash: r.URL.Query().Get("hinweis"),
Data: projectListData{Projects: items},
})
}
// handleProjectCreate legt ein neues Projekt an.
func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
slug := strings.TrimSpace(r.FormValue("slug"))
beschreibung := strings.TrimSpace(r.FormValue("description"))
if slug == "" {
slug = store.Slugify(name)
}
projekt, err := s.store.CreateProject(r.Context(), s.workspaceID, slug, name, beschreibung)
if err != nil {
s.renderProjectListWithError(w, r, err.Error())
return
}
s.store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: projekt.ID, Actor: "platform-admin",
Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
})
// Direkt zur Key-Verwaltung: Ein Projekt ohne Key ist nutzlos, und der erste
// Handgriff nach dem Anlegen ist immer, einen Zugang zu erzeugen.
http.Redirect(w, r, "/p/"+projekt.Slug+"/keys", http.StatusSeeOther)
}
// handleProjectDelete löscht ein Projekt samt Einträgen und Keys.
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
id := r.FormValue("id")
projekt, err := s.store.ProjectByID(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Projekt konnte nicht geladen werden", err)
return
}
// Sicherheitsabfrage im Formular: Der eingegebene Bezeichner muss stimmen.
// Ein Klick auf "Löschen" reicht nicht — dahinter verschwinden alle Einträge
// des Projekts unwiederbringlich.
if r.FormValue("confirm") != projekt.Slug {
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "",
"Zum Löschen muss der Projekt-Bezeichner \""+projekt.Slug+"\" zur Bestätigung eingegeben werden.")
return
}
if err := s.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{
ProjectID: id, Actor: "platform-admin",
Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: projekt.Slug,
})
http.Redirect(w, r, "/admin/projects?hinweis=Projekt+gel%C3%B6scht", http.StatusSeeOther)
}
// 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)
if err != nil {
s.serverError(w, "Projekte konnten nicht geladen werden", err)
return
}
// Fehler beim Zählen sind hier gleichgültig: Auf dem Weg hierher ist bereits
// etwas schiefgegangen, und die Fehlermeldung ist wichtiger als die Zahlen.
items, _ := s.projectListItems(r, projekte)
s.render(w, r, "projects.html", http.StatusBadRequest, pageData{
Title: "Projekte",
Bereich: "projekte",
Error: meldung,
Data: projectListData{Projects: items},
})
}
// projectListItems reichert die Projekte mit Eintragszahl 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) {
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
}
item := projectListItem{
Project: p,
Entries: anzahl,
EigenesLimit: p.WriteLimitHourly,
}
if s.writeBudget != nil {
item.Frei, item.Limit = s.writeBudget.Remaining(p.ID, p.WriteLimitHourly)
}
items = append(items, item)
}
return items, nil
}
// projectEditData sind die Daten der Bearbeitungsseite eines Projekts.
type projectEditData struct {
Item projectListItem
}
// handleProjectEditForm zeigt das Bearbeitungsformular eines Projekts.
//
// Anders als bei den früheren Formularen auf der Übersichtsseite steht hier ein
// EINZELNES Projekt fest — mit seinen tatsächlichen Werten in den Feldern. Ein
// Auswahlfeld über allen Projekten plus leerem Eingabefeld sieht bequemer aus,
// hat aber eine Falle: Wer speichert, ohne ein Feld anzufassen, überschreibt den
// bestehenden Wert mit der Vorgabe des Formulars.
func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) {
projekt, ok := s.projectFromSlug(w, r)
if !ok {
return
}
s.renderProjectEdit(w, r, http.StatusOK, projekt, "", "")
}
// handleProjectEditSave speichert Name, Beschreibung und Obergrenze.
func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
projekt, ok := s.projectFromSlug(w, r)
if !ok {
return
}
name := strings.TrimSpace(r.FormValue("name"))
beschreibung := strings.TrimSpace(r.FormValue("description"))
if name == "" {
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", "Der Name darf nicht leer sein.")
return
}
roh := strings.TrimSpace(r.FormValue("write_limit_hourly"))
limit, err := strconv.Atoi(roh)
if err != nil || limit < 0 {
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "",
"Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).")
return
}
aenderungen := projektAenderungen(projekt, name, beschreibung, limit)
if len(aenderungen) == 0 {
s.renderProjectEdit(w, r, http.StatusOK, projekt, "Nichts geändert.", "")
return
}
if err := s.store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{
Name: name,
Description: beschreibung,
WriteLimitHourly: limit,
}); err != nil {
s.serverError(w, "Projekt konnte nicht geändert werden", err)
return
}
// Was sich geändert hat, steht im Detail — sonst sagt das Protokoll nur,
// 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{
ProjectID: projekt.ID, Actor: "platform-admin",
Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: strings.Join(aenderungen, "; "),
})
http.Redirect(w, r, "/admin/projects?hinweis="+url.QueryEscape(projekt.Slug+" gespeichert"),
http.StatusSeeOther)
}
// projektAenderungen beschreibt, was sich gegenüber dem gespeicherten Stand
// ändert — fürs Protokoll, und um ein Speichern ohne Änderung zu erkennen.
func projektAenderungen(alt store.Project, name, beschreibung string, limit int) []string {
var aenderungen []string
if name != alt.Name {
aenderungen = append(aenderungen, fmt.Sprintf("Name: %q → %q", alt.Name, name))
}
if beschreibung != alt.Description {
aenderungen = append(aenderungen, fmt.Sprintf("Beschreibung: %q → %q", alt.Description, beschreibung))
}
if limit != alt.WriteLimitHourly {
aenderungen = append(aenderungen,
fmt.Sprintf("write_limit_hourly: %d → %d", alt.WriteLimitHourly, limit))
}
return aenderungen
}
// projectFromSlug löst den Bezeichner aus der URL zu einem Projekt auf.
//
// Eigene Funktion statt der Middleware requireProject: Die liegt unter
// /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier ist ohnehin
// 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"))
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return store.Project{}, false
}
if err != nil {
s.serverError(w, "Projekt konnte nicht geladen werden", err)
return store.Project{}, false
}
return projekt, true
}
// renderProjectEdit zeigt die Bearbeitungsseite eines Projekts.
func (s *Server) renderProjectEdit(w http.ResponseWriter, r *http.Request, status int,
projekt store.Project, flash, fehler string) {
items, err := s.projectListItems(r, []store.Project{projekt})
if err != nil {
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
return
}
s.render(w, r, "project-edit.html", status, pageData{
Title: projekt.Name,
Bereich: "projekte",
Flash: flash,
Error: fehler,
Data: projectEditData{Item: items[0]},
})
}
// ── Key-Verwaltung ───────────────────────────────────────────────────────────
// keyListData sind die Daten der Key-Verwaltung.
type keyListData struct {
Keys []store.APIKey
// NewKey wird nur unmittelbar nach dem Anlegen gesetzt. Der Klartext steht
// nirgends gespeichert — nur der Hash liegt in der Datenbank —, deshalb wird
// er hier genau einmal angezeigt.
NewKey string
NewKeyName string
}
// handleKeyList zeigt die Keys eines Projekts.
func (s *Server) handleKeyList(w http.ResponseWriter, r *http.Request) {
s.renderKeyList(w, r, http.StatusOK, keyListData{}, "", "")
}
// handleKeyCreate erzeugt einen neuen Key und zeigt ihn genau einmal an.
func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
name := strings.TrimSpace(r.FormValue("name"))
rolle := auth.Role(r.FormValue("role"))
if !rolle.Valid() {
s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Unbekannte Rolle.")
return
}
klartext, err := auth.GenerateKey()
if err != nil {
s.serverError(w, "Key konnte nicht erzeugt werden", err)
return
}
erzeugt, err := s.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{
ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(),
Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: erzeugt.Name + " (" + string(rolle) + ")",
})
s.renderKeyList(w, r, http.StatusOK,
keyListData{NewKey: klartext, NewKeyName: erzeugt.Name},
"Key angelegt. Er wird nur jetzt angezeigt — bitte gleich übernehmen.", "")
}
// handleKeyRevoke zieht einen Key zurück.
func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) {
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)
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) {
s.serverError(w, "Key konnte nicht zurückgezogen werden", err)
return
}
s.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,
})
s.renderKeyList(w, r, http.StatusOK, keyListData{}, "Key zurückgezogen. Er gilt ab sofort nicht mehr.", "")
}
// 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) {
project, _ := ProjectFrom(r)
keys, err := s.store.ListAPIKeys(r.Context(), project.ID)
if err != nil {
s.serverError(w, "Keys konnten nicht geladen werden", err)
return
}
daten.Keys = keys
s.render(w, r, "keys.html", status, pageData{
Title: "Zugänge – " + project.Name,
Bereich: "zugaenge",
Flash: hinweis,
Error: fehler,
Data: daten,
})
}
// ── Audit ────────────────────────────────────────────────────────────────────
// auditData sind die Daten der Protokollansicht.
type auditData struct {
Events []store.AuditEvent
ProjectNames map[string]string
KeyNames map[string]string
Global bool
}
// handleProjectAudit zeigt das Protokoll eines Projekts.
func (s *Server) handleProjectAudit(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
s.renderAudit(w, r, store.AuditFilter{ProjectID: project.ID}, false, "Protokoll – "+project.Name)
}
// handleGlobalAudit zeigt das Protokoll aller Projekte — nur für den Plattform-Admin.
func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) {
s.renderAudit(w, r, store.AuditFilter{}, true, "Protokoll")
}
// 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) {
ctx := r.Context()
ereignisse, err := s.store.ListAudit(ctx, filter)
if err != nil {
s.serverError(w, "Protokoll konnte nicht geladen werden", err)
return
}
// IDs in lesbare Namen übersetzen — in der Tabelle sollen keine rohen
// Kennungen stehen.
projektNamen, err := s.store.ProjectNames(ctx, s.workspaceID)
if err != nil {
s.serverError(w, "Projektnamen konnten nicht geladen werden", err)
return
}
projektIDs := make([]string, 0, len(projektNamen))
for id := range projektNamen {
projektIDs = append(projektIDs, id)
}
keyNamen, err := s.store.KeyNames(ctx, projektIDs)
if err != nil {
s.serverError(w, "Key-Namen konnten nicht geladen werden", err)
return
}
s.render(w, r, "audit.html", http.StatusOK, pageData{
Title: titel,
Bereich: "protokoll",
Data: auditData{
Events: ereignisse,
ProjectNames: projektNamen,
KeyNames: keyNamen,
Global: global,
},
})
}
// ── Export ───────────────────────────────────────────────────────────────────
// handleExport schreibt alle Projekte als Markdown-Dateien auf die Platte.
//
// 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.
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)
if err != nil {
s.serverError(w, "Export fehlgeschlagen", err)
return
}
meldung := fmt.Sprintf("Export abgeschlossen: %d Dateien geschrieben", stats.Written)
if stats.Removed > 0 {
meldung += fmt.Sprintf(", %d verwaiste entfernt", stats.Removed)
}
http.Redirect(w, r, "/admin/projects?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
}

View file

@ -0,0 +1,122 @@
package web
import (
"fmt"
"net/http"
"strconv"
"time"
"munin/internal/auth"
"munin/internal/store"
)
// handleHome leitet nach der Anmeldung an die passende Startseite weiter.
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
sess, _ := SessionFrom(r)
if sess.IsPlatformAdmin {
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/p/"+sess.Project.Slug+"/", http.StatusSeeOther)
}
// handleLoginForm zeigt das Anmeldeformular.
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "login.html", http.StatusOK, pageData{Title: "Anmelden"})
}
// handleLoginSubmit prüft den eingegebenen Key und setzt den Session-Cookie.
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
ip := auth.ClientIP(r)
key := r.FormValue("key")
subject, ok := s.resolveLoginKey(r, key)
if !ok {
// Erst nach einem Fehlversuch die Sperre prüfen und hochzählen — wer den
// richtigen Key hat, kommt immer durch (siehe Kommentar an APIAuth).
if allowed, retryAfter := s.limiter.Allow(ip); !allowed {
sekunden := int(retryAfter.Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(sekunden))
http.Error(w, fmt.Sprintf("Zu viele Anmeldeversuche. Bitte in %d Sekunden erneut versuchen.", sekunden),
http.StatusTooManyRequests)
return
}
s.limiter.RecordFailure(ip)
s.store.LogAudit(r.Context(), store.AuditEvent{
Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceWeb, IP: ip,
})
s.render(w, r, "login.html", http.StatusUnauthorized, pageData{
Title: "Anmelden",
Error: "Ungültiger Key.",
})
return
}
s.limiter.Reset(ip)
s.setSessionCookie(w, r, subject)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück.
func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
if key == "" {
return "", false
}
// Der Plattform-Admin meldet sich mit dem ADMIN_TOKEN an. Verglichen werden
// 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),
})
return auth.SubjectPlatformAdmin, true
}
if !auth.LooksLikeKey(key) {
return "", false
}
resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key))
if err != nil {
return "", false
}
s.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
}
// handleLogout löscht den Session-Cookie.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: auth.SessionCookieName,
Value: "",
Expires: time.Unix(0, 0),
MaxAge: -1,
}
// Ein Lösch-Cookie muss dieselben Merkmale tragen wie das Original, sonst
// behandelt der Browser es als ein anderes Cookie und das alte bleibt liegen.
s.cookies.Apply(cookie, r)
http.SetCookie(w, cookie)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// setSessionCookie signiert das Subject und setzt es als Cookie.
func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string) {
cookie := &http.Cookie{
Name: auth.SessionCookieName,
Value: s.signer.SignSession(subject),
Expires: time.Now().Add(auth.SessionTTL),
}
s.cookies.Apply(cookie, r)
http.SetCookie(w, cookie)
}

View file

@ -0,0 +1,286 @@
package web
import (
"errors"
"html/template"
"net/http"
"strconv"
"strings"
"munin/internal/entry"
"munin/internal/store"
)
// entryListData sind die Daten der Übersichtsseite eines Projekts.
type entryListData struct {
Entries []entryListItem
Tags []store.TagCount
Query string
Tag string
Total int
}
// entryListItem ist eine Zeile der Übersicht bzw. ein Suchtreffer.
type entryListItem struct {
Entry store.Entry
Snippet template.HTML // bei einer Suche: Textstelle mit hervorgehobenem Treffer
}
// handleEntryList zeigt die Einträge eines Projekts — gefiltert, gesucht oder
// einfach nach letzter Änderung.
func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
ctx := r.Context()
query := strings.TrimSpace(r.URL.Query().Get("q"))
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
daten := entryListData{Query: query, Tag: tag}
var err error
if query != "" {
daten.Entries, err = s.searchEntries(r, project, query)
} else {
daten.Entries, err = s.listEntries(r, project, tag)
}
if err != nil {
s.serverError(w, "Einträge konnten nicht geladen werden", err)
return
}
if daten.Tags, err = s.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 {
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
return
}
s.render(w, r, "entries.html", http.StatusOK, pageData{
Title: project.Name,
Bereich: "eintraege",
AktivesTag: tag,
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) {
ctx := r.Context()
idx, err := s.search.For(ctx, project.ID)
if err != nil {
return nil, err
}
treffer := idx.Search(query, 30)
items := make([]entryListItem, 0, len(treffer))
for _, t := range treffer {
e, err := s.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.
continue
}
if err != nil {
return nil, err
}
items = append(items, entryListItem{
Entry: e,
Snippet: template.HTML(entry.HighlightSnippet(e.Content, query)),
})
}
return items, nil
}
// 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})
if err != nil {
return nil, err
}
items := make([]entryListItem, 0, len(eintraege))
for _, e := range eintraege {
items = append(items, entryListItem{
Entry: e,
Snippet: template.HTML(template.HTMLEscapeString(entry.Snippet(e.Content, ""))),
})
}
return items, nil
}
// entryViewData sind die Daten der Detailansicht.
type entryViewData struct {
Entry store.Entry
HTML template.HTML
Backlinks []store.Backlink
}
// handleEntryView zeigt einen einzelnen Eintrag.
func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
slug := r.PathValue("slug")
e, err := s.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
}
// Die [[slug]]-Verweise zeigen auf die Detailansicht desselben Projekts.
inhalt, err := entry.RenderHTML(e.Content, "/p/"+project.Slug+"/e/")
if err != nil {
s.serverError(w, "Inhalt konnte nicht dargestellt werden", err)
return
}
backlinks, err := s.store.Backlinks(r.Context(), project.ID, slug)
if err != nil {
s.serverError(w, "Rückverweise konnten nicht geladen werden", err)
return
}
s.render(w, r, "entry.html", http.StatusOK, pageData{
Title: e.Title,
Bereich: "eintraege",
Data: entryViewData{Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks},
})
}
// entryEditData sind die Daten des Bearbeitungsformulars.
type entryEditData struct {
Entry store.Entry
TagsIn string // Schlagworte als kommagetrennte Zeile fürs Eingabefeld
IsNew bool
}
// handleEntryNew zeigt das Formular für einen neuen Eintrag.
func (s *Server) handleEntryNew(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "edit.html", http.StatusOK, pageData{
Title: "Neuer Eintrag",
Bereich: "eintraege",
Data: entryEditData{IsNew: true},
})
}
// handleEntryEdit zeigt das Formular für einen bestehenden Eintrag.
func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
e, err := s.store.EntryBySlug(r.Context(), project.ID, r.PathValue("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.render(w, r, "edit.html", http.StatusOK, pageData{
Title: "Bearbeiten: " + e.Title,
Bereich: "eintraege",
Data: entryEditData{Entry: e, TagsIn: strings.Join(e.Tags, ", ")},
})
}
// handleEntrySave speichert einen Eintrag aus dem Formular.
func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
titel := strings.TrimSpace(r.FormValue("title"))
inhalt := r.FormValue("content")
slug := strings.TrimSpace(r.FormValue("slug"))
// Kein Bezeichner angegeben (neuer Eintrag) → aus dem Titel ableiten.
if slug == "" {
slug = store.Slugify(titel)
}
// Die im Formular mitgeführte Version ermöglicht das optimistische Sperren:
// Hat jemand anderes den Eintrag zwischenzeitlich geändert, gibt es einen
// Hinweis statt eines stillen Überschreibens.
version, _ := strconv.Atoi(r.FormValue("version"))
geschrieben, err := s.store.PutEntry(r.Context(), store.EntryWrite{
ProjectID: project.ID,
Slug: slug,
Title: titel,
Content: inhalt,
Tags: splitTags(r.FormValue("tags")),
Source: store.SourceHuman,
Author: sess.ActorName(),
Curated: r.FormValue("curated") != "",
ExpectedVersion: version,
})
if err != nil {
// Fachliche Fehler (fehlender Titel, ungültiger Bezeichner, Konflikt)
// führen zurück ins Formular statt auf eine Fehlerseite — der eingegebene
// Text darf dabei nicht verloren gehen.
s.render(w, r, "edit.html", http.StatusBadRequest, pageData{
Title: "Bearbeiten",
Bereich: "eintraege",
Error: err.Error(),
Data: entryEditData{
Entry: store.Entry{
Slug: slug, Title: titel, Content: inhalt,
Version: version, Curated: r.FormValue("curated") != "",
},
TagsIn: r.FormValue("tags"),
IsNew: version == 0,
},
})
return
}
s.search.Update(project.ID, geschrieben)
s.store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb,
})
http.Redirect(w, r, "/p/"+project.Slug+"/e/"+slug, http.StatusSeeOther)
}
// handleEntryDelete löscht einen Eintrag.
func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) {
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) {
s.serverError(w, "Eintrag konnte nicht gelöscht werden", err)
return
}
s.search.Delete(project.ID, slug)
s.store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb,
})
http.Redirect(w, r, "/p/"+project.Slug+"/", http.StatusSeeOther)
}
// splitTags zerlegt die Schlagwort-Eingabe an Kommas.
// Das Vereinheitlichen (klein, ohne Dubletten) übernimmt danach der Store.
func splitTags(eingabe string) []string {
if strings.TrimSpace(eingabe) == "" {
return nil
}
teile := strings.Split(eingabe, ",")
for i := range teile {
teile[i] = strings.TrimSpace(teile[i])
}
return teile
}

430
internal/web/middleware.go Normal file
View file

@ -0,0 +1,430 @@
package web
import (
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"munin/internal/auth"
"munin/internal/store"
)
// requireSession prüft den Session-Cookie und legt die Sitzung im Context ab.
//
// Reihenfolge der Prüfungen:
//
// 1. Signatur und Ablaufdatum des Cookies
// 2. Gibt es den zugehörigen Key noch, und ist er nicht zurückgezogen?
// 3. Bei schreibenden Requests: stimmt das CSRF-Token?
//
// Schritt 2 ist der Grund, warum ein zurückgezogener Key sofort wirkt: Die
// Sitzung trägt nur die Key-ID, die Gültigkeit wird bei jedem Request frisch in
// der Datenbank nachgesehen.
func (s *Server) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil {
s.redirectToLogin(w, r)
return
}
subject, ok := s.signer.VerifySession(cookie.Value)
if !ok {
s.redirectToLogin(w, r)
return
}
sess, ok := s.sessionFromSubject(r, subject)
if !ok {
s.redirectToLogin(w, r)
return
}
if !s.checkCSRF(w, r, subject) {
return
}
next.ServeHTTP(w, r.WithContext(withSession(r.Context(), sess)))
})
}
// sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung.
func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) {
if subject == auth.SubjectPlatformAdmin {
return Session{
Subject: subject,
IsPlatformAdmin: true,
Role: auth.RoleAdmin,
}, true
}
keyID, ok := auth.KeyIDFromSubject(subject)
if !ok {
return Session{}, false
}
resolved, err := s.store.APIKeyByID(r.Context(), keyID)
if err != nil || resolved.Revoked() {
return Session{}, false
}
project, err := s.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),
}, true
}
// checkCSRF prüft bei schreibenden Requests das CSRF-Token.
//
// GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts
// verändern und brauchen kein Token. Alles andere muss eines mitschicken.
func (s *Server) checkCSRF(w http.ResponseWriter, r *http.Request, subject string) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
if !s.signer.ValidCSRFToken(auth.CSRFTokenFromRequest(r), subject) {
http.Error(w, "Ungültiges oder fehlendes CSRF-Token — bitte die Seite neu laden und erneut versuchen.",
http.StatusForbidden)
return false
}
return true
}
// redirectToLogin schickt den Browser zum Anmeldeformular.
//
// Bei einem schreibenden Request wäre eine Weiterleitung irreführend: Der Browser
// würde das POST als GET wiederholen und der Nutzer stünde ohne Erklärung vor dem
// Formular. Deshalb dort ein klarer Statuscode.
func (s *Server) redirectToLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead:
http.Redirect(w, r, "/login", http.StatusSeeOther)
default:
http.Error(w, "Sitzung abgelaufen — bitte neu anmelden.", http.StatusUnauthorized)
}
}
// requireProject löst den Projekt-Bezeichner aus der URL auf und prüft den Zugriff.
//
// Alle Projektseiten liegen unter /p/{project}/… — dieser eine Ort entscheidet
// damit für sämtliche Projektrouten, ob jemand hineindarf.
func (s *Server) requireProject(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok {
s.redirectToLogin(w, r)
return
}
slug := r.PathValue("project")
project, err := s.store.ProjectBySlug(r.Context(), s.workspaceID, slug)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Projekt konnte nicht geladen werden", err)
return
}
// Ein fremdes Projekt wird als "nicht vorhanden" beantwortet, nicht als
// "verboten". Sonst ließe sich über die Statuscodes herausfinden, welche
// Projekte es sonst noch gibt.
if !sess.CanAccessProject(project.ID) {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r.WithContext(withProject(r.Context(), project)))
})
}
// 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) {
sess, ok := SessionFrom(r)
if !ok || !sess.IsPlatformAdmin {
http.Error(w, "Nur für den Plattform-Admin verfügbar", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// requireWrite lässt nur Sitzungen durch, die Einträge verändern dürfen.
func (s *Server) requireWrite(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok || !sess.CanWrite() {
http.Error(w, "Dieser Zugang darf nur lesen.", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen.
func (s *Server) requireKeyManagement(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok || !sess.CanManageKeys() {
http.Error(w, "Für die Key-Verwaltung wird die Rolle \"admin\" benötigt.", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// ── Allgemeine Schutzschichten ───────────────────────────────────────────────
// SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers.
//
// Diese Header sind Anweisungen an den Browser: Der Server kann damit Angriffe
// entschärfen, die er selbst gar nicht sieht, weil sie erst im Browser des
// Besuchers stattfinden.
func SecurityHeaders(next http.Handler, enableHSTS bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Die Content-Security-Policy ist der wichtigste dieser Header: Sie legt
// fest, woher der Browser überhaupt etwas laden und ausführen darf.
//
// Für uns ist sie die zweite Verteidigungslinie gegen eingeschleustes
// JavaScript. Die erste ist, dass goldmark kein Roh-HTML durchlässt
// (siehe internal/entry). Sollte das je gelockert werden, verhindert
// diese Regel trotzdem die Ausführung.
//
// default-src 'self' alles nur von der eigenen Adresse
// img-src … data: https: Bilder dürfen auch extern eingebunden werden
// object-src 'none' keine Plugin-Einbettungen
// base-uri 'none' verbietet <base href>, das sonst alle
// relativen Links der Seite umbiegen könnte
// form-action 'self' Formulare können nur an uns selbst senden
// frame-ancestors 'none' die Seite darf in keinem fremden Rahmen
// stecken (Schutz vor Clickjacking)
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data: https:; style-src 'self'; "+
"script-src 'self'; object-src 'none'; base-uri 'none'; "+
"form-action 'self'; frame-ancestors 'none'")
// Verbietet dem Browser, den Inhaltstyp zu erraten. Ohne diesen Header
// könnte eine als Text ausgelieferte Datei, die zufällig wie HTML
// aussieht, als HTML ausgeführt werden.
h.Set("X-Content-Type-Options", "nosniff")
// Schutz vor Clickjacking für Browser, die frame-ancestors noch nicht
// kennen. Doppelt hält besser und kostet nichts.
h.Set("X-Frame-Options", "DENY")
// Beim Klick auf einen externen Link soll die vollständige interne URL
// nicht als Herkunft mitgehen — Projekt- und Eintragsnamen können
// verraten, woran gearbeitet wird.
h.Set("Referrer-Policy", "same-origin")
// Der Browser soll gar nicht erst nach Kamera, Mikrofon und Standort fragen.
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// HSTS weist den Browser an, diese Adresse künftig nur noch über HTTPS
// aufzurufen. Der Header darf nur gesetzt werden, wenn HTTPS wirklich
// funktioniert: Ein Browser, der ihn einmal gesehen hat, weigert sich für
// die angegebene Dauer, die Seite über http zu laden — auf einer Adresse
// ohne Zertifikat sperrt man sich damit selbst aus.
if enableHSTS {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
// LimitBody begrenzt, wie viele Bytes ein Request-Body haben darf.
//
// Ohne Begrenzung kann ein einzelner Aufruf beliebig viel Speicher belegen. Das
// muss nicht einmal böswillig sein — ein Agent in einer Schleife oder eine
// versehentlich falsche Datei reichen aus.
//
// http.MaxBytesReader ist genau dafür gebaut: Er ersetzt den Body durch eine
// Variante, die nach maxBytes einen Fehler liefert statt weiterzulesen.
func LimitBody(next http.Handler, maxBytes int64) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
// ── API-Zugang (MCP) ─────────────────────────────────────────────────────────
// APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im
// Context ab.
//
// Wichtig ist die Reihenfolge: erst den Key prüfen, dann die IP-Sperre.
// Andersherum wäre naheliegender, hätte aber einen unangenehmen Nebeneffekt —
// Deploy-Skript, MCP-Client und Browser eines Büros teilen sich nach außen oft
// eine Adresse, und ein einziger falsch konfigurierter Client würde alle anderen
// mitsperren. Gegen das Durchprobieren hilft die Reihenfolge trotzdem: Ein
// Angreifer hat per Definition keinen gültigen Key und landet im unteren Zweig.
func (s *Server) APIAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := auth.ClientIP(r)
key := ""
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
key = strings.TrimPrefix(header, "Bearer ")
}
if auth.LooksLikeKey(key) {
resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key))
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)
sess := Session{
Subject: auth.SubjectKeyPrefix + resolved.Key.ID,
Key: resolved.Key,
Project: resolved.Project,
Role: auth.Role(resolved.Key.Role),
}
ctx := withSession(r.Context(), sess)
ctx = withProject(ctx, resolved.Project)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
if allowed, retryAfter := s.limiter.Allow(ip); !allowed {
sekunden := int(retryAfter.Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(sekunden))
http.Error(w, fmt.Sprintf("Zu viele fehlgeschlagene Versuche. Bitte in %d Sekunden erneut versuchen.", sekunden),
http.StatusTooManyRequests)
return
}
s.limiter.RecordFailure(ip)
s.store.LogAudit(r.Context(), store.AuditEvent{
Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceMCP, IP: ip,
})
http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized)
})
}
// ── Zugriffsprotokoll ────────────────────────────────────────────────────────
// statusSchreiber merkt sich den Statuscode einer Antwort.
//
// http.ResponseWriter verrät ihn nicht — man kann ihn setzen, aber nicht lesen.
// Deshalb dieser dünne Mantel, der WriteHeader abfängt.
type statusSchreiber struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusSchreiber) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusSchreiber) Write(b []byte) (int, error) {
// Wer direkt schreibt, ohne WriteHeader aufzurufen, erzeugt implizit ein 200.
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
// Unwrap gibt den darunterliegenden ResponseWriter zurück.
//
// http.ResponseController nutzt diese Methode, um an Fähigkeiten des
// eingewickelten Schreibers heranzukommen. Der MCP-Transport geht diesen Weg für
// seine gestreamten Antworten — ohne Unwrap bliebe dort alles im Puffer hängen.
func (w *statusSchreiber) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// Flush reicht das Leeren des Puffers durch.
//
// Unwrap deckt nur Aufrufer ab, die http.ResponseController verwenden. Die
// ältere und weiter verbreitete Schreibweise ist eine direkte Typzusicherung:
//
// if f, ok := w.(http.Flusher); ok { f.Flush() }
//
// Die schlägt bei einem eingewickelten Schreiber fehl, wenn er die Methode nicht
// selbst anbietet — und zwar lautlos: Der Aufrufer nimmt an, dass Leeren eben
// nicht unterstützt wird, und schreibt weiter in den Puffer. Solche Fehler
// bemerkt man erst im Betrieb, deshalb hier ausdrücklich.
func (w *statusSchreiber) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// LogRequests schreibt zu jeder Anfrage eine Zeile ins Serverlog.
//
// # Warum das nötig ist
//
// Das Audit-Log hält fest, wer etwas geändert hat — es sagt aber nichts über
// Lesezugriffe, Fehlerantworten oder Antwortzeiten. Ohne Zugriffsprotokoll sieht
// man weder, dass ein Agent in einer Schleife hängt, noch dass eine Seite
// langsam geworden ist, noch dass jemand Pfade abklappert.
//
// # Was bewusst nicht protokolliert wird
//
// - Die Query-Zeichenkette. Dort stehen Suchbegriffe, und die sind Inhalt,
// kein Betriebsdatum. Der Pfad allein reicht, um zu sehen, was aufgerufen wurde.
// - Statische Dateien und der Healthcheck. Beides erzeugt nur Rauschen; der
// Healthcheck läuft alle 30 Sekunden.
func LogRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
start := time.Now()
schreiber := &statusSchreiber{ResponseWriter: w}
// Halter für den Namen des Zugangs mitgeben. Die Anmelde-Middleware
// weiter innen trägt ihn ein — siehe aktorHalter in context.go.
halter := &aktorHalter{}
r = r.WithContext(withAktorHalter(r.Context(), halter))
next.ServeHTTP(schreiber, r)
wer := halter.name
if wer == "" {
wer = "—" // nicht angemeldet oder abgewiesen
}
status := schreiber.status
if status == 0 {
status = http.StatusOK // Handler hat nichts geschrieben
}
log.Printf("%d %-6s %-44s %8s %s",
status, r.Method, r.URL.Path,
time.Since(start).Round(time.Microsecond), wer)
})
}

287
internal/web/server.go Normal file
View file

@ -0,0 +1,287 @@
package web
import (
"bytes"
"html/template"
"io/fs"
"log"
"net/http"
"munin/internal/auth"
"munin/internal/export"
"munin/internal/search"
"munin/internal/store"
)
// Server bündelt alles, was die Handler brauchen.
//
// Ein Struct statt globaler Variablen: Damit ist an jeder Methode sichtbar,
// 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
// exporter ist nil, wenn EXPORT_DIR nicht gesetzt ist — dann blendet die
// Oberfläche den Knopf aus, statt eine Funktion anzubieten, die nichts tut.
exporter *export.Exporter
// writeBudget deckelt die schreibenden MCP-Aufrufe je Projekt. Die
// Weboberfläche setzt es nicht durch — sie zeigt nur an, wie viel noch frei
// ist, und erlaubt dem Plattform-Admin, die Obergrenze zu ändern.
writeBudget *auth.WriteBudget
// adminTokenHash ist der Hash des ADMIN_TOKEN. Verglichen wird der Hash,
// nicht der Klartext — dann liegt das Geheimnis nicht dauerhaft im Speicher
// herum und der Vergleich läuft über dieselbe Funktion wie bei den API-Keys.
adminTokenHash string
}
// Config sammelt die Angaben zum Bauen eines Servers.
type Config struct {
Store *store.Store
Search *search.Registry
Signer *auth.Signer
Cookies auth.CookiePolicy
Limiter *auth.RateLimiter
Templates fs.FS
WorkspaceID string
AdminToken string
Exporter *export.Exporter
WriteBudget *auth.WriteBudget
}
// NewServer lädt die Templates und stellt den Server zusammen.
func NewServer(cfg Config) (*Server, error) {
// FuncMap muss vor dem Parsen registriert sein — die Funktionen werden beim
// Übersetzen der Templates aufgelöst, nicht erst beim Ausführen.
tmpl, err := template.New("").Funcs(templateFuncs()).ParseFS(cfg.Templates, "templates/*.html")
if err != nil {
return nil, err
}
return &Server{
store: cfg.Store,
search: cfg.Search,
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),
}, nil
}
// Routes baut den Router der Weboberfläche.
//
// Die Muster nutzen die seit Go 1.22 eingebaute Methoden- und Platzhalter-Syntax
// ("GET /p/{project}/"): Damit übernimmt der Router die Methodenprüfung und das
// Zerlegen des Pfads, was sonst in jedem Handler von Hand passieren müsste.
func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
// Statische Dateien und Healthcheck brauchen keine Anmeldung.
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Der Healthcheck gibt bewusst nichts über den Zustand der Projekte preis —
// eine Statusseite wäre eine unnötige Auskunftsstelle nach außen.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("ok\n"))
})
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
// geschuetzt verpackt einen Handler mit der Sitzungsprüfung.
geschuetzt := func(h http.HandlerFunc) http.Handler {
return s.requireSession(h)
}
// imProjekt ergänzt die Auflösung des Projekts aus der URL.
imProjekt := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireProject(h))
}
// mitSchreibrecht ergänzt die Rollenprüfung fürs Schreiben.
mitSchreibrecht := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireProject(s.requireWrite(h)))
}
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)))
// 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)))
// Projektseiten.
mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList))
mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew))
mux.Handle("GET /p/{project}/e/{slug}", imProjekt(s.handleEntryView))
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("GET /p/{project}/audit", imProjekt(s.handleProjectAudit))
// Key-Verwaltung.
mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList)))))
mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate)))))
mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke)))))
}
// requirePlatformAdminFunc ist die Kurzform von requirePlatformAdmin für einen
// HandlerFunc — spart bei den Routen oben eine Verschachtelungsebene.
func (s *Server) requirePlatformAdminFunc(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.requirePlatformAdmin(h).ServeHTTP(w, r)
}
}
// ── Rendern ──────────────────────────────────────────────────────────────────
// pageData ist der gemeinsame Rahmen jeder Seite. Die einzelnen Handler
// ergänzen ihre eigenen Daten im Feld Data.
type pageData struct {
Title string
Session Session
Project store.Project
CSRFToken string
Flash string // Erfolgsmeldung
Error string // Fehlermeldung
// ExportEnabled steuert, ob die Oberfläche den Export-Knopf zeigt.
ExportEnabled bool
// Bereich markiert den aktiven Punkt in der Seitenleiste:
// "eintraege", "zugaenge", "protokoll" oder "projekte".
Bereich string
// Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit
// nicht jeder Handler daran denken muss.
Nav navData
// AktivesTag markiert das gewählte Schlagwort in der Seitenleiste.
AktivesTag string
Data any
}
// navData sind die Inhalte der Seitenleiste.
type navData struct {
// Projects ist nur beim Plattform-Admin gefüllt — ein Projekt-Key hat
// ohnehin nur Zugriff auf sein eigenes Projekt.
Projects []store.Project
// Tags sind die Schlagworte des aktuellen Projekts, häufigste zuerst.
Tags []store.TagCount
}
// render führt ein Template aus und schickt das Ergebnis an den Browser.
//
// Gerendert wird zuerst in einen Puffer im Arbeitsspeicher. Erst wenn das
// vollständig geklappt hat, geht die Antwort raus. Andernfalls hätte der Browser
// bei einem Template-Fehler bereits HTTP 200 und eine halb gerenderte Seite
// bekommen — und niemand würde den Fehler bemerken.
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, status int, data pageData) {
if sess, ok := SessionFrom(r); ok {
data.Session = sess
data.CSRFToken = s.signer.CSRFToken(sess.Subject)
}
if project, ok := ProjectFrom(r); ok {
data.Project = project
}
data.ExportEnabled = s.exporter != nil
data.Nav = s.buildNav(r, data.Session, data.Project)
var puffer bytes.Buffer
if err := s.templates.ExecuteTemplate(&puffer, name, data); err != nil {
log.Printf("Template %q konnte nicht gerendert werden: %v", name, err)
http.Error(w, "Seite konnte nicht dargestellt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if _, err := w.Write(puffer.Bytes()); err != nil {
// Der Browser hat die Verbindung getrennt — nichts, was wir noch retten könnten.
log.Printf("Antwort konnte nicht gesendet werden: %v", err)
}
}
// buildNav lädt die Inhalte der Seitenleiste.
//
// Das kostet je Seitenaufruf ein bis zwei kleine Abfragen. Die Alternative wäre,
// dass jeder Handler die Navigation selbst mitliefert — dann wird sie irgendwo
// vergessen, und die Seitenleiste ist auf einer Unterseite plötzlich leer.
//
// Fehler brechen die Seite nicht ab: Eine unvollständige Navigation ist
// ärgerlich, aber kein Grund, den eigentlichen Inhalt nicht anzuzeigen.
func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) navData {
var nav navData
if sess.IsPlatformAdmin {
projekte, err := s.store.ListProjects(r.Context(), s.workspaceID)
if err != nil {
log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err)
} else {
nav.Projects = projekte
}
}
if project.ID != "" {
tags, err := s.store.ListTags(r.Context(), project.ID)
if err != nil {
log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err)
} else {
nav.Tags = tags
}
}
return nav
}
// serverError protokolliert einen unerwarteten Fehler und meldet dem Aufrufer
// eine allgemeine Störung.
//
// Die Trennung ist Absicht: Der technische Grund gehört ins Log, wo ihn nur sieht,
// wer ohnehin Zugriff auf den Server hat. Nach außen geht nur, dass etwas
// schiefging — Fehlermeldungen aus der Tiefe verraten oft Pfade, Tabellennamen
// oder Versionsstände.
func (s *Server) serverError(w http.ResponseWriter, kontext string, err error) {
log.Printf("%s: %v", kontext, err)
http.Error(w, "Es ist ein Fehler aufgetreten. Details stehen im Server-Log.", http.StatusInternalServerError)
}
// templateFuncs stellt den Templates ein paar Hilfsfunktionen bereit.
func templateFuncs() template.FuncMap {
return template.FuncMap{
// datum formatiert einen Zeitpunkt für die Anzeige.
"datum": func(t interface{ Format(string) string }) string {
return t.Format("02.01.2006 15:04")
},
// join fügt Schlagworte zu einer Zeile zusammen.
"join": func(teile []string, trenner string) string {
out := ""
for i, t := range teile {
if i > 0 {
out += trenner
}
out += t
}
return out
},
}
}

963
internal/web/web_test.go Normal file
View file

@ -0,0 +1,963 @@
package web
import (
"bytes"
"context"
"io/fs"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"munin/internal/auth"
"munin/internal/search"
"munin/internal/store"
)
const testSecret = "ein-test-secret-das-lang-genug-ist-fuer-tests"
const testAdminToken = "test-admin-token-lang-genug"
// testServer baut einen vollständigen Server auf einer frischen Datenbank.
func testServer(t *testing.T) (*Server, *store.Store, store.Project) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Datenbank: %v", err)
}
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, "testprojekt", "Testprojekt", "")
if err != nil {
t.Fatal(err)
}
// os.DirFS("../..") zeigt vom Package-Verzeichnis auf die Projektwurzel,
// wo templates/ liegt.
srv, err := NewServer(Config{
Store: db,
Search: search.NewRegistry(db),
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),
})
if err != nil {
t.Fatalf("Server: %v", err)
}
return srv, db, projekt
}
// templatesForTest liefert das Template-Verzeichnis der Projektwurzel.
func templatesForTest(t *testing.T) fs.FS {
t.Helper()
return os.DirFS("../..")
}
// testHandler baut den vollständigen Router — so wie main.go es tut.
func testHandler(t *testing.T, srv *Server) http.Handler {
t.Helper()
mux := http.NewServeMux()
srv.Routes(mux, os.DirFS("../../static"))
return mux
}
// 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 {
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 {
t.Fatal(err)
}
return klartext
}
// 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()
formular := url.Values{"key": {key}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
return c
}
}
t.Fatalf("Anmeldung lieferte keinen Cookie (Status %d)", w.Code)
return nil
}
// ── Anmeldung ────────────────────────────────────────────────────────────────
func TestLoginRequiredForEverything(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
geschuetzt := []string{"/", "/admin/projects", "/p/testprojekt/", "/p/testprojekt/keys"}
for _, pfad := range geschuetzt {
t.Run(pfad, func(t *testing.T) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, pfad, nil))
if w.Code != http.StatusSeeOther {
t.Errorf("Status = %d, erwartet %d (Weiterleitung zur Anmeldung)", w.Code, http.StatusSeeOther)
}
})
}
}
func TestLoginWithProjectKey(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleWrite)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200", w.Code)
}
}
func TestLoginRejectsBadKey(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
formular := url.Values{"key": {"wk_" + strings.Repeat("0", 64)}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusUnauthorized)
}
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
t.Error("trotz falschem Key wurde eine Sitzung ausgestellt")
}
}
}
// TestRevokedKeyEndsSession prüft, dass ein zurückgezogener Key sofort wirkt —
// die Sitzung darf ihn nicht überleben.
func TestRevokedKeyEndsSession(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleWrite)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(ctx, projekt.ID)
if err := db.RevokeAPIKey(ctx, keys[0].ID); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("Status = %d, erwartet Weiterleitung zur Anmeldung — die Sitzung galt weiter", w.Code)
}
}
// ── CSRF ─────────────────────────────────────────────────────────────────────
// TestCSRFBlocksForgedPost bildet den Angriff nach: Eine fremde Seite schickt ein
// POST, der Browser hängt den Session-Cookie automatisch an — nur das Token kann
// sie nicht liefern.
func TestCSRFBlocksForgedPost(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, db, projekt.ID, "test", auth.RoleAdmin)
cookie := anmelden(t, h, key)
formular := url.Values{"name": {"geklaut"}, "role": {"admin"}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/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.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
if len(keys) != 1 {
t.Errorf("%d Keys — es wurde trotz fehlendem Token einer angelegt", len(keys))
}
}
func TestCSRFAllowsRealForm(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, 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
token := auth.NewSigner([]byte(testSecret)).CSRFToken(subject)
formular := url.Values{"name": {"zweiter"}, "role": {"read"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/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.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
neue, _ := db.ListAPIKeys(context.Background(), projekt.ID)
if len(neue) != 2 {
t.Errorf("%d Keys, erwartet 2", len(neue))
}
// Der Klartext des neuen Keys muss genau einmal in der Antwort stehen.
if !strings.Contains(w.Body.String(), auth.KeyPrefix) {
t.Error("der neue Key wurde nicht angezeigt")
}
}
// ── Abschottung ──────────────────────────────────────────────────────────────
// TestProjectIsolation ist der wichtigste Test des Web-Teils: Ein Key kommt
// ausschließlich an sein eigenes Projekt.
func TestProjectIsolation(t *testing.T) {
srv, db, projektA := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
ws, _ := db.EnsureWorkspace(ctx, "Test")
projektB, err := db.CreateProject(ctx, ws.ID, "fremd", "Fremdes Projekt", "")
if err != nil {
t.Fatal(err)
}
// Ein Eintrag, den Projekt A auf keinen Fall sehen darf.
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projektB.ID, Slug: "geheim", Title: "Geheim",
Content: "Vertrauliches aus Projekt B", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
keyA := erzeugeKey(t, db, projektA.ID, "a", auth.RoleAdmin)
cookie := anmelden(t, h, keyA)
// Jede Route des fremden Projekts muss als "nicht vorhanden" antworten —
// nicht als "verboten". Sonst ließe sich über die Statuscodes herausfinden,
// welche Projekte es sonst noch gibt.
fremdeRouten := []string{
"/p/fremd/",
"/p/fremd/e/geheim",
"/p/fremd/keys",
"/p/fremd/audit",
}
for _, pfad := range fremdeRouten {
t.Run(pfad, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, pfad, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404", w.Code)
}
if strings.Contains(w.Body.String(), "Vertrauliches") {
t.Error("Inhalt des fremden Projekts war sichtbar")
}
})
}
}
// 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)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d — ein Projekt-Admin kam an die Projektverwaltung", w.Code, http.StatusForbidden)
}
}
func TestPlatformAdminCanAccessAnyProject(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, testAdminToken)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200", w.Code)
}
}
// ── Rollen ───────────────────────────────────────────────────────────────────
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann.
func TestReadRoleCannotWrite(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, db, projekt.ID, "nur-lesen", auth.RoleRead)
cookie := anmelden(t, h, key)
schreibrouten := []struct{ methode, pfad string }{
{http.MethodGet, "/p/testprojekt/new"},
{http.MethodPost, "/p/testprojekt/save"},
}
for _, tc := range schreibrouten {
t.Run(tc.pfad, func(t *testing.T) {
r := httptest.NewRequest(tc.methode, tc.pfad, strings.NewReader(""))
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.Errorf("Status = %d — der Nur-Lese-Zugang kam durch", w.Code)
}
})
}
}
// TestWriteRoleCannotManageKeys prüft die Abgrenzung zwischen write und admin.
func TestWriteRoleCannotManageKeys(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, db, projekt.ID, "schreiber", auth.RoleWrite)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil)
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)
}
}
// ── Sicherheits-Header und Limits ────────────────────────────────────────────
func TestSecurityHeaders(t *testing.T) {
h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), false)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
erwartet := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "same-origin",
}
for name, wert := range erwartet {
if got := w.Header().Get(name); got != wert {
t.Errorf("%s = %q, erwartet %q", name, got, wert)
}
}
csp := w.Header().Get("Content-Security-Policy")
for _, teil := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} {
if !strings.Contains(csp, teil) {
t.Errorf("CSP enthält %q nicht", teil)
}
}
// Ohne den Schalter darf HSTS nicht gesetzt sein — sonst sperrt man sich auf
// einer Adresse ohne Zertifikat selbst aus.
if w.Header().Get("Strict-Transport-Security") != "" {
t.Error("HSTS wurde gesetzt, obwohl abgeschaltet")
}
}
func TestLimitBody(t *testing.T) {
var leseFehler error
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 512)
for {
if _, err := r.Body.Read(buf); err != nil {
if err.Error() != "EOF" {
leseFehler = err
}
return
}
}
})
h := LimitBody(inner, 100)
h.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 5000))))
if leseFehler == nil {
t.Error("ein zu großer Body wurde ohne Fehler durchgelassen")
}
}
// ── API-Zugang ───────────────────────────────────────────────────────────────
func TestAPIAuthRejectsBadKey(t *testing.T) {
srv, _, _ := testServer(t)
var erreicht bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true }))
faelle := map[string]string{
"ohne Header": "",
"falsches Schema": "Basic abc",
"unbekannter Key": "Bearer wk_" + strings.Repeat("0", 64),
"kaputte Form": "Bearer nonsens",
}
for name, header := range faelle {
t.Run(name, func(t *testing.T) {
erreicht = false
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
if header != "" {
r.Header.Set("Authorization", header)
}
r.RemoteAddr = "203.0.113.1:1234"
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if erreicht {
t.Error("der Request kam durch")
}
if w.Code != http.StatusUnauthorized && w.Code != http.StatusTooManyRequests {
t.Errorf("Status = %d, erwartet 401", w.Code)
}
})
}
}
func TestAPIAuthAcceptsValidKey(t *testing.T) {
srv, db, projekt := testServer(t)
key := erzeugeKey(t, db, projekt.ID, "agent", auth.RoleWrite)
var gesehen store.Project
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gesehen, _ = ProjectFrom(r)
}))
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer "+key)
h.ServeHTTP(httptest.NewRecorder(), r)
if gesehen.ID != projekt.ID {
t.Error("das Projekt wurde nicht im Context abgelegt")
}
}
// TestAPIAuthValidKeyBypassesRateLimit prüft, dass die IP-Sperre nur
// Fehlversuche trifft — sonst würde ein falsch konfigurierter Client alle
// anderen hinter derselben Adresse mitsperren.
func TestAPIAuthValidKeyBypassesRateLimit(t *testing.T) {
srv, db, projekt := testServer(t)
key := erzeugeKey(t, db, projekt.ID, "agent", auth.RoleWrite)
var erreicht bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true }))
// Erst die Adresse in die Sperre laufen lassen …
for i := 0; i < 15; i++ {
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer wk_"+strings.Repeat("0", 64))
r.RemoteAddr = "203.0.113.2:1234"
h.ServeHTTP(httptest.NewRecorder(), r)
}
// … und dann mit dem richtigen Key von derselben Adresse kommen.
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer "+key)
r.RemoteAddr = "203.0.113.2:1234"
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if !erreicht {
t.Errorf("gültiger Key wurde wegen der IP-Sperre abgewiesen (Status %d)", w.Code)
}
}
// ── Einträge über die Weboberfläche ──────────────────────────────────────────
func TestEntryCreateAndView(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, 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)
formular := url.Values{
"title": {"Deployment-Prozess"},
"content": {"Wir deployen über [[server-zugang]]."},
"tags": {"ops, deployment"},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/save", 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())
}
// Der Bezeichner wurde aus dem Titel abgeleitet.
e, err := db.EntryBySlug(context.Background(), projekt.ID, "deployment-prozess")
if err != nil {
t.Fatalf("Eintrag nicht gefunden: %v", err)
}
if e.Source != store.SourceHuman {
t.Errorf("Source = %q, erwartet %q", e.Source, store.SourceHuman)
}
if e.Author != "mensch" {
t.Errorf("Author = %q, erwartet den Key-Namen", e.Author)
}
if len(e.Tags) != 2 {
t.Errorf("Tags = %v, erwartet zwei", e.Tags)
}
// Die Detailansicht muss den Verweis als Link darstellen.
rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/deployment-prozess", nil)
rv.AddCookie(cookie)
wv := httptest.NewRecorder()
h.ServeHTTP(wv, rv)
if wv.Code != http.StatusOK {
t.Fatalf("Detailansicht: Status %d", wv.Code)
}
if !strings.Contains(wv.Body.String(), "/p/testprojekt/e/server-zugang") {
t.Error("der [[…]]-Verweis wurde nicht in einen Link umgewandelt")
}
}
// TestEntryContentIsEscaped ist ein Sicherheitstest: Eingeschleustes HTML darf
// nicht ausgeführt werden.
func TestEntryContentIsEscaped(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "boese", Title: "Test",
Content: "<script>alert(1)</script>\n\nText", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
key := erzeugeKey(t, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/boese", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if strings.Contains(w.Body.String(), "<script>alert(1)</script>") {
t.Error("eingeschleustes <script> wurde unverändert ausgeliefert")
}
}
// ── Zugriffsprotokoll ────────────────────────────────────────────────────────
// TestLogRequestsSchreibtZeile prüft, dass jede Anfrage protokolliert wird.
func TestLogRequestsSchreibtZeile(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
log.SetFlags(0)
defer func() {
log.SetOutput(os.Stderr)
log.SetFlags(log.LstdFlags)
}()
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/p/test/save", nil))
zeile := puffer.String()
for _, teil := range []string{"418", "POST", "/p/test/save"} {
if !strings.Contains(zeile, teil) {
t.Errorf("%q fehlt in der Protokollzeile: %s", teil, zeile)
}
}
}
// TestLogRequestsSchweigtBeiRauschen prüft, dass statische Dateien und der
// Healthcheck nicht protokolliert werden — der Healthcheck läuft alle 30
// Sekunden und würde das Log fluten.
func TestLogRequestsSchweigtBeiRauschen(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
defer log.SetOutput(os.Stderr)
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
for _, pfad := range []string{"/healthz", "/static/style.css"} {
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, pfad, nil))
}
if puffer.Len() > 0 {
t.Errorf("Rauschen wurde protokolliert: %s", puffer.String())
}
}
// TestLogRequestsProtokolliertKeineSuchbegriffe ist ein Datenschutztest:
// Die Query-Zeichenkette enthält Suchbegriffe, und die sind Inhalt, kein
// Betriebsdatum.
func TestLogRequestsProtokolliertKeineSuchbegriffe(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
defer log.SetOutput(os.Stderr)
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
h.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodGet, "/p/test/?q=gehaltsverhandlung", nil))
if strings.Contains(puffer.String(), "gehaltsverhandlung") {
t.Errorf("der Suchbegriff steht im Protokoll: %s", puffer.String())
}
}
// TestLogRequestsNenntDenZugang prüft, dass der verwendete Zugang mit
// protokolliert wird — sonst sieht man nicht, welcher Agent Last erzeugt.
func TestLogRequestsNenntDenZugang(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
defer log.SetOutput(os.Stderr)
// Reihenfolge wie im Betrieb: Der Protokollierer liegt AUSSEN, die Anmeldung
// geschieht weiter innen. Der Name muss trotzdem in der Zeile landen.
innen := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
withSession(r.Context(), Session{
Subject: "key:1",
Key: store.APIKey{Name: "claude-desktop"},
})
})
LogRequests(innen).ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodGet, "/p/test/", nil))
if !strings.Contains(puffer.String(), "claude-desktop") {
t.Errorf("der Zugang fehlt im Protokoll: %s", puffer.String())
}
}
// TestLogRequestsOhneAnmeldung prüft die Gegenrichtung: Eine abgewiesene
// Anfrage wird protokolliert, nur eben ohne Namen.
func TestLogRequestsOhneAnmeldung(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
defer log.SetOutput(os.Stderr)
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/mcp", nil))
zeile := puffer.String()
if !strings.Contains(zeile, "401") {
t.Errorf("abgewiesene Anfrage wurde nicht protokolliert: %s", zeile)
}
if !strings.Contains(zeile, "—") {
t.Errorf("fehlender Zugang wird nicht als \"—\" dargestellt: %s", zeile)
}
}
// TestStatusSchreiberReichtFlusherDurch prüft, dass der Mantel um den
// ResponseWriter dessen Fähigkeiten nicht verdeckt.
//
// Der MCP-Transport streamt Antworten und braucht dafür http.Flusher. Ginge der
// beim Einwickeln verloren, blieben MCP-Antworten im Puffer hängen — ein Fehler,
// den man erst im Betrieb bemerkt.
func TestStatusSchreiberReichtFlusherDurch(t *testing.T) {
var gefunden bool
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, gefunden = w.(http.Flusher)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/mcp", nil))
if !gefunden {
t.Error("http.Flusher ist durch den Protokoll-Mantel verlorengegangen")
}
}
// ── Schreibbudget ────────────────────────────────────────────────────────────
// TestPlatformAdminEditsProject prüft den Weg durch die Verwaltung: Der
// Plattform-Admin ändert Name, Beschreibung und Obergrenze eines Projekts, und
// alles drei steht danach wirklich in der Datenbank.
func TestPlatformAdminEditsProject(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
w := adminPost(t, h, "/admin/projects/testprojekt/edit", url.Values{
"name": {"Neuer Name"},
"description": {"Neue Beschreibung"},
"write_limit_hourly": {"250"},
})
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet %d — Antwort: %s", w.Code, http.StatusSeeOther, w.Body.String())
}
gelesen, err := db.ProjectByID(context.Background(), projekt.ID)
if err != nil {
t.Fatal(err)
}
if gelesen.Name != "Neuer Name" {
t.Errorf("Name = %q, erwartet \"Neuer Name\"", gelesen.Name)
}
if gelesen.Description != "Neue Beschreibung" {
t.Errorf("Description = %q, erwartet \"Neue Beschreibung\"", gelesen.Description)
}
if gelesen.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly)
}
}
// TestProjectEditFormShowsCurrentValues ist der Grund, warum die Bearbeitung
// eine eigene Seite je Projekt ist: Die Felder müssen den gespeicherten Stand
// zeigen. Sonst überschreibt ein Speichern ohne Änderung die Werte mit den
// Vorgaben des Formulars.
func TestProjectEditFormShowsCurrentValues(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
if err := db.UpdateProject(context.Background(), projekt.ID, store.ProjectUpdate{
Name: "Testprojekt", Description: "Eine Beschreibung", WriteLimitHourly: 250,
}); err != nil {
t.Fatal(err)
}
cookie := anmelden(t, h, testAdminToken)
r := httptest.NewRequest(http.MethodGet, "/admin/projects/testprojekt/edit", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
koerper := w.Body.String()
for _, erwartet := range []string{`value="Eine Beschreibung"`, `value="250"`} {
if !strings.Contains(koerper, erwartet) {
t.Errorf("das Formular enthält %s nicht", erwartet)
}
}
}
// TestProjectEditKeepsUnchangedValues deckt die Falle des früheren
// Auswahlfeld-Formulars ab: Ein Speichern, das die Obergrenze nicht anfasst,
// darf sie nicht auf 0 zurücksetzen.
func TestProjectEditKeepsUnchangedValues(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if err := db.UpdateProject(ctx, projekt.ID, store.ProjectUpdate{
Name: "Testprojekt", Description: "", WriteLimitHourly: 250,
}); err != nil {
t.Fatal(err)
}
// 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{
"name": {"Anderer Name"},
"description": {""},
"write_limit_hourly": {"250"},
})
gelesen, _ := db.ProjectByID(ctx, projekt.ID)
if gelesen.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d — die Obergrenze ging beim Speichern verloren", gelesen.WriteLimitHourly)
}
}
// TestProjectAdminCannotEditProject ist der eigentliche Grund, warum die Route
// beim Plattform-Admin liegt: Ein Projekt-Admin könnte sich sonst selbst mehr
// Budget geben — dann wäre die Grenze keine.
func TestProjectAdminCannotEditProject(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, 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)
formular := url.Values{
"name": {"Gekapert"},
"write_limit_hourly": {"999999"},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/admin/projects/testprojekt/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.StatusForbidden {
t.Errorf("Status = %d, erwartet %d — ein Projekt-Admin durfte sein Projekt bearbeiten", w.Code, http.StatusForbidden)
}
gelesen, _ := db.ProjectByID(context.Background(), projekt.ID)
if gelesen.WriteLimitHourly != 0 {
t.Errorf("WriteLimitHourly = %d — die Obergrenze wurde trotzdem geändert", gelesen.WriteLimitHourly)
}
}
// TestProjectEditRejectsGarbage prüft, dass eine unbrauchbare Eingabe eine
// Fehlermeldung ergibt und nicht etwa eine 0 (also "Standard") schreibt.
func TestProjectEditRejectsGarbage(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if err := db.UpdateProject(ctx, projekt.ID, store.ProjectUpdate{
Name: "Testprojekt", WriteLimitHourly: 42,
}); err != nil {
t.Fatal(err)
}
w := adminPost(t, h, "/admin/projects/testprojekt/edit", url.Values{
"name": {"Testprojekt"},
"write_limit_hourly": {"viele"},
})
if w.Code != http.StatusBadRequest {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest)
}
gelesen, _ := db.ProjectByID(ctx, projekt.ID)
if gelesen.WriteLimitHourly != 42 {
t.Errorf("WriteLimitHourly = %d — die alte Obergrenze wurde überschrieben", gelesen.WriteLimitHourly)
}
}
// TestProjectEditRejectsEmptyName: Ein Projekt ohne Namen wäre in der Übersicht
// eine leere Zeile.
func TestProjectEditRejectsEmptyName(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
w := adminPost(t, h, "/admin/projects/testprojekt/edit", url.Values{
"name": {" "},
"write_limit_hourly": {"0"},
})
if w.Code != http.StatusBadRequest {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest)
}
gelesen, _ := db.ProjectByID(context.Background(), projekt.ID)
if gelesen.Name != projekt.Name {
t.Errorf("Name = %q — der leere Name wurde gespeichert", gelesen.Name)
}
}
// TestProjectEditUnknownSlug: ein Bezeichner, den es nicht gibt.
func TestProjectEditUnknownSlug(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, testAdminToken)
r := httptest.NewRequest(http.MethodGet, "/admin/projects/gibtesnicht/edit", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404", w.Code)
}
}
// 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 {
t.Helper()
cookie := anmelden(t, h, testAdminToken)
formular.Set(auth.CSRFFieldName, auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin))
r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}

423
main.go Normal file
View file

@ -0,0 +1,423 @@
// Munin — ein projektbezogenes Gedächtnis für KI-Agenten und Menschen.
//
// Benannt nach dem Raben Odins, dessen Name „Gedächtnis" bedeutet: Er fliegt
// aus, sammelt, kehrt zurück und berichtet. Genau das tun die Agenten hier.
//
// Der Hauptzugang ist MCP (Model Context Protocol): KI-Werkzeuge legen dort
// Wissen ab und rufen es wieder auf. Die Weboberfläche dient zum Nachlesen,
// Pflegen und Verwalten.
//
// Dieses File ist der Einstiegspunkt: Es liest die Konfiguration, prüft die
// Pflichtangaben, baut die Bestandteile zusammen und startet den HTTP-Server.
package main
import (
"context"
"embed"
"flag"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/joho/godotenv"
"munin/internal/auth"
"munin/internal/export"
"munin/internal/mcpserver"
"munin/internal/search"
"munin/internal/store"
"munin/internal/web"
)
// //go:embed bettet Dateien beim Kompilieren ins Binary ein. Nach dem Bauen sind
// templates/ und static/ nicht mehr als Ordner nötig — alles steckt im Programm.
// Das Ergebnis ist eine einzelne Datei, die überall läuft.
//go:embed templates/*
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// ── Größenbegrenzungen ───────────────────────────────────────────────────────
//
// Ohne diese Grenzen kann ein einzelner Aufruf beliebig viel Speicher belegen.
// Das muss nicht böswillig sein — ein Agent in einer Schleife reicht aus.
const (
// maxWebBodyBytes gilt für die Formulare der Weboberfläche. Ein Eintrag kann
// lang sein, aber ein Megabyte Text ist bereits ein sehr langes Dokument.
maxWebBodyBytes = 1 << 20 // 1 MB
// maxAPIBodyBytes gilt für MCP-Aufrufe.
defaultMaxAPIBodyBytes = 4 << 20 // 4 MB
)
// ── Server-Timeouts ──────────────────────────────────────────────────────────
//
// Ohne Timeouts hält eine einzige Verbindung dauerhaft Ressourcen fest. Genau
// darauf zielt der "Slowloris"-Angriff: viele Verbindungen öffnen und die
// Anfrage extrem langsam senden, bis der Server keine neuen mehr annimmt.
const (
readHeaderTimeout = 10 * time.Second
readTimeout = 60 * time.Second
writeTimeout = 120 * time.Second
idleTimeout = 2 * time.Minute
)
// ── Schreibbudget ────────────────────────────────────────────────────────────
//
// Ein gültiger API-Key umgeht die IP-Sperre bewusst (siehe APIAuth in
// internal/web/middleware.go). Ohne eine zweite Grenze hätte damit ein Agent,
// der in einer Schleife hängt, überhaupt kein Limit mehr.
const (
// defaultWriteLimitHourly ist die Vorgabe für alle Projekte ohne eigene
// Obergrenze.
//
// Warum 1.000? Echte Agentenarbeit sind eine Handvoll "remember"-Aufrufe je
// Sitzung; auch ein Projekt mit vielen gleichzeitigen Agenten kommt kaum
// über ein paar Dutzend je Stunde. 1.000 liegt also weit über dem Bedarf und
// zugleich weit unter dem, was Schaden anrichtet — gemessene Kapazität sind
// 8.656 Schreibvorgänge je SEKUNDE (siehe AUSBAUSTUFEN.md).
//
// Die Zahl darf großzügig sein: Sie soll eine Schleife stoppen, nicht Arbeit
// rationieren. Ein zu niedriger Wert blockiert echte Nutzung und wird dann
// abgeschaltet — und damit schützt er vor gar nichts mehr.
defaultWriteLimitHourly = 1000
// writeLimitWindow ist das Zeitfenster, auf das sich die Obergrenze bezieht.
writeLimitWindow = time.Hour
)
// exportInterval bestimmt, wie oft der Markdown-Export automatisch läuft.
const exportInterval = 24 * time.Hour
// auditRetention bestimmt, wie lange Protokolleinträge aufbewahrt werden.
//
// Das Protokoll enthält IP-Adressen und Zugangsnamen, aus denen sich Personen
// ableiten lassen. Eine Aufbewahrungsfrist ist deshalb nicht nur eine Frage des
// Plattenplatzes, sondern die Voraussetzung dafür, das Protokoll überhaupt
// führen zu dürfen.
const auditRetention = 90 * 24 * time.Hour
func main() {
// Mit -export läuft nur der Export und das Programm endet danach. Gedacht
// für Sicherungsskripte und Cronjobs, die einen aktuellen Stand brauchen,
// ohne den Server anzufassen.
nurExport := flag.Bool("export", false, "Nur den Markdown-Export ausführen und beenden")
flag.Parse()
// .env laden, falls vorhanden. Echte Umgebungsvariablen haben Vorrang.
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
log.Printf("Hinweis: .env konnte nicht geladen werden: %v", err)
}
cfg := readConfig()
// ── Datenbank ────────────────────────────────────────────────────────────
if err := os.MkdirAll(filepath.Dir(cfg.dbPath), 0750); err != nil {
log.Fatal("Datenverzeichnis konnte nicht angelegt werden: ", err)
}
db, err := store.Open(cfg.dbPath)
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
workspace, err := db.EnsureWorkspace(ctx, "Intern")
if err != nil {
log.Fatal(err)
}
// Der Export ist abgeschaltet, solange EXPORT_DIR nicht gesetzt ist.
var exporter *export.Exporter
if cfg.exportDir != "" {
exporter = export.New(db, cfg.exportDir)
}
if *nurExport {
if exporter == nil {
log.Fatal("EXPORT_DIR ist nicht gesetzt — ohne Zielverzeichnis gibt es nichts zu exportieren.")
}
stats, err := exporter.ExportAll(ctx, workspace.ID)
if err != nil {
log.Fatal("Export fehlgeschlagen: ", err)
}
log.Printf("Export abgeschlossen: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
return
}
// ── Bestandteile ─────────────────────────────────────────────────────────
signer := auth.NewSigner([]byte(cfg.sessionSecret))
cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE"))
limiter := auth.NewRateLimiter()
writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow)
searchRegistry := search.NewRegistry(db)
webServer, err := web.NewServer(web.Config{
Store: db,
Search: searchRegistry,
Signer: signer,
Cookies: cookies,
Limiter: limiter,
Templates: templatesFS,
WorkspaceID: workspace.ID,
AdminToken: cfg.adminToken,
Exporter: exporter,
WriteBudget: writeBudget,
})
if err != nil {
log.Fatal("Templates konnten nicht geladen werden: ", err)
}
mcpHandler := mcpserver.New(db, searchRegistry, writeBudget)
// ── Routen ───────────────────────────────────────────────────────────────
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
webServer.Routes(mux, staticSub)
// Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier
// kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key.
mux.Handle("/mcp", web.LimitBody(webServer.APIAuth(mcpHandler), cfg.maxAPIBody))
// Reihenfolge von außen nach innen:
// LogRequests erfasst jede Antwort, auch abgewiesene
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
// LimitBody deckelt den Body, bevor ein Handler ihn liest
handler := web.LogRequests(
web.SecurityHeaders(
web.LimitBody(mux, maxWebBodyBytes),
cfg.enableHSTS,
),
)
// ── Wiederkehrende Aufgaben ──────────────────────────────────────────────
go pruneAuditRegularly(db)
if exporter != nil {
go exportRegularly(exporter, workspace.ID)
log.Printf("Markdown-Export aktiv: %s (täglich)", cfg.exportDir)
}
// ── Server starten ───────────────────────────────────────────────────────
server := &http.Server{
Addr: ":" + cfg.port,
Handler: handler,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
// Standard wären 1 MB Header. So viel braucht niemand, und kleinere
// Grenzen bedeuten weniger Speicher je halb offener Verbindung.
MaxHeaderBytes: 64 << 10,
}
signalCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
projekte, _ := db.ListProjects(ctx, workspace.ID)
log.Printf("Munin läuft auf http://localhost:%s (%d Projekte)", cfg.port, len(projekte))
log.Printf("Datenbank: %s", cfg.dbPath)
if !cfg.enableHSTS {
log.Println("Hinweis: ENABLE_HSTS ist aus — bei Betrieb unter HTTPS auf true setzen.")
}
if cfg.writeLimitHourly <= 0 {
log.Println("Warnung: WRITE_LIMIT_HOURLY=0 — schreibende MCP-Aufrufe sind unbegrenzt.")
} else {
log.Printf("Schreibbudget: %d Aufrufe je Projekt und Stunde (Vorgabe)", cfg.writeLimitHourly)
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Server-Fehler: ", err)
}
}()
<-signalCtx.Done()
log.Println("Server wird gestoppt…")
// Laufende Anfragen zu Ende bringen, bevor der Prozess endet.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("Fehler beim Stoppen: %v", err)
}
log.Println("Server gestoppt.")
}
// config sammelt alle Einstellungen aus der Umgebung.
type config struct {
dbPath string
port string
adminToken string
sessionSecret string
enableHSTS bool
maxAPIBody int64
exportDir string
// writeLimitHourly ist die Vorgabe für Projekte ohne eigene Obergrenze.
// 0 schaltet die Begrenzung vollständig ab.
writeLimitHourly int
}
// readConfig liest die Umgebungsvariablen und prüft die Pflichtangaben.
//
// Der Server startet lieber gar nicht, als mit einem schwachen Geheimnis zu
// laufen: SESSION_SECRET signiert Sitzungen und CSRF-Tokens, ADMIN_TOKEN öffnet
// jedes Projekt. Ein erratbarer Wert wäre eine Hintertür.
func readConfig() config {
cfg := config{
dbPath: envOr("DB_PATH", "data/munin.db"),
port: envOr("PORT", "8080"),
adminToken: os.Getenv("ADMIN_TOKEN"),
sessionSecret: os.Getenv("SESSION_SECRET"),
enableHSTS: isTrue(os.Getenv("ENABLE_HSTS")),
maxAPIBody: envBytes("MAX_API_BODY_BYTES", defaultMaxAPIBodyBytes),
exportDir: os.Getenv("EXPORT_DIR"),
writeLimitHourly: envInt("WRITE_LIMIT_HOURLY", defaultWriteLimitHourly),
}
if cfg.sessionSecret == "" {
log.Fatal("SESSION_SECRET ist nicht gesetzt — signiert Sitzungen und CSRF-Tokens. Erzeugen mit: openssl rand -hex 32")
}
if len(cfg.sessionSecret) < 32 {
log.Fatal("SESSION_SECRET ist zu kurz — mindestens 32 Zeichen. Erzeugen mit: openssl rand -hex 32")
}
if cfg.adminToken == "" {
log.Fatal("ADMIN_TOKEN ist nicht gesetzt — der Plattform-Admin legt damit Projekte an. Erzeugen mit: openssl rand -hex 24")
}
if len(cfg.adminToken) < 16 {
log.Fatal("ADMIN_TOKEN ist zu kurz — mindestens 16 Zeichen. Erzeugen mit: openssl rand -hex 24")
}
// Hinter einem Reverse-Proxy muss der Server wissen, wem er die echte
// Client-Adresse glauben darf (siehe internal/auth/trustedproxy.go).
if trusted := os.Getenv("TRUSTED_PROXY"); trusted != "" {
if err := auth.SetTrustedProxies(trusted); err != nil {
log.Fatal(err)
}
log.Printf("TRUSTED_PROXY aktiv (%d Einträge) — X-Forwarded-For wird ausgewertet", auth.TrustedProxyCount())
}
return cfg
}
// exportRegularly schreibt den Bestand regelmäßig als Markdown-Dateien.
//
// Einmal direkt beim Start, damit auch ein Server, der nie einen ganzen Tag
// durchläuft, einen aktuellen Stand hinterlässt.
func exportRegularly(e *export.Exporter, workspaceID string) {
lauf := func() {
stats, err := e.ExportAll(context.Background(), workspaceID)
if err != nil {
log.Printf("Export fehlgeschlagen: %v", err)
return
}
if stats.Removed > 0 {
log.Printf("Export: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
}
}
lauf()
ticker := time.NewTicker(exportInterval)
defer ticker.Stop()
for range ticker.C {
lauf()
}
}
// pruneAuditRegularly räumt das Protokoll täglich auf.
func pruneAuditRegularly(db *store.Store) {
// Einmal direkt beim Start, damit auch ein Server aufräumt, der nie 24
// Stunden am Stück läuft.
prune(db)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
prune(db)
}
}
func prune(db *store.Store) {
grenze := time.Now().UTC().Add(-auditRetention).Format(time.RFC3339Nano)
n, err := db.PruneAudit(context.Background(), grenze)
if err != nil {
log.Printf("Protokoll konnte nicht aufgeräumt werden: %v", err)
return
}
if n > 0 {
log.Printf("Protokoll aufgeräumt: %d Einträge älter als %d Tage entfernt", n, int(auditRetention.Hours()/24))
}
}
// envOr liest eine Umgebungsvariable mit Standardwert.
func envOr(key, standard string) string {
if v := os.Getenv(key); v != "" {
return v
}
return standard
}
// envBytes liest eine Byte-Angabe. Ein unbrauchbarer Wert führt zu einer Warnung
// und dem Standardwert — ein Tippfehler soll den Start nicht verhindern.
func envBytes(key string, standard int64) int64 {
roh := os.Getenv(key)
if roh == "" {
return standard
}
wert, err := strconv.ParseInt(roh, 10, 64)
if err != nil || wert <= 0 {
log.Printf("Hinweis: %s=%q ist keine gültige Byte-Angabe — es gilt der Standardwert %d", key, roh, standard)
return standard
}
return wert
}
// envInt liest eine Zahl. Wie envBytes führt ein unbrauchbarer Wert zu einer
// Warnung und dem Standardwert, statt den Start zu verhindern.
//
// Anders als envBytes ist 0 hier ein gültiger Wert — er schaltet die Begrenzung
// ab. Deshalb eine eigene Funktion statt eines Umbaus von envBytes: Dort wäre 0
// ein Tippfehler, hier eine Ansage.
func envInt(key string, standard int) int {
roh := os.Getenv(key)
if roh == "" {
return standard
}
wert, err := strconv.Atoi(roh)
if err != nil || wert < 0 {
log.Printf("Hinweis: %s=%q ist keine gültige Zahl — es gilt der Standardwert %d", key, roh, standard)
return standard
}
return wert
}
// isTrue erkennt die üblichen Schreibweisen für "an".
func isTrue(v string) bool {
switch v {
case "true", "TRUE", "True", "1", "yes", "on":
return true
default:
return false
}
}

30
static/confirm.js Normal file
View file

@ -0,0 +1,30 @@
// Fragt vor dem Absenden bestimmter Formulare nach einer Bestätigung.
//
// Früher stand die Abfrage direkt im HTML:
//
// <button onclick="return confirm('Wirklich löschen?')">
//
// Das funktioniert nicht mehr, seit der Server eine Content-Security-Policy
// schickt (siehe middleware/security.go). Die verbietet Inline-JavaScript —
// und zwar aus gutem Grund: Kann der Browser nicht unterscheiden, ob ein Stück
// Code vom Entwickler oder von einem Angreifer stammt, ist jede XSS-Lücke sofort
// ausnutzbar. Die Regel "Skripte nur aus eigenen Dateien" macht diesen
// Unterschied maschinell prüfbar.
//
// Die Bestätigung steht deshalb jetzt als data-confirm-Attribut am Formular,
// und dieses Skript hängt sich daran.
document.addEventListener("submit", function (event) {
var form = event.target;
// closest() sucht vom Element aufwärts nach dem passenden Vorfahren —
// hier reicht das Formular selbst, aber so bleibt es robust.
var message = form.getAttribute && form.getAttribute("data-confirm");
if (!message) {
return;
}
if (!window.confirm(message)) {
// preventDefault verhindert, dass der Browser das Formular abschickt.
event.preventDefault();
}
});

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 75 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 128 KiB

678
static/style.css Normal file
View file

@ -0,0 +1,678 @@
/* Aussehen der Weboberfläche.
*
* ── Aufbau dieser Datei ──────────────────────────────────────────────────────
* 1. Grundwerte Farben, Abstände, Schriftgrößen, Breiten
* 2. Grundlagen body, Überschriften, Links, Code
* 3. Gerüst Kopfleisten und Inhaltsbereich
* 4. Bausteine Karten, Meldungen, Marken, Tabellen, Formulare, Knöpfe
* 5. Seiten das Wenige, das wirklich nur eine Seite betrifft
* 6. Schmale Geräte
*
* ── Zwei Regeln, die alles zusammenhalten ────────────────────────────────────
*
* ERSTENS: Abstände kommen ausschließlich aus der Skala (--s1 bis --s8). Keine
* frei gewählten Werte. Vorher standen hier 29 verschiedene Abstandswerte — das
* Ergebnis war, dass nichts zueinander ausgerichtet war und jede neue Seite
* wieder ein bisschen anders aussah.
*
* ZWEITENS: Die Breite richtet sich nach dem Inhalt, nicht nach dem Bildschirm.
* Eine Tabelle mit acht Spalten braucht Platz, ein Fließtext dagegen darf nicht
* über die ganze Breite laufen — ab etwa 75 Zeichen je Zeile findet das Auge den
* Zeilenanfang nicht mehr zuverlässig. Deshalb gibt es drei Breiten, und jede
* Seite wählt die passende Darstellung.
*/
/* ── 1. Grundwerte ────────────────────────────────────────────────────────── */
:root {
/* Farben: eine neutrale Grundfläche, ein Akzent, eine Warnfarbe. */
--grund: #faf9f7; /* Seitenhintergrund */
--flaeche: #ffffff; /* Karten, Tabellen, Eingabefelder */
--flaeche-leis: #f4f2ef; /* Tabellenköpfe, gedämpfte Bereiche */
--rahmen: #e4e1dc;
--rahmen-stark: #cfcbc4;
--text: #23211e;
--text-leise: #6d6862;
--akzent: #2c6e4e;
--akzent-flach: #ebf3ee;
--akzent-text: #205138;
--warn: #a13a2b;
--warn-flach: #fbeeeb;
/* Abstandsskala — alles Weitere ist ein Vielfaches davon. */
--s1: 0.25rem;
--s2: 0.5rem;
--s3: 0.75rem;
--s4: 1rem;
--s5: 1.5rem;
--s6: 2rem;
--s7: 3rem;
--s8: 4rem;
/* Schriftgrößen. */
--t-klein: 0.8125rem;
--t-meta: 0.875rem;
--t-normal: 1rem;
--t-gross: 1.125rem;
--t-h2: 1.25rem;
--t-h1: 1.625rem;
/* Breiten. Die Seite nutzt den Platz aus; nur der Fließtext begrenzt sich
* selbst (siehe Regel zwei oben). */
--breite-seite: 92rem; /* Seitenleiste plus Inhalt */
--breite-leiste: 15rem; /* Seitenleiste */
--breite-lesen: 44rem; /* Fließtext eines Eintrags */
--breite-schmal: 25rem; /* Anmeldung */
/* Höhe der Bedienelemente. Wichtig ist nicht der Wert, sondern dass Knopf
* und Eingabefeld denselben verwenden — sonst stehen sie in einer Zeile
* nebeneinander unterschiedlich hoch, und das fällt sofort auf. */
--hoehe-bedienelement: 2.25rem;
--hoehe-kopfleiste: 3.5rem;
--radius: 6px;
--radius-gross: 10px;
--schatten: 0 1px 2px rgb(0 0 0 / 0.04), 0 1px 3px rgb(0 0 0 / 0.06);
}
/* Dunkle Darstellung: nur die Farben werden getauscht, alle Regeln darunter
* gelten unverändert weiter. */
@media (prefers-color-scheme: dark) {
:root {
--grund: #1a1918;
--flaeche: #232120;
--flaeche-leis: #2b2927;
--rahmen: #383533;
--rahmen-stark: #4a4643;
--text: #e9e6e2;
--text-leise: #a09a94;
--akzent: #6fbf96;
--akzent-flach: #1f2d26;
--akzent-text: #8fd3ae;
--warn: #e08d7e;
--warn-flach: #2e2320;
--schatten: 0 1px 3px rgb(0 0 0 / 0.4);
}
}
*, *::before, *::after { box-sizing: border-box; }
/* ── 2. Grundlagen ───────────────────────────────────────────────────────── */
body {
margin: 0;
background: var(--grund);
color: var(--text);
font: var(--t-normal)/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { line-height: 1.3; font-weight: 600; margin: 0; }
h1 { font-size: var(--t-h1); letter-spacing: -0.01em; }
h2 { font-size: var(--t-h2); }
h3 { font-size: var(--t-gross); }
p { margin: 0 0 var(--s4); }
p:last-child { margin-bottom: 0; }
a { color: var(--akzent-text); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Sichtbarer Fokusrahmen für alles, was per Tastatur erreichbar ist.
* :focus-visible statt :focus — so erscheint der Rahmen bei Tastaturbedienung,
* aber nicht bei jedem Mausklick. */
:focus-visible {
outline: 2px solid var(--akzent);
outline-offset: 2px;
border-radius: var(--radius);
}
code, pre {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
}
code {
background: var(--flaeche-leis);
border: 1px solid var(--rahmen);
padding: 0.1em 0.35em;
border-radius: 4px;
}
pre {
background: var(--flaeche-leis);
border: 1px solid var(--rahmen);
border-radius: var(--radius);
padding: var(--s4);
overflow-x: auto;
margin: 0;
}
pre code { background: none; border: none; padding: 0; }
/* Kleiner erklärender Text. Als Blockelement UND innerhalb von Beschriftungen
* verwendbar, deshalb kein eigenes display. */
.hilfe {
color: var(--text-leise);
font-size: var(--t-meta);
font-weight: normal;
}
/* ── 3. Gerüst ───────────────────────────────────────────────────────────── */
/* Schmale Kopfleiste über die ganze Breite. Sie trägt nur Marke und Abmelden —
* die eigentliche Navigation steht in der Seitenleiste. */
.kopfleiste {
background: var(--flaeche);
border-bottom: 1px solid var(--rahmen);
position: sticky;
top: 0;
z-index: 20;
}
.kopfleiste-innen {
max-width: var(--breite-seite);
margin-inline: auto;
padding-inline: var(--s5);
min-height: var(--hoehe-kopfleiste);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
}
.marke-wort {
font-weight: 600;
color: var(--text);
letter-spacing: -0.01em;
}
.marke-wort:hover { text-decoration: none; }
/* Das Gerüst: Seitenleiste links, Inhalt rechts. */
.gerüst {
max-width: var(--breite-seite);
margin-inline: auto;
padding-inline: var(--s5);
display: grid;
grid-template-columns: var(--breite-leiste) minmax(0, 1fr);
gap: var(--s7);
align-items: start;
}
/* Seitenleiste.
*
* position: sticky hält sie beim Scrollen sichtbar. Der Wert für top entspricht
* der Höhe der Kopfleiste, damit sie genau darunter stehen bleibt und nicht
* dahinter verschwindet. */
.seitenleiste {
position: sticky;
top: var(--hoehe-kopfleiste);
max-height: calc(100dvh - var(--hoehe-kopfleiste));
overflow-y: auto;
padding-block: var(--s6);
display: flex;
flex-direction: column;
gap: var(--s5);
}
.nav-gruppe { display: flex; flex-direction: column; gap: var(--s1); }
.nav-titel {
font-size: var(--t-klein);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-leise);
margin-bottom: var(--s1);
padding-inline: var(--s2);
}
.nav-liste { list-style: none; margin: 0; padding: 0; }
.nav-liste a {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s1) var(--s2);
border-radius: var(--radius);
color: var(--text);
font-size: var(--t-meta);
line-height: 1.5;
}
.nav-liste a:hover { background: var(--flaeche-leis); text-decoration: none; }
/* Der aktive Punkt wird durch Fläche markiert, nicht durch Farbe allein —
* so bleibt er auch bei eingeschränktem Farbsehen erkennbar. */
.nav-liste a.aktiv {
background: var(--akzent-flach);
color: var(--akzent-text);
font-weight: 500;
}
.nav-liste.leise a { color: var(--text-leise); }
/* Schlagwort und Anzahl: Name links, Zahl rechtsbündig. */
.nav-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-zahl { color: var(--text-leise); font-size: var(--t-klein); font-variant-numeric: tabular-nums; }
.nav-liste a.aktiv .nav-zahl { color: inherit; }
/* Inhaltsbereich. */
.inhalt { padding-block: var(--s6) var(--s8); min-width: 0; }
/* Fließtext auf Lesebreite. Nur der Text selbst — Überschrift, Metazeile und
* Tabellen dürfen die volle Breite nutzen. */
.lesebreite { max-width: var(--breite-lesen); }
/* Seitenkopf: Titel links, Aktionen rechts — auf einer Linie.
*
* align-items: center statt flex-start, damit der Knopf mittig zur Überschrift
* sitzt und nicht an deren Oberkante klebt. */
.seitenkopf {
display: flex;
align-items: center;
gap: var(--s4);
flex-wrap: wrap;
margin-bottom: var(--s5);
}
.seitenkopf-titel { flex: 1; min-width: 15rem; }
.seitenkopf-titel .hilfe { display: block; margin-top: var(--s1); }
.seitenkopf-aktionen { display: flex; gap: var(--s2); align-items: center; }
/* Abschnitt innerhalb einer Seite. */
.abschnitt { margin-top: var(--s7); }
.abschnitt > h2 { margin-bottom: var(--s2); }
.abschnitt > .hilfe { display: block; margin-bottom: var(--s4); }
/* ── 4. Bausteine ────────────────────────────────────────────────────────── */
/* Karte: der Standardbehälter für alles Gruppierte. */
.karte {
background: var(--flaeche);
border: 1px solid var(--rahmen);
border-radius: var(--radius-gross);
padding: var(--s5);
box-shadow: var(--schatten);
}
.karte + .karte { margin-top: var(--s4); }
/* Meldungen. */
.meldung {
padding: var(--s3) var(--s4);
border: 1px solid var(--rahmen);
border-left-width: 3px;
border-radius: var(--radius);
margin-bottom: var(--s5);
font-size: var(--t-meta);
}
.meldung.hinweis { background: var(--akzent-flach); border-left-color: var(--akzent); }
.meldung.fehler { background: var(--warn-flach); border-left-color: var(--warn); }
/* Leerzustand: nicht nur ein grauer Absatz, sondern ein erkennbarer Bereich mit
* einem Hinweis, was als Nächstes zu tun wäre. */
.leer {
text-align: center;
padding: var(--s7) var(--s5);
border: 1px dashed var(--rahmen-stark);
border-radius: var(--radius-gross);
color: var(--text-leise);
}
.leer p { margin-bottom: var(--s4); }
.leer p:last-child { margin-bottom: 0; }
/* Marken: eine einzige visuelle Familie für alle kleinen Auszeichnungen. */
.marke {
display: inline-flex;
align-items: center;
gap: var(--s1);
padding: 0.1rem var(--s2);
border: 1px solid var(--rahmen);
border-radius: 999px;
font-size: var(--t-klein);
line-height: 1.5;
color: var(--text-leise);
white-space: nowrap;
}
a.marke:hover {
border-color: var(--rahmen-stark);
color: var(--text);
text-decoration: none;
}
.marke.betont {
background: var(--akzent-flach);
border-color: transparent;
color: var(--akzent-text);
}
/* Metazeile unter einem Titel: Marken und Kleintext in einer Reihe. */
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s2);
color: var(--text-leise);
font-size: var(--t-klein);
}
/* Knöpfe. Drei Ausprägungen, mehr braucht es nicht. */
button, .knopf {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s2);
padding: var(--s2) var(--s4);
min-height: var(--hoehe-bedienelement);
background: var(--akzent);
color: #fff;
border: 1px solid transparent;
border-radius: var(--radius);
font: inherit;
font-size: var(--t-meta);
font-weight: 500;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
button:hover, .knopf:hover { filter: brightness(1.08); text-decoration: none; }
.knopf.zweitrangig, button.zweitrangig {
background: var(--flaeche);
color: var(--text);
border-color: var(--rahmen-stark);
}
.knopf.zweitrangig:hover, button.zweitrangig:hover {
background: var(--flaeche-leis);
filter: none;
}
button.warnung {
background: var(--flaeche);
color: var(--warn);
border-color: var(--warn);
}
button.warnung:hover { background: var(--warn-flach); filter: none; }
/* Ein Knopf, der wie ein Link aussieht — nötig, weil Abmelden ein POST sein muss
* (siehe CSRF-Schutz), aber wie ein Menüpunkt wirken soll. */
.wie-link {
background: none;
border: none;
padding: 0;
min-height: 0;
font: inherit;
font-size: var(--t-meta);
font-weight: normal;
color: var(--text-leise);
cursor: pointer;
}
.wie-link:hover { filter: none; text-decoration: underline; }
.reihe { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
form.reihe { display: inline-flex; }
/* Formulare.
*
* Jedes Feld ist ein .feld mit Beschriftung, optionalem Hinweis und Bedienelement.
* Die Breite richtet sich nach dem erwarteten Inhalt statt immer 100 % zu sein:
* Ein Feld für einen Bezeichner, das über die halbe Seite läuft, sieht aus wie
* ein Fehler. */
.formular { display: flex; flex-direction: column; gap: var(--s4); }
.feld { display: flex; flex-direction: column; gap: var(--s1); }
.feld > label { font-weight: 500; font-size: var(--t-meta); }
.feld > .hilfe { margin-bottom: var(--s1); }
input[type=text], input[type=search], input[type=password], select, textarea {
width: 100%;
padding: var(--s2) var(--s3);
min-height: var(--hoehe-bedienelement);
background: var(--flaeche);
border: 1px solid var(--rahmen-stark);
border-radius: var(--radius);
color: var(--text);
font: inherit;
font-size: var(--t-meta);
}
input::placeholder { color: var(--text-leise); opacity: 0.7; }
input:focus, select:focus, textarea:focus {
border-color: var(--akzent);
outline: 2px solid var(--akzent-flach);
outline-offset: 0;
}
textarea {
resize: vertical;
line-height: 1.6;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
min-height: 20rem;
}
/* Feldbreiten nach Inhalt. */
.feld.kurz { max-width: 18rem; }
.feld.mittel { max-width: 28rem; }
.feld.lang { max-width: 40rem; }
/* Ankreuzfeld: Kästchen und Text nebeneinander, Text nicht fett. */
.ankreuz {
flex-direction: row;
align-items: flex-start;
gap: var(--s2);
}
/* Das Kästchen sitzt etwas tiefer als die Oberkante des Textes, damit es
* optisch auf der ersten Textzeile liegt statt darüber. */
.ankreuz input { width: auto; min-height: 0; margin-top: 0.3rem; }
.ankreuz label { font-weight: normal; }
.formular-aktionen {
display: flex;
gap: var(--s2);
align-items: center;
padding-top: var(--s2);
}
/* Tabellen. */
.tabelle-huelle {
background: var(--flaeche);
border: 1px solid var(--rahmen);
border-radius: var(--radius-gross);
box-shadow: var(--schatten);
overflow-x: auto; /* breite Tabellen scrollen in sich, nicht die Seite */
}
table.tabelle {
width: 100%;
border-collapse: collapse;
font-size: var(--t-meta);
}
.tabelle th, .tabelle td {
text-align: left;
padding: var(--s3) var(--s4);
border-bottom: 1px solid var(--rahmen);
vertical-align: middle;
}
.tabelle thead th {
background: var(--flaeche-leis);
color: var(--text-leise);
font-weight: 500;
font-size: var(--t-klein);
white-space: nowrap;
}
.tabelle tbody tr:last-child td { border-bottom: none; }
.tabelle tbody tr:hover { background: var(--flaeche-leis); }
.tabelle .zahl { text-align: right; font-variant-numeric: tabular-nums; }
.tabelle .schmal { width: 1%; white-space: nowrap; }
.tabelle .rechts { text-align: right; }
.tabelle tr.gedaempft { color: var(--text-leise); }
.tabelle tr.gedaempft td:first-child { text-decoration: line-through; }
/* ── 5. Seiten ───────────────────────────────────────────────────────────── */
/* Anmeldung: eigenständige, ruhige Seite ohne Navigation. */
.anmeldeseite {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--s5);
}
.anmeldeseite .karte { width: 100%; padding: var(--s6); }
.anmeldeseite h1 { margin-bottom: var(--s2); }
.anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); }
/* Werkzeugleiste über der Eintragsliste: Suche und Filter. */
.werkzeugleiste {
display: flex;
gap: var(--s2);
margin-bottom: var(--s4);
}
.werkzeugleiste input[type=search] { flex: 1; }
/* Eintragsliste. */
.eintraege {
list-style: none;
margin: 0;
padding: 0;
background: var(--flaeche);
border: 1px solid var(--rahmen);
border-radius: var(--radius-gross);
box-shadow: var(--schatten);
overflow: hidden;
}
.eintraege li {
padding: var(--s4);
border-bottom: 1px solid var(--rahmen);
}
.eintraege li:last-child { border-bottom: none; }
.eintraege li:hover { background: var(--flaeche-leis); }
.eintrag-titel {
display: block;
font-size: var(--t-gross);
font-weight: 500;
color: var(--text);
margin-bottom: var(--s2);
}
.eintrag-ausschnitt {
margin: var(--s2) 0 0;
color: var(--text-leise);
font-size: var(--t-meta);
/* Auf zwei Zeilen begrenzen, damit alle Zeilen der Liste gleich hoch bleiben. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.eintrag-ausschnitt mark {
background: var(--akzent-flach);
color: var(--akzent-text);
padding: 0 0.15em;
border-radius: 3px;
}
/* Einzelner Eintrag: Fließtext in Lesebreite. */
.eintrag-text {
font-size: var(--t-gross);
line-height: 1.75;
margin-top: var(--s6);
}
.eintrag-text > :first-child { margin-top: 0; }
.eintrag-text h1, .eintrag-text h2, .eintrag-text h3 { margin: var(--s6) 0 var(--s3); }
.eintrag-text p, .eintrag-text ul, .eintrag-text ol { margin: 0 0 var(--s4); }
.eintrag-text ul, .eintrag-text ol { padding-left: var(--s5); }
.eintrag-text li { margin-bottom: var(--s1); }
.eintrag-text img { max-width: 100%; border-radius: var(--radius); }
.eintrag-text blockquote {
margin: var(--s4) 0;
padding: var(--s1) var(--s4);
border-left: 3px solid var(--rahmen-stark);
color: var(--text-leise);
}
.eintrag-text pre { margin-bottom: var(--s4); font-size: var(--t-meta); }
.eintrag-text table { border-collapse: collapse; width: 100%; font-size: var(--t-meta); }
.eintrag-text th, .eintrag-text td { border: 1px solid var(--rahmen); padding: var(--s2) var(--s3); }
.eintrag-text hr { border: none; border-top: 1px solid var(--rahmen); margin: var(--s6) 0; }
.rueckverweise { margin-top: var(--s7); }
.rueckverweise h2 { font-size: var(--t-meta); color: var(--text-leise); margin-bottom: var(--s3); }
.rueckverweise ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
/* Neu erzeugter Key: wird genau einmal angezeigt, also deutlich hervorheben. */
.key-anzeige { border-color: var(--akzent); background: var(--akzent-flach); }
.key-wert {
display: block;
margin: var(--s3) 0;
padding: var(--s3);
background: var(--flaeche);
border: 1px solid var(--rahmen-stark);
border-radius: var(--radius);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: var(--t-meta);
word-break: break-all;
user-select: all; /* ein Klick markiert den ganzen Key */
}
.key-anleitung { margin-top: var(--s4); }
.key-anleitung summary {
cursor: pointer;
font-size: var(--t-meta);
color: var(--akzent-text);
}
.key-anleitung pre { margin-top: var(--s3); font-size: var(--t-klein); }
/* Gefahrenzone: Löschvorgänge stehen abgesetzt am Seitenende, nicht zwischen
* den harmlosen Aktionen. Vorher steckte das Löschen eines Projekts samt
* Bestätigungsfeld in einer Tabellenzelle — zu eng und viel zu beiläufig für
* eine Aktion, die alle Einträge mitnimmt. */
.gefahrenzone { border-color: var(--warn); }
.gefahrenzone h2 { color: var(--warn); }
/* ── 6. Schmale Geräte ───────────────────────────────────────────────────── */
/* Unterhalb dieser Breite ist für zwei Spalten kein Platz mehr: Die Seitenleiste
* rückt über den Inhalt und wird flach — aus den Listen werden Zeilen, die bei
* Bedarf seitlich scrollen. So bleibt alles erreichbar, ohne dass eine
* ausklappbare Navigation samt JavaScript nötig wäre. */
@media (max-width: 60rem) {
.gerüst {
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding-inline: var(--s4);
}
.kopfleiste-innen { padding-inline: var(--s4); }
.seitenleiste {
position: static;
max-height: none;
overflow: visible;
flex-direction: row;
flex-wrap: wrap;
gap: var(--s4);
padding-block: var(--s4);
border-bottom: 1px solid var(--rahmen);
}
/* Jede Gruppe wird zu einer Zeile: Überschrift links, Punkte daneben. */
.nav-gruppe {
flex-direction: row;
align-items: center;
gap: var(--s2);
min-width: 0;
flex: 1 1 100%;
}
.nav-titel { margin-bottom: 0; padding-inline: 0; white-space: nowrap; }
.nav-liste {
display: flex;
gap: var(--s1);
overflow-x: auto;
scrollbar-width: none; /* die Leiste scrollt, ohne einen Balken zu zeigen */
}
.nav-liste::-webkit-scrollbar { display: none; }
.nav-liste a { white-space: nowrap; }
/* Die Schlagwortliste kann lang werden — hier zählt jede Zeile. */
.schlagworte .nav-liste a { padding-inline: var(--s2); }
.inhalt { padding-block: var(--s5) var(--s7); }
.seitenkopf { gap: var(--s3); }
.seitenkopf-aktionen { width: 100%; }
.seitenkopf-aktionen .knopf { flex: 1; }
.karte { padding: var(--s4); }
.tabelle th, .tabelle td { padding: var(--s2) var(--s3); }
.feld.kurz, .feld.mittel, .feld.lang { max-width: none; }
textarea { min-height: 14rem; }
}

52
templates/audit.html Normal file
View file

@ -0,0 +1,52 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Protokoll</h1>
<span class="hilfe">
Die jüngsten {{len .Data.Events}} Vorgänge, neueste zuerst.
{{if .Data.Global}}Über alle Projekte hinweg.{{end}}
Einträge werden nach 90 Tagen automatisch entfernt.
</span>
</div>
</div>
{{if .Data.Events}}
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr>
<th>Zeitpunkt</th>
{{if .Data.Global}}<th>Projekt</th>{{end}}
<th>Wer</th>
<th>Vorgang</th>
<th>Eintrag</th>
<th>Herkunft</th>
<th>IP</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{{$projekte := .Data.ProjectNames}}{{$global := .Data.Global}}
{{range .Data.Events}}
<tr>
<td>{{datum .Timestamp}}</td>
{{if $global}}<td>{{with index $projekte .ProjectID}}{{.}}{{else}}—{{end}}</td>{{end}}
<td>{{.Actor}}</td>
<td><code>{{.Action}}</code></td>
<td>{{if .EntrySlug}}<code>{{.EntrySlug}}</code>{{else}}—{{end}}</td>
<td>{{.Source}}</td>
<td>{{if .IP}}{{.IP}}{{else}}—{{end}}</td>
<td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="leer">
<p>Noch nichts protokolliert.</p>
</div>
{{end}}
{{template "fuss" .}}

64
templates/edit.html Normal file
View file

@ -0,0 +1,64 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>{{if .Data.IsNew}}Neuer Eintrag{{else}}Eintrag bearbeiten{{end}}</h1>
{{if not .Data.IsNew}}<span class="hilfe"><code>{{.Data.Entry.Slug}}</code> · Fassung {{.Data.Entry.Version}}</span>{{end}}
</div>
</div>
<div class="karte">
<form method="POST" class="formular"
action="{{if .Data.IsNew}}/p/{{.Project.Slug}}/save{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/save{{end}}">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{/* Die Fassung wandert unverändert durchs Formular und zurück. Hat jemand
anderes den Eintrag zwischenzeitlich geändert, erkennt der Server das
und meldet einen Konflikt, statt die fremde Änderung zu überschreiben. */}}
<input type="hidden" name="version" value="{{.Data.Entry.Version}}">
<div class="feld lang">
<label for="title">Titel</label>
<input type="text" id="title" name="title" value="{{.Data.Entry.Title}}" required autofocus>
</div>
{{if .Data.IsNew}}
<div class="feld kurz">
<label for="slug">Bezeichner</label>
<span class="hilfe">Leer lassen, um ihn aus dem Titel abzuleiten.</span>
<input type="text" id="slug" name="slug" pattern="[a-z0-9][a-z0-9_-]*"
placeholder="wird-abgeleitet" autocomplete="off">
</div>
{{else}}
<input type="hidden" name="slug" value="{{.Data.Entry.Slug}}">
{{end}}
<div class="feld mittel">
<label for="tags">Schlagworte</label>
<span class="hilfe">Durch Komma getrennt.</span>
<input type="text" id="tags" name="tags" value="{{.Data.TagsIn}}" placeholder="ops, deployment">
</div>
<div class="feld">
<label for="content">Inhalt</label>
<span class="hilfe">Markdown. <code>[[bezeichner]]</code> verweist auf einen anderen Eintrag.</span>
<textarea id="content" name="content" rows="24" spellcheck="true">{{.Data.Entry.Content}}</textarea>
</div>
<div class="feld ankreuz">
<input type="checkbox" id="curated" name="curated" value="1" {{if .Data.Entry.Curated}}checked{{end}}>
<label for="curated">
Kuratiert
<span class="hilfe">Geprüftes Wissen, keine Beobachtung eines Agenten.</span>
</label>
</div>
<div class="formular-aktionen">
<button type="submit">Speichern</button>
<a class="knopf zweitrangig"
href="{{if .Data.IsNew}}/p/{{.Project.Slug}}/{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}{{end}}">Abbrechen</a>
</div>
</form>
</div>
{{template "fuss" .}}

64
templates/entries.html Normal file
View file

@ -0,0 +1,64 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Einträge</h1>
<span class="hilfe">
{{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}}
</span>
</div>
{{if .Session.CanWrite}}
<div class="seitenkopf-aktionen">
<a class="knopf" href="/p/{{.Project.Slug}}/new">Neuer Eintrag</a>
</div>
{{end}}
</div>
<form method="GET" action="/p/{{.Project.Slug}}/" class="werkzeugleiste" role="search">
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Im Projekt suchen…" autocomplete="off">
<button type="submit" class="zweitrangig">Suchen</button>
</form>
{{if .Data.Query}}
<p class="hilfe">{{len .Data.Entries}} Treffer für „{{.Data.Query}}“ ·
<a href="/p/{{.Project.Slug}}/">Suche zurücksetzen</a></p>
{{else if .Data.Tag}}
<p class="hilfe">{{len .Data.Entries}} Einträge mit „{{.Data.Tag}}“ ·
<a href="/p/{{.Project.Slug}}/">Filter aufheben</a></p>
{{end}}
{{if .Data.Entries}}
<ul class="eintraege">
{{$slug := .Project.Slug}}
{{range .Data.Entries}}
<li>
<a class="eintrag-titel" href="/p/{{$slug}}/e/{{.Entry.Slug}}">{{.Entry.Title}}</a>
<div class="meta">
{{if .Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}}
<span class="marke">{{if eq .Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span>
{{range .Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}}
<span>{{datum .Entry.UpdatedAt}}</span>
{{if .Entry.Author}}<span>· {{.Entry.Author}}</span>{{end}}
</div>
{{if .Snippet}}<p class="eintrag-ausschnitt">{{.Snippet}}</p>{{end}}
</li>
{{end}}
</ul>
{{else}}
<div class="leer">
{{if .Data.Query}}
<p>Nichts gefunden für „{{.Data.Query}}“.</p>
<p class="hilfe">Es wird auch nach Wortanfängen gesucht — vielleicht hilft ein anderer Begriff.</p>
{{else if .Data.Tag}}
<p>Keine Einträge mit dem Schlagwort „{{.Data.Tag}}“.</p>
<p><a href="/p/{{.Project.Slug}}/">Alle Einträge anzeigen</a></p>
{{else}}
<p>Noch keine Einträge.</p>
<p class="hilfe">Agenten legen sie über MCP an, Menschen über „Neuer Eintrag“.</p>
{{end}}
</div>
{{end}}
{{template "fuss" .}}

45
templates/entry.html Normal file
View file

@ -0,0 +1,45 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>{{.Data.Entry.Title}}</h1>
</div>
{{if .Session.CanWrite}}
<div class="seitenkopf-aktionen">
<a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/edit">Bearbeiten</a>
<form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/delete" class="reihe"
data-confirm="Eintrag „{{.Data.Entry.Title}}“ wirklich löschen?">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="warnung">Löschen</button>
</form>
</div>
{{end}}
</div>
<div class="meta">
<code>{{.Data.Entry.Slug}}</code>
{{if .Data.Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}}
<span class="marke">{{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span>
{{$slug := .Project.Slug}}
{{range .Data.Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}}
<span>geändert {{datum .Data.Entry.UpdatedAt}}{{if .Data.Entry.Author}} von {{.Data.Entry.Author}}{{end}}</span>
<span>· Fassung {{.Data.Entry.Version}}</span>
</div>
{{/* .HTML ist bereits gerendertes, sicheres HTML — goldmark lässt kein Roh-HTML
aus dem Markdown durch (siehe internal/entry). */}}
<article class="eintrag-text lesebreite">{{.Data.HTML}}</article>
{{if .Data.Backlinks}}
<aside class="rueckverweise">
<h2>Verweist hierher</h2>
<ul>
{{$slug := .Project.Slug}}
{{range .Data.Backlinks}}
<li><a href="/p/{{$slug}}/e/{{.Slug}}">{{.Title}}</a></li>
{{end}}
</ul>
</aside>
{{end}}
{{template "fuss" .}}

130
templates/keys.html Normal file
View file

@ -0,0 +1,130 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Zugänge</h1>
<span class="hilfe">Ein eigener Key je Mensch und je Agent. Nur so steht im
Protokoll, wer etwas geschrieben hat — und ein einzelner Zugang lässt
sich zurückziehen, ohne alle anderen zu stören.</span>
</div>
</div>
{{if .Data.NewKey}}
<div class="karte key-anzeige">
<strong>Key für „{{.Data.NewKeyName}}“</strong>
<code class="key-wert">{{.Data.NewKey}}</code>
<span class="hilfe">Wird nur jetzt angezeigt — gespeichert ist nur eine Prüfsumme.
Geht der Key verloren, muss ein neuer erzeugt werden.</span>
{{/* Drei Clients, drei Schreibweisen für denselben Endpunkt. Der Block steht
hier ausgeschrieben, weil der Klartext-Key nur auf dieser Seite ein
einziges Mal zu sehen ist — wer ihn erst aus dem README zusammensetzen
müsste, hätte ihn dann schon nicht mehr. */}}
<details class="key-anleitung">
<summary>MCP-Client einrichten</summary>
<strong>Claude Code</strong>
<pre><code>claude mcp add --transport http munin-{{.Project.Slug}} https://DEIN-SERVER/mcp \
--header "Authorization: Bearer {{.Data.NewKey}}"</code></pre>
<strong>Cline</strong> <span class="hilfe">(<code>cline_mcp_settings.json</code>)</span>
<pre><code>{
"mcpServers": {
"munin-{{.Project.Slug}}": {
"type": "streamableHttp",
"url": "https://DEIN-SERVER/mcp",
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" },
"autoApprove": ["recall", "get", "list"]
}
}
}</code></pre>
<strong>OpenCode</strong> <span class="hilfe">(<code>opencode.json</code>)</span>
<pre><code>{
"mcp": {
"munin-{{.Project.Slug}}": {
"type": "remote",
"url": "https://DEIN-SERVER/mcp",
"enabled": true,
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" }
}
}
}</code></pre>
</details>
</div>
{{end}}
{{if .Data.Keys}}
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr>
<th>Name</th>
<th>Rolle</th>
<th>Angelegt</th>
<th>Zuletzt benutzt</th>
<th>Status</th>
<th class="schmal rechts"></th>
</tr>
</thead>
<tbody>
{{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}
{{range .Data.Keys}}
<tr {{if .Revoked}}class="gedaempft"{{end}}>
<td>{{.Name}}</td>
<td><code>{{.Role}}</code></td>
<td>{{datum .CreatedAt}}</td>
<td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td>
<td>{{if .Revoked}}zurückgezogen{{else}}aktiv{{end}}</td>
<td class="schmal rechts">
{{if not .Revoked}}
<form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe"
data-confirm="Key „{{.Name}}“ zurückziehen? Alle Clients, die ihn benutzen, verlieren sofort den Zugang.">
<input type="hidden" name="csrf_token" value="{{$csrf}}">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="warnung">Zurückziehen</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="leer">
<p>Noch kein Zugang angelegt.</p>
<p class="hilfe">Ohne Key kann sich niemand mit diesem Projekt verbinden.</p>
</div>
{{end}}
<section class="abschnitt">
<h2>Neuer Zugang</h2>
<div class="karte">
<form method="POST" action="/p/{{.Project.Slug}}/keys/create" class="formular">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="feld mittel">
<label for="name">Name</label>
<span class="hilfe">Wer oder was benutzt diesen Key?</span>
<input type="text" id="name" name="name" required placeholder="z.B. tom-cline oder ci-pipeline">
</div>
<div class="feld kurz">
<label for="role">Rolle</label>
<select id="role" name="role">
<option value="read">read — nur lesen und suchen</option>
<option value="write" selected>write — zusätzlich schreiben</option>
<option value="admin">admin — zusätzlich Zugänge verwalten</option>
</select>
</div>
<div class="formular-aktionen">
<button type="submit">Key erzeugen</button>
</div>
</form>
</div>
</section>
{{template "fuss" .}}

31
templates/login.html Normal file
View file

@ -0,0 +1,31 @@
{{/* Die Anmeldung bekommt einen eigenen, ruhigen Rahmen: keine Navigation, denn
es gibt noch nichts zu navigieren. */}}
{{template "html-anfang" .}}
<div class="anmeldeseite">
<div class="schale schmal">
<div class="karte">
<h1>Munin</h1>
<span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin
verwendet sein Admin-Token.</span>
{{if .Error}}<div class="meldung fehler">{{.Error}}</div>{{end}}
<form method="POST" action="/login" class="formular">
{{/* Ohne Sitzung gibt es kein CSRF-Token, auf das man sich beziehen
könnte. Geschützt ist dieses Formular über SameSite=Lax und
das Rate-Limit. */}}
<div class="feld">
<label for="key">API-Key</label>
<input type="password" id="key" name="key" autofocus
autocomplete="current-password" placeholder="wk_…">
</div>
<div class="formular-aktionen">
<button type="submit">Anmelden</button>
</div>
</form>
</div>
</div>
</div>
{{template "html-ende" .}}

118
templates/partials.html Normal file
View file

@ -0,0 +1,118 @@
{{/*
Gemeinsame Bausteine aller Seiten.
Statt eines verschachtelten Layout-Templates gibt es hier Blöcke, die jede
Seite einbindet. Das ist etwas mehr Tipparbeit je Seite, dafür braucht es
keine Template-Vererbung — und man sieht jeder Seitendatei an, wie sie
aufgebaut ist.
html-anfang / html-ende Dokumentrahmen (auch von der Anmeldeseite genutzt)
kopf / fuss Rahmen, Kopfleiste und Seitenleiste
*/}}
{{define "html-anfang"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} – Munin</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
{{end}}
{{define "html-ende"}}
<script src="/static/confirm.js"></script>
</body>
</html>
{{end}}
{{define "kopf"}}{{template "html-anfang" .}}
{{/* Schmale Kopfleiste über die ganze Breite: Marke und Abmelden. Alles
Weitere steht in der Seitenleiste. */}}
<header class="kopfleiste">
<div class="kopfleiste-innen">
<a class="marke-wort" href="/">Munin</a>
<form method="POST" action="/logout" class="reihe">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="wie-link">Abmelden</button>
</form>
</div>
</header>
<div class="gerüst">
{{/* ── Seitenleiste ──────────────────────────────────────────────────────
Enthält drei Gruppen: die Projekte, die Bereiche des aktuellen Projekts
und dessen Schlagworte. Die Schlagworte stehen hier statt als Filterzeile
über der Liste — sie sind Navigation, und die Liste bekommt so mehr Platz. */}}
<aside class="seitenleiste">
{{if .Session.IsPlatformAdmin}}
<nav class="nav-gruppe">
<h2 class="nav-titel">Projekte</h2>
<ul class="nav-liste">
{{$aktuell := .Project.Slug}}
{{range .Nav.Projects}}
<li><a href="/p/{{.Slug}}/" {{if eq $aktuell .Slug}}class="aktiv"{{end}}>{{.Name}}</a></li>
{{end}}
</ul>
<ul class="nav-liste leise">
<li><a href="/admin/projects" {{if eq .Bereich "projekte"}}class="aktiv"{{end}}>Verwalten…</a></li>
</ul>
</nav>
{{end}}
{{/* Die Bereiche des aktuellen Projekts. Die Überschrift ist der
Projektname — für einen Projekt-Key ist das zugleich die einzige
Ortsangabe, denn eine Projektliste bekommt er nicht zu sehen. */}}
{{if .Project.Slug}}
<nav class="nav-gruppe">
<h2 class="nav-titel">{{.Project.Name}}</h2>
<ul class="nav-liste">
<li><a href="/p/{{.Project.Slug}}/" {{if eq .Bereich "eintraege"}}class="aktiv"{{end}}>Einträge</a></li>
{{if .Session.CanManageKeys}}
<li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li>
{{end}}
<li><a href="/p/{{.Project.Slug}}/audit" {{if eq .Bereich "protokoll"}}class="aktiv"{{end}}>Protokoll</a></li>
</ul>
</nav>
{{if .Nav.Tags}}
<nav class="nav-gruppe schlagworte">
<h2 class="nav-titel">Schlagworte</h2>
<ul class="nav-liste">
{{$slug := .Project.Slug}}{{$tag := .AktivesTag}}
{{range .Nav.Tags}}
<li>
<a href="/p/{{$slug}}/?tag={{.Tag}}" {{if eq $tag .Tag}}class="aktiv"{{end}}>
<span class="nav-text">{{.Tag}}</span>
<span class="nav-zahl">{{.Count}}</span>
</a>
</li>
{{end}}
</ul>
</nav>
{{end}}
{{end}}
{{if .Session.IsPlatformAdmin}}
<nav class="nav-gruppe">
<h2 class="nav-titel">Plattform</h2>
<ul class="nav-liste">
<li><a href="/admin/audit">Protokoll (alle)</a></li>
</ul>
</nav>
{{end}}
</aside>
<main class="inhalt">
{{if .Error}}<div class="meldung fehler">{{.Error}}</div>{{end}}
{{if .Flash}}<div class="meldung hinweis">{{.Flash}}</div>{{end}}
{{end}}
{{define "fuss"}}
</main>
</div>
{{template "html-ende" .}}
{{end}}

View file

@ -0,0 +1,77 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>{{.Data.Item.Project.Name}}</h1>
<span class="hilfe"><code>{{.Data.Item.Project.Slug}}</code> · {{.Data.Item.Entries}} Einträge</span>
</div>
<div class="seitenkopf-aktionen">
<a class="knopf zweitrangig" href="/p/{{.Data.Item.Project.Slug}}/">Zum Projekt</a>
<a class="knopf zweitrangig" href="/p/{{.Data.Item.Project.Slug}}/keys">Zugänge</a>
</div>
</div>
<div class="karte">
<form method="POST" action="/admin/projects/{{.Data.Item.Project.Slug}}/edit" class="formular">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="feld mittel">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Data.Item.Project.Name}}" required autofocus>
</div>
<div class="feld lang">
<label for="description">Beschreibung</label>
<input type="text" id="description" name="description"
value="{{.Data.Item.Project.Description}}" placeholder="optional">
</div>
<div class="feld kurz">
<label for="slug">Bezeichner</label>
<span class="hilfe">Steht fest — er steckt in der MCP-Konfiguration jedes Clients.</span>
<input type="text" id="slug" value="{{.Data.Item.Project.Slug}}" disabled>
</div>
<div class="feld kurz">
<label for="write_limit_hourly">Schreibbudget je Stunde</label>
<span class="hilfe">
0 = Standardwert des Servers{{if .Data.Item.Limit}} ({{.Data.Item.Limit}}){{end}}.
{{if .Data.Item.Limit}}Im laufenden Fenster noch frei: {{.Data.Item.Frei}}.{{end}}
</span>
<input type="number" id="write_limit_hourly" name="write_limit_hourly"
min="0" step="1" value="{{.Data.Item.EigenesLimit}}">
</div>
<div class="formular-aktionen">
<button type="submit">Speichern</button>
<a class="knopf zweitrangig" href="/admin/projects">Abbrechen</a>
</div>
</form>
</div>
<section class="abschnitt">
<h2>Gefahrenzone</h2>
<span class="hilfe">Das Löschen entfernt alle {{.Data.Item.Entries}} Einträge und sämtliche
Zugänge dieses Projekts unwiederbringlich. Der Markdown-Export bleibt erhalten,
sofern einer eingerichtet ist.</span>
<div class="karte gefahrenzone">
<form method="POST" action="/admin/projects/delete" class="formular"
data-confirm="Projekt wirklich löschen? Alle Einträge und Zugänge gehen unwiederbringlich verloren.">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="id" value="{{.Data.Item.Project.ID}}">
<div class="feld kurz">
<label for="confirm">Bezeichner zur Bestätigung</label>
<span class="hilfe">Zum Löschen <code>{{.Data.Item.Project.Slug}}</code> eintippen.</span>
<input type="text" id="confirm" name="confirm" autocomplete="off">
</div>
<div class="formular-aktionen">
<button type="submit" class="warnung">Projekt löschen</button>
</div>
</form>
</div>
</section>
{{template "fuss" .}}

100
templates/projects.html Normal file
View file

@ -0,0 +1,100 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Projekte</h1>
<span class="hilfe">Jedes Projekt hat eigene Einträge, eigene Zugänge und einen
eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span>
</div>
{{if .ExportEnabled}}
<div class="seitenkopf-aktionen">
{{/* Der Export läuft ohnehin täglich. Der Knopf ist für den Fall, dass
man unmittelbar vor einer Sicherung einen aktuellen Stand braucht. */}}
<form method="POST" action="/admin/export" class="reihe">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="zweitrangig">Jetzt exportieren</button>
</form>
</div>
{{end}}
</div>
{{if .Data.Projects}}
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr>
<th>Projekt</th>
<th>Bezeichner</th>
<th class="zahl">Einträge</th>
<th class="zahl">Schreibbudget</th>
<th class="schmal rechts">Verwaltung</th>
</tr>
</thead>
<tbody>
{{range .Data.Projects}}
<tr>
<td>
<a href="/p/{{.Project.Slug}}/">{{.Project.Name}}</a>
{{if .Project.Description}}<div class="hilfe">{{.Project.Description}}</div>{{end}}
</td>
<td><code>{{.Project.Slug}}</code></td>
<td class="zahl">{{.Entries}}</td>
<td class="zahl">
{{if .Limit}}
{{.Frei}} / {{.Limit}}
<div class="hilfe">
{{if .EigenesLimit}}eigene Grenze{{else}}Standard{{end}} · je Stunde
</div>
{{else}}
<span class="hilfe">unbegrenzt</span>
{{end}}
</td>
<td class="schmal rechts">
<a href="/admin/projects/{{.Project.Slug}}/edit">Bearbeiten</a> ·
<a href="/p/{{.Project.Slug}}/keys">Zugänge</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="leer">
<p>Noch kein Projekt angelegt.</p>
<p class="hilfe">Ein Projekt ist die Einheit der Abschottung — meist eines je Team.</p>
</div>
{{end}}
<section class="abschnitt">
<h2>Neues Projekt</h2>
<span class="hilfe">Der Bezeichner erscheint in der URL und in der MCP-Konfiguration
und lässt sich später nicht mehr ändern.</span>
<div class="karte">
<form method="POST" action="/admin/projects/create" class="formular">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="feld mittel">
<label for="name">Name</label>
<input type="text" id="name" name="name" required placeholder="z.B. Plattform-Team">
</div>
<div class="feld kurz">
<label for="slug">Bezeichner</label>
<span class="hilfe">Leer lassen, um ihn aus dem Namen abzuleiten.</span>
<input type="text" id="slug" name="slug" pattern="[a-z0-9][a-z0-9_-]*" placeholder="plattform">
</div>
<div class="feld lang">
<label for="description">Beschreibung</label>
<input type="text" id="description" name="description" placeholder="optional">
</div>
<div class="formular-aktionen">
<button type="submit">Projekt anlegen</button>
</div>
</form>
</div>
</section>
{{template "fuss" .}}