initial commit after rewrite
This commit is contained in:
commit
f846955e58
62 changed files with 13114 additions and 0 deletions
29
.dockerignore
Normal file
29
.dockerignore
Normal 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
80
.env.example
Normal 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
16
.gitignore
vendored
Normal 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
191
AGENTEN-GUIDE.md
Normal 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
448
AUSBAUSTUFEN.md
Normal 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
63
Dockerfile
Normal 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
484
README.md
Normal 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
278
REPOMAP.md
Normal 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
27
go.mod
Normal 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
70
go.sum
Normal 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
487
internal/auth/auth_test.go
Normal 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
104
internal/auth/cookies.go
Normal 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
49
internal/auth/csrf.go
Normal 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
22
internal/auth/doc.go
Normal 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
110
internal/auth/keys.go
Normal 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
249
internal/auth/ratelimit.go
Normal 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
109
internal/auth/session.go
Normal 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)
|
||||
}
|
||||
179
internal/auth/trustedproxy.go
Normal file
179
internal/auth/trustedproxy.go
Normal 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
131
internal/entry/entry.go
Normal 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"
|
||||
}
|
||||
163
internal/entry/entry_test.go
Normal file
163
internal/entry/entry_test.go
Normal 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
|
||||
// "<" statt auf "<script>", weil die Hervorhebung mitten im
|
||||
// escapten Text steht: "<<mark>script</mark>>".
|
||||
if !strings.Contains(got, "<") || !strings.Contains(got, ">") {
|
||||
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
309
internal/export/export.go
Normal 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 + `"`
|
||||
}
|
||||
344
internal/export/export_test.go
Normal file
344
internal/export/export_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
561
internal/mcpserver/mcpserver_test.go
Normal file
561
internal/mcpserver/mcpserver_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
227
internal/mcpserver/server.go
Normal file
227
internal/mcpserver/server.go
Normal 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
419
internal/mcpserver/tools.go
Normal 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
405
internal/search/index.go
Normal 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
102
internal/search/registry.go
Normal 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)
|
||||
}
|
||||
266
internal/search/search_test.go
Normal file
266
internal/search/search_test.go
Normal 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
165
internal/store/audit.go
Normal 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
502
internal/store/entries.go
Normal 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
179
internal/store/keys.go
Normal 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
55
internal/store/links.go
Normal 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)
|
||||
})
|
||||
}
|
||||
126
internal/store/migrations/001_init.sql
Normal file
126
internal/store/migrations/001_init.sql
Normal 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);
|
||||
47
internal/store/migrations/002_projekt_in_nebentabellen.sql
Normal file
47
internal/store/migrations/002_projekt_in_nebentabellen.sql
Normal 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);
|
||||
26
internal/store/migrations/003_schreibbudget_je_projekt.sql
Normal file
26
internal/store/migrations/003_schreibbudget_je_projekt.sql
Normal 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
186
internal/store/models.go
Normal 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
275
internal/store/projects.go
Normal 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
278
internal/store/store.go
Normal 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()
|
||||
}
|
||||
808
internal/store/store_test.go
Normal file
808
internal/store/store_test.go
Normal 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
147
internal/web/context.go
Normal 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)
|
||||
}
|
||||
494
internal/web/handlers_admin.go
Normal file
494
internal/web/handlers_admin.go
Normal 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)
|
||||
}
|
||||
122
internal/web/handlers_auth.go
Normal file
122
internal/web/handlers_auth.go
Normal 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)
|
||||
}
|
||||
286
internal/web/handlers_entries.go
Normal file
286
internal/web/handlers_entries.go
Normal 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
430
internal/web/middleware.go
Normal 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
287
internal/web/server.go
Normal 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
963
internal/web/web_test.go
Normal 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
423
main.go
Normal 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
30
static/confirm.js
Normal 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();
|
||||
}
|
||||
});
|
||||
1
static/img/munin_logo.svg
Normal file
1
static/img/munin_logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 128 KiB |
1
static/img/munin_logo_dark.svg
Normal file
1
static/img/munin_logo_dark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 128 KiB |
1
static/img/munin_logo_lean.svg
Normal file
1
static/img/munin_logo_lean.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 75 KiB |
1
static/img/munin_logo_transparent.svg
Normal file
1
static/img/munin_logo_transparent.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 128 KiB |
678
static/style.css
Normal file
678
static/style.css
Normal 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
52
templates/audit.html
Normal 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
64
templates/edit.html
Normal 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
64
templates/entries.html
Normal 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
45
templates/entry.html
Normal 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
130
templates/keys.html
Normal 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
31
templates/login.html
Normal 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
118
templates/partials.html
Normal 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}}
|
||||
77
templates/project-edit.html
Normal file
77
templates/project-edit.html
Normal 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
100
templates/projects.html
Normal 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" .}}
|
||||
Loading…
Add table
Reference in a new issue