Ausbau, Erweiterung, Graphen, Bugfixes, Export, Stabilität, Lasttest
This commit is contained in:
parent
c4753c5fdb
commit
71c7e9bf5b
57 changed files with 4950 additions and 329 deletions
145
AUSBAUSTUFEN.md
145
AUSBAUSTUFEN.md
|
|
@ -73,6 +73,35 @@ zum Lesen, eine zum Schreiben.
|
||||||
Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100
|
Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100
|
||||||
Schreibvorgänge/s — etwa **1 % der Kapazität**.
|
Schreibvorgänge/s — etwa **1 % der Kapazität**.
|
||||||
|
|
||||||
|
**Nachmessung über den ganzen Weg (2026-10-06):** `last_test.go` lässt Agenten
|
||||||
|
über echtes HTTP und MCP arbeiten, mit Key-Prüfung, Protokoll und Suchindex
|
||||||
|
(Ausführung und alle Ergebnisse in `LASTTEST.md`). 128 Agenten, je 500 Einträge vorab,
|
||||||
|
30 % Schreiben, 20 umkämpfte Bezeichner je Projekt, 10 Sekunden, Testrechner
|
||||||
|
mit 10 Kernen, Clients und Server auf derselben Maschine:
|
||||||
|
|
||||||
|
| Aufstellung | `remember` je s | `remember` p50 / p99 | `recall` p50 / p99 | Fehler |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| ein Projekt | 1.427 | 68 ms / 269 ms | 4,5 ms / 23 ms | 0 |
|
||||||
|
| ein Workspace, 4 Projekte | 1.355 | 73 ms / 286 ms | 4,5 ms / 24 ms | 0 |
|
||||||
|
| 4 Workspaces, je 1 Projekt | 1.328 | 60 ms / 205 ms | 15 ms / 36 ms | 0 |
|
||||||
|
|
||||||
|
- Kein Schreibvorgang ging verloren, der Suchindex kannte in jedem Lauf den
|
||||||
|
letzten Stand.
|
||||||
|
- Projekte im selben Workspace teilen sich die Schreibverbindung: Mehrere
|
||||||
|
Projekte verhalten sich wie eines. Getrennte Workspaces schreiben etwas
|
||||||
|
schneller, der Rechner insgesamt bleibt aber die Grenze (die Suche wird
|
||||||
|
langsamer, weil alles um dieselben Kerne konkurriert).
|
||||||
|
- Gegenüber 8.656/s oben kostet der volle Weg etwa das Sechsfache. 1.300
|
||||||
|
Schreibvorgänge je *Sekunde* stehen einer Vorgabe von 1.000 je *Stunde* und
|
||||||
|
Projekt gegenüber (Schreibbudget 3.8). Last ist bei echter Nutzung kein Thema.
|
||||||
|
|
||||||
|
Dabei gefunden und behoben: Schrieben zwei Agenten gleichzeitig denselben
|
||||||
|
Eintrag, konnte der Suchindex eine ältere Fassung behalten als die Datenbank.
|
||||||
|
`Registry.Refresh` liest seitdem nach jedem Schreiben aus der Datenbank nach,
|
||||||
|
statt den mitgegebenen Stand zu übernehmen. Außerdem hat seitdem jedes Projekt
|
||||||
|
seine eigene Sperre in der Registry: Der Aufbau eines großen Index nach einem
|
||||||
|
Neustart lässt die anderen Projekte desselben Workspace nicht mehr warten.
|
||||||
|
|
||||||
**Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei
|
**Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei
|
||||||
Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key →
|
Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key →
|
||||||
Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt:
|
Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt:
|
||||||
|
|
@ -736,9 +765,9 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang.
|
||||||
- **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`.
|
- **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`.
|
||||||
- **Offen:** Stufe 2 (Ablaufdatum).
|
- **Offen:** Stufe 2 (Ablaufdatum).
|
||||||
|
|
||||||
### 4.8 Admin je Workspace
|
### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30)
|
||||||
|
|
||||||
Heute legt nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40
|
Bis dahin legte nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40
|
||||||
Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle
|
Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle
|
||||||
Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen.
|
Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen.
|
||||||
|
|
||||||
|
|
@ -746,12 +775,50 @@ Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen.
|
||||||
anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch
|
anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch
|
||||||
das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt).
|
das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt).
|
||||||
|
|
||||||
**Abhängigkeit:** Hängt eng mit 4.3 (Keycloak) zusammen. Mit Personen statt Keys
|
**Entscheidung (2026-09-30): ohne Keycloak umsetzen.** Ursprünglich stand hier,
|
||||||
wäre „Workspace-Admin“ einfach eine Gruppenmitgliedschaft. Ohne 4.3 bräuchte es
|
4.8 nur zusammen mit 4.3 zu entscheiden. Keycloak wird aktuell aber nicht
|
||||||
eine eigene Key-Art ohne Projektbindung. Beides zusammen entscheiden, nicht
|
gebraucht, das Nadelöhr beim Plattform-Admin dagegen schon. Kommt 4.3 doch,
|
||||||
4.8 vorziehen und später umbauen.
|
fallen nur die Token-Tabelle und der Anmeldezweig weg. Die Rechteprüfungen
|
||||||
|
(`Session.CanManageProjects`, `CanEditWriteBudget`, `CanImport`, die Routen)
|
||||||
|
bleiben gleich, nur die Herkunft von `IsWorkspaceAdmin` ändert sich.
|
||||||
|
|
||||||
**Aufwand:** hoch.
|
**Umsetzung:**
|
||||||
|
|
||||||
|
- *Eigene Token-Art `wa_…`* in der Kontroll-Datenbank (`workspace_admins`,
|
||||||
|
`migrations_control/002`), Hash statt Klartext, zurückziehbar, mit letzter
|
||||||
|
Nutzung. Mehrere je Workspace, jeder mit Namen der Person, damit das
|
||||||
|
Protokoll sagt, wer etwas getan hat.
|
||||||
|
- *Nur Weboberfläche:* `APIAuth` akzeptiert nur `wk_…` (`LooksLikeKey`). Ein
|
||||||
|
`wa_`-Token scheitert dort schon an der Form und liegt außerdem in einer
|
||||||
|
anderen Tabelle.
|
||||||
|
- *Eigenes Feld `Session.IsWorkspaceAdmin`* statt `IsPlatformAdmin` mitzunutzen.
|
||||||
|
Jede Prüfung auf `IsPlatformAdmin` weist ihn damit von selbst ab. Freigegeben
|
||||||
|
ist nur, was die `Can…`-Methoden ausdrücklich nennen.
|
||||||
|
- *Darf:* Projekte des eigenen Workspace anlegen, bearbeiten, löschen. Zugänge
|
||||||
|
aller Projekte dort verwalten. Alle Projekte lesen, dazu Protokoll, Nutzung
|
||||||
|
und `/admin/audit`.
|
||||||
|
- *Darf nicht:* Einträge schreiben, importieren, Papierkorb. Außerdem
|
||||||
|
Schreibbudget (der Server ignoriert das Feld, es wird also nicht nur im
|
||||||
|
Template ausgeblendet), Export, Workspaces, andere Workspace-Admins anlegen.
|
||||||
|
- *Anlegen und Zurückziehen* nur durch den Plattform-Admin, auf der
|
||||||
|
Bearbeitungsseite des Workspace. Beides und jede Anmeldung stehen im
|
||||||
|
Protokoll des Workspace.
|
||||||
|
- *Ungenutzte Workspace-Admins* markiert wie bei den Keys (4.7, dieselbe Regel
|
||||||
|
über `store.ungenutzt`). Die Workspace-Übersicht zeigt je Workspace die Zahl
|
||||||
|
der aktiven und der ungenutzten Workspace-Admins.
|
||||||
|
|
||||||
|
**Bewusst hingenommen:** Wer Zugänge verwalten darf, kann sich einen
|
||||||
|
`admin`-Key für jedes Projekt seines Workspace anlegen und damit auch
|
||||||
|
schreiben. Das Verbot, Einträge zu schreiben, ist also keine harte Grenze. Es
|
||||||
|
sorgt dafür, dass Änderungen über Keys laufen und im Protokoll eindeutig
|
||||||
|
zuzuordnen sind. Die Rolle gehört deshalb nur an Personen, denen die Abteilung
|
||||||
|
ohnehin alle ihre Projekte anvertraut (so steht es auch in der Hilfe).
|
||||||
|
|
||||||
|
**Tests:** `internal/web/workspace_admin_test.go` (darf / darf nicht,
|
||||||
|
Workspace-Trennung, MCP abgewiesen, Zurückziehen beendet die Sitzung),
|
||||||
|
`TestWorkspaceAdmins`, `TestWorkspaceAdminsVanishWithWorkspace`,
|
||||||
|
`TestWorkspaceAdminUnused`, `TestWorkspaceListCountsActiveAdmins`,
|
||||||
|
`TestWorkspaceAdminTokenIsNoKey`.
|
||||||
|
|
||||||
### 4.9 Zugriff über ein Projekt hinaus — aufgeschoben
|
### 4.9 Zugriff über ein Projekt hinaus — aufgeschoben
|
||||||
|
|
||||||
|
|
@ -842,6 +909,65 @@ messen. Innerhalb eines Workspace erledigt ein solcher `read`-Key nebenbei
|
||||||
auch das öffentliche Projekt. Übrig bliebe nur „öffentlich über
|
auch das öffentliche Projekt. Übrig bliebe nur „öffentlich über
|
||||||
Workspaces hinweg“ (Variante A oben).
|
Workspaces hinweg“ (Variante A oben).
|
||||||
|
|
||||||
|
#### Idee: Mehrere Keys in einem MCP-Block (2026-10-06)
|
||||||
|
|
||||||
|
Eine Alternative zu Workspace-Keys, nur als Idee festgehalten, nicht
|
||||||
|
entschieden. Der Client trägt *einen* Server ein und übergibt alle seine Keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"munin": {
|
||||||
|
"url": "https://munin.firma.de/mcp",
|
||||||
|
"headers": { "X-Munin-Keys": "wk_aaa,wk_bbb,wk_ccc" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Server löst jeden Key einzeln auf dem heutigen Weg auf (Key → Workspace →
|
||||||
|
Projekt + Rolle). Ist einer ungültig, scheitert die ganze Anfrage
|
||||||
|
(fail-closed). Die Werkzeuge bekommen einen Parameter `project`, `recall`
|
||||||
|
sucht ohne Angabe in allen freigeschalteten Projekten. Ein einzelner Key in
|
||||||
|
`Authorization: Bearer` verhält sich wie heute.
|
||||||
|
|
||||||
|
**Vorteile gegenüber Workspace-Keys:**
|
||||||
|
|
||||||
|
- Kein Umbau der Datenbank: `api_keys.project_id` bleibt `NOT NULL`, kein
|
||||||
|
neuer Key-Typ, keine neue Verwaltung.
|
||||||
|
- Rechte bleiben je Key: ein `read`-Key bleibt `read`. Sperren, Protokoll
|
||||||
|
(4.1), Nutzungsübersicht (4.6) und ungenutzte Zugänge (4.7) funktionieren
|
||||||
|
unverändert.
|
||||||
|
- Kein größerer Schaden bei einem verlorenen Key: der Client hält dieselben
|
||||||
|
Keys wie heute, nur in einem statt in N Blöcken.
|
||||||
|
- Schreibbudget (3.8) bleibt je Projekt.
|
||||||
|
- Löst das öffentliche Projekt mit: ein `read`-Key aus einem fremden
|
||||||
|
Workspace ist in der Liste kein Sonderfall, jeder Key wird für sich
|
||||||
|
geroutet.
|
||||||
|
- 6 Werkzeuge im Kontext des Agenten statt 6 × N.
|
||||||
|
|
||||||
|
**Risiken für die Richtigkeit der Antworten:**
|
||||||
|
|
||||||
|
| Risiko | Was passiert | Gegenmittel |
|
||||||
|
|---|---|---|
|
||||||
|
| Wissen vermischt | „Wir deployen mit Helm“ aus A wird als allgemeingültig wiedergegeben, obwohl B Ansible nutzt | Jeder Treffer nennt sein Projekt deutlich, Instructions: Aussagen gelten nur für ihr Projekt, bei Widerspruch nachfragen |
|
||||||
|
| Gleicher Slug in mehreren Projekten | `get deployment` liest das falsche Projekt; schlimmer: `remember` **überschreibt** einen bestehenden Eintrag in B mit Inhalt aus A | `project` ist Pflicht für `get`/`remember`/`forget`/`related`, sobald mehr als ein Key übergeben wird. Fehlt es, Fehlermeldung statt Raten |
|
||||||
|
| Rangfolge unterschlägt Projekte | BM25-Werte getrennter Indizes sind nicht vergleichbar, ein großes Projekt füllt die Top 10, der Agent schließt „nichts bekannt“ | Treffer je Projekt gruppiert zurückgeben (beste N je Projekt) statt einer gemischten Liste |
|
||||||
|
| Dubletten | Wissen aus A wird in B neu angelegt und veraltet dort still | Nur über Instructions zu mindern |
|
||||||
|
|
||||||
|
Nur der gleiche Slug verändert gespeicherte Daten. Die Pflichtangabe von
|
||||||
|
`project` schließt das technisch aus. Die anderen drei betreffen die Qualität
|
||||||
|
der Antworten und lassen sich nur verringern.
|
||||||
|
|
||||||
|
**Aufwand (Schätzung, nicht erprobt):**
|
||||||
|
|
||||||
|
| | Umfang | Aufwand |
|
||||||
|
|---|---|---|
|
||||||
|
| **Stufe 1** | Nur lesend: `recall`/`get`/`list`/`related` über mehrere Keys, schreiben weiter über die Projekt-Server | 3–4 Tage |
|
||||||
|
| **Stufe 2** | Zusätzlich `remember`/`forget` mit Pflichtangabe `project` | +1–2 Tage |
|
||||||
|
|
||||||
|
**Wenn es so weit ist:** mit Stufe 1 beginnen. Dann ist das Überschreiben
|
||||||
|
ausgeschlossen, und an echten Antworten lässt sich sehen, wie stark sich
|
||||||
|
Wissen vermischt, bevor Schreiben dazukommt. Speicher und Kaltstart betrifft
|
||||||
|
nur die Indizes der übergebenen Keys, nicht den ganzen Workspace. Die
|
||||||
|
Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Teil 5 — Oberfläche
|
## Teil 5 — Oberfläche
|
||||||
|
|
@ -1048,8 +1174,9 @@ Als Nächstes:
|
||||||
`remember`-Aufrufe legen neu an, wie viele aktualisieren?
|
`remember`-Aufrufe legen neu an, wie viele aktualisieren?
|
||||||
8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
|
8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
|
||||||
beide ändern den MCP-Vertrag.
|
beide ändern den MCP-Vertrag.
|
||||||
9. **Admin je Workspace** (4.8) zusammen mit **Keycloak** (4.3). Wird dringend,
|
9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak
|
||||||
sobald das Anlegen von Projekten spürbar auf den Plattform-Admin wartet.
|
(siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es
|
||||||
|
gebraucht wird.
|
||||||
|
|
||||||
Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die
|
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
|
Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es
|
||||||
|
|
|
||||||
347
BETRIEB.md
Normal file
347
BETRIEB.md
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
# Betriebshandbuch
|
||||||
|
|
||||||
|
Was tun, wenn etwas schiefgeht? Diese Seite ist für den Ernstfall geschrieben,
|
||||||
|
auch für jemanden, der Munin nicht selbst gebaut hat. Jeder Abschnitt beginnt
|
||||||
|
mit dem, was man sieht, und endet mit dem, was man tut.
|
||||||
|
|
||||||
|
Die Befehle gehen vom Docker-Aufbau aus dem README aus: Container `munin`,
|
||||||
|
Volume unter `/data`. Ohne Docker gilt dasselbe mit dem eigenen `DATA_DIR`
|
||||||
|
statt `/data`, und die Ausgabe des Prozesses statt `docker logs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auf einen Blick
|
||||||
|
|
||||||
|
| Was | Wo |
|
||||||
|
|---|---|
|
||||||
|
| Kontroll-Datenbank | `DATA_DIR/control.db`: Workspaces, welcher Key zu welchem Workspace gehört, Workspace-Admins |
|
||||||
|
| Ein Workspace | `DATA_DIR/workspaces/<workspace-id>.db`: Projekte, Einträge, Keys, Protokoll dieses Workspace |
|
||||||
|
| Zugehörige Hilfsdateien | `…db-wal` und `…db-shm` neben jeder Datenbank. Sie gehören im laufenden Betrieb dazu, **nie einzeln löschen, solange der Server läuft** |
|
||||||
|
| Logs | Standardausgabe des Prozesses: `docker logs munin` |
|
||||||
|
| Sicherungen | `BACKUP_DIR/<zeitstempel>/` von `scripts/backup.sh`, siehe README „Datensicherung“ |
|
||||||
|
| Markdown-Export | `EXPORT_DIR`, falls gesetzt. Lesbar, aber **keine** vollständige Sicherung (keine Keys, keine Historie) |
|
||||||
|
| Geheimnisse | `SESSION_SECRET`, `ADMIN_TOKEN` als Umgebungsvariablen, siehe README „Konfiguration“ |
|
||||||
|
| Lebenszeichen | `GET /healthz` antwortet `ok` |
|
||||||
|
|
||||||
|
Welcher Workspace hinter einer ID steckt:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sqlite3 /data/control.db "SELECT id, name FROM workspaces;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Läuft er?
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s https://<server>/healthz # erwartet: ok
|
||||||
|
docker logs --tail 50 munin
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim Start schreibt Munin diese Zeilen ins Log. Fehlen sie, ist der Start
|
||||||
|
gescheitert:
|
||||||
|
|
||||||
|
```
|
||||||
|
Munin läuft auf http://localhost:8080 (3 Workspaces)
|
||||||
|
Workspaces: /data/workspaces (Kontrolle: /data/control.db)
|
||||||
|
Schreibbudget: 1000 Aufrufe je Projekt und Stunde (Vorgabe)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/healthz` prüft nur, ob der Prozess antwortet, nicht die Datenbanken. Ein
|
||||||
|
`ok` heißt also nicht, dass jeder Workspace lesbar ist. Siehe dazu
|
||||||
|
[Datenbank beschädigt](#datenbank-beschädigt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server startet nicht
|
||||||
|
|
||||||
|
**Man sieht:** Der Container beendet sich sofort, `/healthz` antwortet nicht.
|
||||||
|
|
||||||
|
`docker logs munin` zeigt die Ursache in der letzten Zeile:
|
||||||
|
|
||||||
|
| Meldung | Ursache | Abhilfe |
|
||||||
|
|---|---|---|
|
||||||
|
| `SESSION_SECRET ist nicht gesetzt` / `zu kurz` | Umgebungsvariable fehlt oder hat weniger als 32 Zeichen | Aus dem Passwortspeicher setzen. **Nicht** neu erzeugen, wenn es ein altes gibt, sonst sind alle Anmeldungen weg |
|
||||||
|
| `ADMIN_TOKEN ist nicht gesetzt` / `zu kurz` | dasselbe, mindestens 16 Zeichen | dasselbe |
|
||||||
|
| `Datenverzeichnis konnte nicht angelegt werden` | Volume fehlt oder gehört nicht dem Benutzer im Container (UID 10001) | `chown -R 10001:10001` auf das Volume, oder richtig einhängen |
|
||||||
|
| `unable to open database file`, `permission denied` | dasselbe für eine einzelne Datei, oft nach einem Zurückkopieren als root | Besitzer der Datei korrigieren |
|
||||||
|
| `address already in use` | Ein anderer Prozess belegt den Port | anderen Prozess beenden oder `PORT` ändern |
|
||||||
|
| `database disk image is malformed` | beschädigte Datenbank | [Datenbank beschädigt](#datenbank-beschädigt) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platte voll
|
||||||
|
|
||||||
|
**Man sieht:** Lesen geht noch, Schreiben schlägt fehl. Im Log steht
|
||||||
|
`database or disk is full`, Agenten bekommen Fehler bei `remember`.
|
||||||
|
|
||||||
|
SQLite bleibt dabei heil. Ein Schreibvorgang, der nicht mehr passt, wird
|
||||||
|
vollständig zurückgerollt, halbe Einträge gibt es nicht.
|
||||||
|
|
||||||
|
1. Nachsehen, was wächst: `du -sh /data/* /data/workspaces/* <BACKUP_DIR>/* <EXPORT_DIR>`
|
||||||
|
2. Platz schaffen, in dieser Reihenfolge:
|
||||||
|
- alte Sicherungen löschen und `RETENTION_DAYS` im Cronjob setzen, falls es fehlt
|
||||||
|
- Docker-Logs begrenzen (`--log-opt max-size=50m`)
|
||||||
|
3. **Nicht** die `-wal`- oder `-shm`-Dateien löschen. Darin stehen Änderungen,
|
||||||
|
die noch nicht in der Hauptdatei sind.
|
||||||
|
|
||||||
|
Sobald wieder Platz ist, schreibt Munin ohne Neustart weiter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ein Key ist in falsche Hände geraten
|
||||||
|
|
||||||
|
Zum Beispiel in ein öffentliches Repository eingecheckt, in einem Chat
|
||||||
|
gepostet, oder der Laptop ist weg.
|
||||||
|
|
||||||
|
1. Mit Admin-Rechten anmelden, ins Projekt wechseln, **Zugänge** → beim
|
||||||
|
betroffenen Key **Zurückziehen**. Das wirkt sofort, für MCP-Aufrufe und für
|
||||||
|
Sitzungen in der Weboberfläche. Jede Anfrage prüft den Key neu in der
|
||||||
|
Datenbank.
|
||||||
|
2. Unter **Protokoll** in der Spalte *Wer* nach dem Namen des Keys suchen (ein
|
||||||
|
Filter fehlt bisher, die Suche des Browsers hilft): Was wurde damit getan,
|
||||||
|
von welchen Adressen? Unbekannte Adressen deuten auf Missbrauch hin.
|
||||||
|
3. Hat der Key geschrieben, was er nicht sollte: Einträge über ihre
|
||||||
|
**Historie** auf eine frühere Fassung zurücksetzen, Gelöschtes aus dem
|
||||||
|
**Papierkorb** holen.
|
||||||
|
4. Dem Team einen neuen Key ausstellen.
|
||||||
|
|
||||||
|
Wer den Key nicht kennt, findet ihn über den Workspace: Der Plattform-Admin
|
||||||
|
sieht alle Workspaces, ein Workspace-Admin alle Projekte seines Workspace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ein Workspace-Admin-Token (`wa_…`) ist in falsche Hände geraten
|
||||||
|
|
||||||
|
Ein solches Token öffnet den ganzen Workspace.
|
||||||
|
|
||||||
|
1. Als Plattform-Admin **Workspaces** → **Bearbeiten** → Abschnitt
|
||||||
|
*Workspace-Admins* → **Zurückziehen**. Das wirkt sofort, auch für eine
|
||||||
|
laufende Sitzung.
|
||||||
|
2. **Protokoll (alle)** des Workspace prüfen: angelegte oder gelöschte
|
||||||
|
Projekte, neue Keys. Keys, die in der fraglichen Zeit angelegt wurden,
|
||||||
|
zurückziehen.
|
||||||
|
3. Neues Token für die Person ausstellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `ADMIN_TOKEN` ist in falsche Hände geraten
|
||||||
|
|
||||||
|
Mit dem `ADMIN_TOKEN` kann sich jeder als Plattform-Admin anmelden und kommt
|
||||||
|
an jeden Workspace.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -hex 24 # neues ADMIN_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Neuen Wert im Passwortspeicher ablegen und in der Konfiguration des
|
||||||
|
Containers setzen.
|
||||||
|
2. Container neu starten. **Alle Sitzungen des Plattform-Admins enden damit
|
||||||
|
sofort**, auch die eines Angreifers, der sich schon angemeldet hat. Jede
|
||||||
|
Admin-Sitzung trägt einen Fingerabdruck des Tokens, mit dem sie entstanden
|
||||||
|
ist, und der Server vergleicht ihn bei jeder Anfrage (seit 2026-10-06,
|
||||||
|
siehe `auth.PlatformSubject`). Teams und Workspace-Admins bleiben
|
||||||
|
angemeldet, MCP-Agenten merken nichts.
|
||||||
|
3. [Prüfen, was in der fraglichen Zeit geschah](#was-ist-in-der-fraglichen-zeit-geschehen).
|
||||||
|
|
||||||
|
## `SESSION_SECRET` ist in falsche Hände geraten
|
||||||
|
|
||||||
|
Mit dem `SESSION_SECRET` kann jeder gültige Anmelde-Cookies selbst ausstellen,
|
||||||
|
für jeden Zugang. Da der Fingerabdruck des `ADMIN_TOKEN` ebenfalls aus dem
|
||||||
|
`SESSION_SECRET` berechnet wird, schließt das den Plattform-Admin ein, ohne
|
||||||
|
dass das Token je bekannt war. Das ist der schwerste Fall.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -hex 32 # neues SESSION_SECRET
|
||||||
|
openssl rand -hex 24 # neues ADMIN_TOKEN, zur Sicherheit gleich mit
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Beide Werte im Passwortspeicher ersetzen und in der Konfiguration des
|
||||||
|
Containers setzen.
|
||||||
|
2. Container neu starten. Alle sind danach aus der Weboberfläche abgemeldet und
|
||||||
|
melden sich mit ihrem Key neu an. **MCP-Agenten merken nichts davon**, sie
|
||||||
|
nutzen ihre Keys direkt, nicht die Cookies.
|
||||||
|
3. [Prüfen, was in der fraglichen Zeit geschah](#was-ist-in-der-fraglichen-zeit-geschehen).
|
||||||
|
|
||||||
|
### Was ist in der fraglichen Zeit geschehen?
|
||||||
|
|
||||||
|
- Anmeldungen des Plattform-Admins stehen **nur im Server-Log**, nicht im
|
||||||
|
Protokoll: `docker logs munin 2>&1 | grep "Anmeldung Plattform-Admin"`.
|
||||||
|
Unbekannte Adressen sind ein Warnsignal. Ein selbst ausgestelltes Cookie
|
||||||
|
(geleaktes `SESSION_SECRET`) hinterlässt diese Zeile allerdings nicht.
|
||||||
|
- In jedem Workspace das **Protokoll (alle)**: neue Projekte, Keys und
|
||||||
|
Workspace-Admins. Die Liste der Workspaces auf unbekannte Einträge prüfen.
|
||||||
|
Was nicht von dir stammt, zurückziehen oder löschen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ein Agent dreht durch
|
||||||
|
|
||||||
|
**Man sieht:** Ein Projekt füllt sich mit Unsinn, oder Agenten melden, dass das
|
||||||
|
Schreibbudget erschöpft ist.
|
||||||
|
|
||||||
|
Das Schreibbudget (Vorgabe 1.000 Aufrufe je Projekt und Stunde) begrenzt den
|
||||||
|
Schaden bereits. Ein Agent, der in einer Schleife hängt, wird nach spätestens
|
||||||
|
einer Stunde gebremst.
|
||||||
|
|
||||||
|
1. **Nutzung** im Projekt zeigt, welcher Key wie viel geschrieben hat.
|
||||||
|
2. Den Key des Agenten **zurückziehen** (siehe oben) oder das Team bitten, den
|
||||||
|
Agenten zu stoppen.
|
||||||
|
3. Aufräumen über **Historie** und **Papierkorb**.
|
||||||
|
4. Braucht ein Projekt dauerhaft mehr: Der Plattform-Admin setzt unter
|
||||||
|
*Projekte → Bearbeiten* eine eigene Obergrenze.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einträge versehentlich gelöscht oder überschrieben
|
||||||
|
|
||||||
|
Fast immer ohne Sicherung lösbar:
|
||||||
|
|
||||||
|
- **Überschrieben:** Eintrag öffnen → **Historie** → frühere Fassung
|
||||||
|
wiederherstellen. Jede Fassung bleibt erhalten.
|
||||||
|
- **Gelöscht:** **Papierkorb** im Projekt, 30 Tage lang.
|
||||||
|
- **Endgültig gelöscht oder älter als 30 Tage im Papierkorb:** nur noch aus einer
|
||||||
|
Sicherung. Für einzelne Einträge reicht oft der Markdown-Export: Datei
|
||||||
|
heraussuchen, als ZIP packen und über **Import** im Projekt wieder einlesen.
|
||||||
|
- **Ganzes Projekt gelöscht:** nur aus einer Sicherung, siehe unten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aus einer Sicherung wiederherstellen
|
||||||
|
|
||||||
|
Der Ablauf ist am 2026-10-06 einmal vollständig durchgespielt worden (Sicherung
|
||||||
|
im laufenden Betrieb, Zurückkopieren, Start).
|
||||||
|
|
||||||
|
### Vorher bedenken
|
||||||
|
|
||||||
|
Eine Sicherung setzt **alles** in der zurückgespielten Datei auf ihren Stand
|
||||||
|
zurück, nicht nur das, was man retten will:
|
||||||
|
|
||||||
|
- Einträge und Fassungen nach der Sicherung sind weg.
|
||||||
|
- Keys, die nach der Sicherung **angelegt** wurden, gelten nicht mehr.
|
||||||
|
- Keys, die nach der Sicherung **zurückgezogen** wurden, **gelten wieder.**
|
||||||
|
Nach dem Zurückspielen deshalb unter *Zugänge* nachsehen und erneut
|
||||||
|
zurückziehen. Das ist der gefährlichste Nebeneffekt.
|
||||||
|
|
||||||
|
Wer nur ein paar Einträge braucht, spielt die Sicherung besser in ein
|
||||||
|
**getrenntes Verzeichnis** zurück, startet dort einen zweiten Server auf einem
|
||||||
|
anderen Port, exportiert die Einträge und importiert sie im echten Server.
|
||||||
|
Dann bleibt alles andere unberührt.
|
||||||
|
|
||||||
|
### Einen Workspace zurückholen
|
||||||
|
|
||||||
|
Die Kontroll-Datenbank bleibt dabei, wie sie ist. Die anderen Workspaces merken
|
||||||
|
nichts davon.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Welche Datei? (ID ↔ Name)
|
||||||
|
sqlite3 /data/control.db "SELECT id, name FROM workspaces;"
|
||||||
|
|
||||||
|
# 2. Sicherung prüfen, bevor man sich auf sie verlässt
|
||||||
|
sqlite3 <BACKUP_DIR>/<zeitstempel>/workspaces/<id>.db "PRAGMA integrity_check;" # erwartet: ok
|
||||||
|
|
||||||
|
# 3. Server stoppen
|
||||||
|
docker stop munin
|
||||||
|
|
||||||
|
# 4. Die Hilfsdateien der alten Fassung entfernen. Nach einem Absturz liegen
|
||||||
|
# sie noch da, und SQLite würde sonst ihren Inhalt in die zurückgespielte
|
||||||
|
# Datei übernehmen.
|
||||||
|
rm -f /data/workspaces/<id>.db-wal /data/workspaces/<id>.db-shm
|
||||||
|
|
||||||
|
# 5. Die kaputte oder falsche Datei zur Seite legen, nicht löschen
|
||||||
|
mv /data/workspaces/<id>.db /data/workspaces/<id>.db.vorher
|
||||||
|
|
||||||
|
# 6. Zurückkopieren, Besitzer richtig setzen
|
||||||
|
cp <BACKUP_DIR>/<zeitstempel>/workspaces/<id>.db /data/workspaces/
|
||||||
|
chown 10001:10001 /data/workspaces/<id>.db
|
||||||
|
|
||||||
|
# 7. Starten und prüfen
|
||||||
|
docker start munin
|
||||||
|
curl -s https://<server>/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach in der Weboberfläche den Workspace öffnen und **zurückgezogene Keys
|
||||||
|
erneut zurückziehen** (siehe oben). Läuft alles, kann `<id>.db.vorher` weg.
|
||||||
|
|
||||||
|
### Alles zurückholen
|
||||||
|
|
||||||
|
Wie oben, aber mit `control.db` und **allen** Dateien unter `workspaces/`, und
|
||||||
|
mit allen `-wal`/`-shm`-Dateien. Zusätzlich zu den Keys gelten dann auch
|
||||||
|
Workspace-Admins wieder, die nach der Sicherung zurückgezogen wurden.
|
||||||
|
|
||||||
|
### Ein gelöschter Workspace
|
||||||
|
|
||||||
|
Beim Löschen verschwinden die Datei **und** sein Eintrag in der
|
||||||
|
Kontroll-Datenbank. Nur die Datei zurückzukopieren reicht deshalb nicht, der
|
||||||
|
Server kennt den Workspace nicht mehr. Es braucht auch die `control.db` aus
|
||||||
|
derselben Sicherung, und die setzt alles Zentrale zurück: andere Workspaces,
|
||||||
|
Workspace-Admins, die Zuordnung neuer Keys. Das ist eine bewusste Entscheidung.
|
||||||
|
Meist ist der Weg über einen getrennten zweiten Server mit Export und Import
|
||||||
|
der bessere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank beschädigt
|
||||||
|
|
||||||
|
**Man sieht:** `database disk image is malformed` im Log, oder ein Workspace
|
||||||
|
lässt sich nicht öffnen, während andere gehen. Workspace-Dateien werden erst
|
||||||
|
beim ersten Zugriff geöffnet, deshalb kann der Fehler auch erst Stunden nach
|
||||||
|
dem Start auftauchen.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker stop munin
|
||||||
|
sqlite3 /data/workspaces/<id>.db "PRAGMA integrity_check;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alles außer `ok` heißt: [aus der Sicherung zurückholen](#einen-workspace-zurückholen).
|
||||||
|
Nicht versuchen, die Datei zu reparieren. Mit der letzten guten Sicherung ist
|
||||||
|
man schneller und sicherer am Ziel.
|
||||||
|
|
||||||
|
Häufigste Ursache ist eine Datei, die im laufenden Betrieb mit `cp` statt mit
|
||||||
|
`scripts/backup.sh` kopiert und dann zurückgespielt wurde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Eine neue Fassung einspielen
|
||||||
|
|
||||||
|
Das Schema der Datenbanken wird beim Start automatisch angepasst
|
||||||
|
(`internal/store/migrations*`). Der Weg zurück auf eine ältere Fassung ist
|
||||||
|
danach nicht garantiert. Deshalb:
|
||||||
|
|
||||||
|
1. `scripts/backup.sh` von Hand laufen lassen.
|
||||||
|
2. Neues Abbild bauen, Container ersetzen.
|
||||||
|
3. Log auf die Startzeilen prüfen, `/healthz`, einmal in der Weboberfläche
|
||||||
|
suchen und ein MCP-`recall` absetzen.
|
||||||
|
4. Geht etwas schief: altes Abbild starten **und** die Sicherung aus Schritt 1
|
||||||
|
zurückspielen.
|
||||||
|
|
||||||
|
Ein Neustart beendet laufende Anfragen sauber (bis zu 10 Sekunden). Agenten
|
||||||
|
verbinden sich danach von selbst neu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regelmäßig
|
||||||
|
|
||||||
|
| Wann | Was |
|
||||||
|
|---|---|
|
||||||
|
| wöchentlich | Läuft die Sicherung? Ein Blick ins Log des Cronjobs und auf den neuesten Ordner in `BACKUP_DIR`. |
|
||||||
|
| monatlich | Marke *ungenutzt* bei Keys und Workspace-Admins (Übersicht unter *Workspaces*). Vergessene Zugänge zurückziehen. |
|
||||||
|
| vierteljährlich | **Probe der Sicherung:** die neueste Sicherung in ein leeres Verzeichnis kopieren, `PRAGMA integrity_check` für jede Datei, einen zweiten Server mit `DATA_DIR` darauf und anderem `PORT` starten, einmal anmelden und suchen. Eine Sicherung, die nie zurückgespielt wurde, ist nur eine Hoffnung. |
|
||||||
|
| vor jedem Update | Sicherung von Hand, siehe oben |
|
||||||
|
|
||||||
|
Die Probe der Sicherung ohne Docker, auf einem beliebigen Rechner mit Go und
|
||||||
|
sqlite3:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
probe=$(mktemp -d)
|
||||||
|
cp -r <BACKUP_DIR>/<zeitstempel>/. "$probe/"
|
||||||
|
for f in "$probe"/control.db "$probe"/workspaces/*.db; do
|
||||||
|
echo "$f: $(sqlite3 "$f" 'PRAGMA integrity_check;')"
|
||||||
|
done
|
||||||
|
DATA_DIR="$probe" PORT=18080 SESSION_SECRET=$(openssl rand -hex 32) \
|
||||||
|
ADMIN_TOKEN=probe-probe-probe-1 go run .
|
||||||
|
# im Browser: http://localhost:18080, Anmeldung mit probe-probe-probe-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Probe darf mit eigenen Geheimnissen laufen. Sie braucht weder die echten
|
||||||
|
noch erreicht sie den echten Server.
|
||||||
224
LASTTEST.md
Normal file
224
LASTTEST.md
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
# Lasttest
|
||||||
|
|
||||||
|
Wie viel hält Munin aus, wenn viele Agenten gleichzeitig lesen und schreiben,
|
||||||
|
in einem Projekt, in mehreren Projekten eines Workspace oder über mehrere
|
||||||
|
Workspaces verteilt? Der Lasttest in `last_test.go` beantwortet das mit echten
|
||||||
|
Aufrufen über HTTP und MCP, also über denselben Weg wie im Betrieb:
|
||||||
|
Key-Prüfung, Protokoll, Datenbank und Suchindex.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausführen
|
||||||
|
|
||||||
|
Der Test läuft **nicht** bei `go test ./...` mit. Er braucht Zeit und lastet
|
||||||
|
den Rechner voll aus. Das Build-Tag `last` schaltet ihn erst ein, wenn man
|
||||||
|
ausdrücklich danach fragt:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -tags last -run TestLast -v .
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit eigenen Werten:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -tags last -run TestLast -v . -last.agenten=128 -last.dauer=30s
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit dem Race-Detektor, der Datenwettläufe zwischen Goroutinen meldet. Er macht
|
||||||
|
alles etwa zehnmal langsamer, deshalb mit kleineren Werten:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -race -tags last -run TestLast . -last.dauer=3s -last.agenten=16 -last.eintraege=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test legt alles in einem temporären Verzeichnis an und räumt danach auf.
|
||||||
|
Echte Daten in `data/` fasst er nicht an.
|
||||||
|
|
||||||
|
### Schalter
|
||||||
|
|
||||||
|
| Schalter | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `-last.agenten` | 32 | Gleichzeitige Agenten je Aufstellung. Jeder hat einen eigenen Key und eine eigene MCP-Sitzung. |
|
||||||
|
| `-last.dauer` | 10s | Laufzeit je Aufstellung. Es gibt drei Aufstellungen, die Gesamtdauer ist also etwa das Dreifache. |
|
||||||
|
| `-last.projekte` | 4 | Zahl der Projekte bzw. Workspaces in den Aufstellungen mit mehreren |
|
||||||
|
| `-last.eintraege` | 500 | Einträge, die jedes Projekt vorab bekommt, damit die Suche einen realistischen Index hat |
|
||||||
|
| `-last.slugs` | 20 | Bezeichner, auf die die Agenten je Projekt schreiben. Weniger heißt mehr Gedränge auf denselben Einträgen. |
|
||||||
|
| `-last.schreibanteil` | 30 | Anteil von `remember` in Prozent. Der Rest teilt sich 2 : 1 auf `recall` und `get` auf. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was der Test macht
|
||||||
|
|
||||||
|
### Drei Aufstellungen
|
||||||
|
|
||||||
|
Alle drei laufen mit derselben Zahl Agenten, damit die Zahlen vergleichbar
|
||||||
|
sind:
|
||||||
|
|
||||||
|
| Aufstellung | Was sie zeigt |
|
||||||
|
|---|---|
|
||||||
|
| **ein Projekt** | Alle Agenten im selben Projekt. Der Grundwert. |
|
||||||
|
| **ein Workspace, N Projekte** | Die Projekte liegen in einer Datei und teilen sich die **eine Schreibverbindung** (siehe `openPool` in `internal/store/store.go`). Bremsen sich die Projekte gegenseitig? |
|
||||||
|
| **N Workspaces, je 1 Projekt** | Jeder Workspace hat seine eigene Datei. Hier kann echt parallel geschrieben werden. |
|
||||||
|
|
||||||
|
### Was ein Agent tut
|
||||||
|
|
||||||
|
Jeder Agent verbindet sich mit seinem Key über `/mcp` und ruft bis zum Ende der
|
||||||
|
Laufzeit in einer Schleife zufällig auf:
|
||||||
|
|
||||||
|
- `remember` auf einen der umkämpften Bezeichner (`gemeinsam-00` …). Jeder
|
||||||
|
Schreibvorgang enthält ein **eindeutiges Wort**, an dem sich später erkennen
|
||||||
|
lässt, welche Fassung wo gelandet ist.
|
||||||
|
- `recall` mit einem von acht Fachbegriffen, die in den vorab angelegten
|
||||||
|
Einträgen vorkommen
|
||||||
|
- `get` auf einen der vorab angelegten Einträge
|
||||||
|
|
||||||
|
Das Schreibbudget (Ausbaustufe 3.8) ist im Test abgeschaltet. Es soll eine
|
||||||
|
Schleife stoppen und würde sonst die Messung abwürgen.
|
||||||
|
|
||||||
|
### Was am Ende geprüft wird
|
||||||
|
|
||||||
|
Der Test schlägt fehl, wenn eines davon nicht stimmt:
|
||||||
|
|
||||||
|
1. **Kein Aufruf ist fehlgeschlagen.**
|
||||||
|
2. **Kein Schreibvorgang ging verloren:** Jedes erfolgreiche `remember` erzeugt
|
||||||
|
eine Fassung. Die Summe der Fassungen aller umkämpften Einträge muss genau
|
||||||
|
der Zahl der erfolgreichen Aufrufe entsprechen.
|
||||||
|
3. **Der Suchindex kennt den letzten Stand:** Das eindeutige Wort der letzten
|
||||||
|
gespeicherten Fassung muss den Eintrag über die Suche finden.
|
||||||
|
|
||||||
|
### Die Ausgabe lesen
|
||||||
|
|
||||||
|
Für jede Aufstellung erscheint eine Tabelle:
|
||||||
|
|
||||||
|
```
|
||||||
|
Werkzeug Aufrufe je s p50 p95 p99 max Fehler
|
||||||
|
verbinden 128 0 18.03ms 33.46ms 35.04ms 35.54ms 0
|
||||||
|
recall 21496 2150 4.45ms 8.02ms 23.05ms 40.33ms 0
|
||||||
|
get 11381 1138 1.04ms 2.4ms 3.18ms 7.33ms 0
|
||||||
|
remember 14269 1427 67.84ms 190.04ms 269.16ms 439.31ms 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- **je s:** erfolgreiche Aufrufe pro Sekunde
|
||||||
|
- **p50:** Die Hälfte der Aufrufe war schneller, also die typische Dauer.
|
||||||
|
- **p95 / p99:** Die langsamsten 5 % bzw. 1 % der Aufrufe dauerten mindestens
|
||||||
|
so lange. Das sind die Werte, die ein Nutzer als „hängt manchmal“ erlebt.
|
||||||
|
- **max:** der langsamste Aufruf überhaupt
|
||||||
|
- **verbinden:** der MCP-Verbindungsaufbau, einmal je Agent
|
||||||
|
|
||||||
|
Darunter steht `Bestand geprüft: … Fassungen, Suchindex aktuell`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ergebnis vom 2026-10-06
|
||||||
|
|
||||||
|
**Testrechner:** Mac mit 10 Kernen. Clients und Server liefen auf derselben
|
||||||
|
Maschine, jeweils 500 Einträge vorab, 30 % Schreiben, 20 umkämpfte Bezeichner
|
||||||
|
je Projekt, 4 Projekte bzw. Workspaces.
|
||||||
|
|
||||||
|
### 128 Agenten, 10 Sekunden
|
||||||
|
|
||||||
|
| Aufstellung | `remember` je s | `remember` p50 / p95 / p99 | `recall` p50 / p99 | `get` p50 / p99 | Fehler |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| ein Projekt | 1.427 | 68 / 190 / 269 ms | 4,5 / 23 ms | 1,0 / 3,2 ms | 0 |
|
||||||
|
| ein Workspace, 4 Projekte | 1.355 | 73 / 201 / 286 ms | 4,5 / 24 ms | 1,0 / 3,4 ms | 0 |
|
||||||
|
| 4 Workspaces, je 1 Projekt | 1.328 | 60 / 151 / 205 ms | 15 / 36 ms | 3,8 / 12 ms | 0 |
|
||||||
|
|
||||||
|
### 32 Agenten, 5 Sekunden
|
||||||
|
|
||||||
|
| Aufstellung | `remember` je s | `remember` p50 / p99 | `recall` p50 / p99 | Fehler |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| ein Projekt | 1.374 | 13 / 42 ms | 4,7 / 11 ms | 0 |
|
||||||
|
| ein Workspace, 4 Projekte | 1.387 | 13 / 46 ms | 4,4 / 10 ms | 0 |
|
||||||
|
| 4 Workspaces, je 1 Projekt | 1.141 | 8 / 18 ms | 10 / 24 ms | 0 |
|
||||||
|
|
||||||
|
In allen Läufen ging kein Schreibvorgang verloren, und der Suchindex war am
|
||||||
|
Ende aktuell. Auch der Lauf mit Race-Detektor war ohne Befund.
|
||||||
|
|
||||||
|
### Was die Zahlen sagen
|
||||||
|
|
||||||
|
- **Last ist bei echter Nutzung kein Thema.** Der Server schafft rund 1.300
|
||||||
|
Schreibvorgänge pro *Sekunde*. Das Schreibbudget erlaubt 1.000 pro *Stunde*
|
||||||
|
und Projekt, und echte Agenten liegen bei ein paar Dutzend pro Stunde.
|
||||||
|
- **Projekte im selben Workspace verhalten sich wie ein Projekt.** Sie teilen
|
||||||
|
sich die Schreibverbindung, Schreibvorgänge stellen sich also an. Mit mehr
|
||||||
|
Agenten wachsen deshalb die Wartezeiten beim Schreiben (p50 von 13 ms bei 32
|
||||||
|
auf 68 ms bei 128 Agenten), aber nicht die Fehler.
|
||||||
|
- **Mehrere Workspaces schreiben etwas schneller, suchen aber langsamer.**
|
||||||
|
Getrennte Dateien heben die Warteschlange beim Schreiben auf. Dafür arbeiten
|
||||||
|
dann mehr Schreiber gleichzeitig und konkurrieren mit der Suche um dieselben
|
||||||
|
Kerne. Auf einem Rechner ist die CPU die eigentliche Grenze, nicht SQLite.
|
||||||
|
- **Der volle Weg kostet etwa das Sechsfache** gegenüber dem reinen Schreiben in
|
||||||
|
die Datenbank (8.656/s, siehe `AUSBAUSTUFEN.md` 1.2). Dazu kommen HTTP, MCP,
|
||||||
|
Key-Prüfung, Protokolleintrag und Suchindex.
|
||||||
|
|
||||||
|
### Grenzen der Messung
|
||||||
|
|
||||||
|
- **Clients und Server teilen sich den Rechner.** Die Agenten verbrauchen selbst
|
||||||
|
CPU. Ein Server allein schafft mehr, die Zahlen sind eher eine Untergrenze.
|
||||||
|
- **Kaltstart nicht gemessen.** Der Suchindex jedes Projekts ist nach dem
|
||||||
|
Anlegen der Einträge schon aufgebaut. Der erste Zugriff auf ein großes
|
||||||
|
Projekt nach einem Neustart (etwa 170 ms je 10.000 Einträge, siehe
|
||||||
|
`AUSBAUSTUFEN.md` 1.1) ist nicht enthalten. Während dieses Aufbaus wartet nur
|
||||||
|
dieses eine Projekt. Bis 2026-10-06 warteten auch alle anderen Projekte
|
||||||
|
desselben Workspace, das ist behoben (siehe unten).
|
||||||
|
- **Nur MCP.** Die Weboberfläche ist nicht dabei.
|
||||||
|
- **Kein Rückkanal:** Der zusätzliche SSE-Kanal, den manche MCP-Clients offen
|
||||||
|
halten, ist abgeschaltet. Er kostet je Agent eine offene Verbindung, aber
|
||||||
|
kaum Rechenzeit.
|
||||||
|
- **Gleichmäßige Dauerlast.** Echte Agenten arbeiten in Schüben mit Pausen
|
||||||
|
dazwischen. Der Test ist also deutlich härter als die Wirklichkeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dabei gefunden
|
||||||
|
|
||||||
|
**Suchindex hielt eine ältere Fassung (behoben).** Speichern und Index
|
||||||
|
aktualisieren waren zwei getrennte Schritte, und der Index übernahm den Stand,
|
||||||
|
den der Aufrufer mitgab. Überholten sich zwei Schreiber auf demselben Eintrag
|
||||||
|
zwischen diesen Schritten, stand Fassung 3 in der Datenbank, aber Fassung 2 im
|
||||||
|
Index. `recall` fand den Eintrag dann bis zum Neustart über veraltete Wörter.
|
||||||
|
Genauso konnte ein gerade gelöschter Eintrag durch eine verspätete
|
||||||
|
Aktualisierung wieder in der Suche auftauchen.
|
||||||
|
|
||||||
|
Unter Last ließ sich das nicht zuverlässig auslösen, das Zeitfenster liegt bei
|
||||||
|
Mikrosekunden. Mit gezielt vertauschter Reihenfolge war es sofort
|
||||||
|
reproduzierbar.
|
||||||
|
|
||||||
|
**Behebung:** `Registry.Refresh` in `internal/search/registry.go` liest nach
|
||||||
|
jedem Schreiben den aktuellen Stand aus der Datenbank nach. Der letzte Aufruf
|
||||||
|
liest damit immer den neuesten Stand, egal in welcher Reihenfolge die Aufrufe
|
||||||
|
ankommen. Abgesichert durch `internal/search/registry_test.go` und
|
||||||
|
`internal/mcpserver/gleichzeitig_test.go`.
|
||||||
|
|
||||||
|
**Aufbau des Suchindex blockierte den ganzen Workspace (behoben).** Die
|
||||||
|
Registry hatte nur eine Sperre für alle Projekte eines Workspace und hielt sie
|
||||||
|
auch, während der Index eines Projekts aus der Datenbank aufgebaut wurde. Nach
|
||||||
|
einem Neustart musste so jedes Projekt warten, bis das größte fertig
|
||||||
|
eingelesen war. Seit 2026-10-06 hat jedes Projekt seine eigene Sperre.
|
||||||
|
Abgesichert durch `TestLangsamerAufbauBremstAndereProjekteNicht` und
|
||||||
|
`TestAenderungWaehrendDesAufbausGehtNichtVerloren`: Ein Schreibvorgang
|
||||||
|
während des Aufbaus wartet, bis der Aufbau fertig ist, und trägt seinen Stand
|
||||||
|
danach nach.
|
||||||
|
|
||||||
|
**Stolperstein beim Messen:** Mit den Vorgaben von Go hält ein HTTP-Client nur
|
||||||
|
zwei Verbindungen je Ziel offen. Bei 128 Agenten öffnete der Test dadurch
|
||||||
|
laufend neue Verbindungen, bis dem Mac die lokalen Ports ausgingen
|
||||||
|
(`connect: can't assign requested address`). Das ist ein Fehler der Messung,
|
||||||
|
nicht des Servers, denn echte Agenten sitzen auf verschiedenen Rechnern. Der
|
||||||
|
Test nutzt deshalb einen gemeinsamen Transport mit einer offenen Verbindung je
|
||||||
|
Agent. Taucht die Meldung wieder auf, liegt die Ursache dort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wann erneut messen
|
||||||
|
|
||||||
|
- Nach Änderungen am Schreibweg: `PutEntry`, Protokoll, Suchindex,
|
||||||
|
Verbindungspools in `internal/store/store.go`
|
||||||
|
- Vor einem Umzug auf andere Hardware oder in einen Container mit begrenzter
|
||||||
|
CPU. Dann am besten Server und Test auf getrennten Rechnern, oder den Test
|
||||||
|
mit weniger Agenten.
|
||||||
|
- Wenn `AUSBAUSTUFEN.md` 4.9 (mehrere Keys in einem MCP-Block) gebaut wird:
|
||||||
|
Ein `recall` über mehrere Projekte belastet die Suche anders als heute.
|
||||||
|
|
||||||
|
Neue Ergebnisse bitte mit Datum unter **Ergebnis** ergänzen, die alten stehen
|
||||||
|
lassen. So bleibt sichtbar, wie sich Änderungen ausgewirkt haben.
|
||||||
32
README.md
32
README.md
|
|
@ -261,6 +261,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
|
||||||
| `/admin/workspaces` | Workspaces auflisten, anlegen und wählen (nur Plattform-Admin) |
|
| `/admin/workspaces` | Workspaces auflisten, anlegen und wählen (nur Plattform-Admin) |
|
||||||
| `/admin/workspaces/{id}/edit` | Workspace umbenennen oder löschen |
|
| `/admin/workspaces/{id}/edit` | Workspace umbenennen oder löschen |
|
||||||
| `/admin/projects` | Projekte des gewählten Workspace anlegen und auflisten, mit Schreibvorgängen der letzten 30 Tage je Projekt; Markdown-Export anstoßen |
|
| `/admin/projects` | Projekte des gewählten Workspace anlegen und auflisten, mit Schreibvorgängen der letzten 30 Tage je Projekt; Markdown-Export anstoßen |
|
||||||
|
| `/admin/export.zip` | Alle Projekte des gewählten Workspace als ZIP herunterladen (Plattform- und Workspace-Admin) |
|
||||||
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
|
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
|
||||||
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace |
|
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace |
|
||||||
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, Schlagworte für viele Einträge auf einmal ändern |
|
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, Schlagworte für viele Einträge auf einmal ändern |
|
||||||
|
|
@ -271,6 +272,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
|
||||||
| `/p/{projekt}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen |
|
| `/p/{projekt}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen |
|
||||||
| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph |
|
| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph |
|
||||||
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) |
|
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) |
|
||||||
|
| `/p/{projekt}/export.zip` | Projekt als ZIP herunterladen, passt direkt in den Import (Rolle `admin`) |
|
||||||
| `/p/{projekt}/usage` | Nutzung: Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Bestand, Verweise ins Leere, aktive Zugänge |
|
| `/p/{projekt}/usage` | Nutzung: Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Bestand, Verweise ins Leere, aktive Zugänge |
|
||||||
| `/p/{projekt}/audit` | Protokoll des Projekts |
|
| `/p/{projekt}/audit` | Protokoll des Projekts |
|
||||||
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) |
|
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) |
|
||||||
|
|
@ -347,6 +349,7 @@ besser als unsicherer Betrieb.
|
||||||
| Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` |
|
| Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` |
|
||||||
| API-Keys nur als SHA-256-Prüfsumme gespeichert | `internal/auth/keys.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` |
|
| Zurückgezogener Key beendet laufende Sitzungen sofort | `internal/web/middleware.go` |
|
||||||
|
| Neues `ADMIN_TOKEN` beendet alle Sitzungen des Plattform-Admins sofort | `internal/auth/session.go` (`PlatformSubject`) |
|
||||||
| CSRF-Token in allen Formularen | `internal/auth/csrf.go` |
|
| CSRF-Token in allen Formularen | `internal/auth/csrf.go` |
|
||||||
| `SameSite=Lax`, `HttpOnly`, `Secure` | `internal/auth/cookies.go` |
|
| `SameSite=Lax`, `HttpOnly`, `Secure` | `internal/auth/cookies.go` |
|
||||||
| Rate-Limiting je Adresse, nur auf Fehlversuche | `internal/auth/ratelimit.go` |
|
| Rate-Limiting je Adresse, nur auf Fehlversuche | `internal/auth/ratelimit.go` |
|
||||||
|
|
@ -393,12 +396,14 @@ trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jed
|
||||||
|
|
||||||
- **Keine Benutzerkonten.** Zugänge sind Keys, keine Personen. Ein Key je Person
|
- **Keine Benutzerkonten.** Zugänge sind Keys, keine Personen. Ein Key je Person
|
||||||
ist der Ersatz — geplant ist später Keycloak.
|
ist der Ersatz — geplant ist später Keycloak.
|
||||||
- **Projekte pflegt nur der Plattform-Admin.** Ein Team kann seinen eigenen
|
- **Projekte pflegen Plattform-Admin und Workspace-Admins.** Ein Team mit
|
||||||
Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist
|
einem Projekt-Key kann seinen eigenen Projektnamen nicht selbst korrigieren,
|
||||||
eine Berechtigungsstufe weniger den Umweg wert.
|
dafür braucht es den Admin seines Workspace (AUSBAUSTUFEN.md 4.8).
|
||||||
- **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor.
|
- **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor.
|
||||||
- **Ein Admin-Token für alle Workspaces.** Es gibt keinen Admin je Workspace.
|
- **Ein Plattform-Admin über allem.** Workspace-Admins sind auf ihren
|
||||||
Wer den `ADMIN_TOKEN` hat, kommt an jeden Workspace.
|
Workspace beschränkt, aber wer den `ADMIN_TOKEN` hat, kommt an jeden
|
||||||
|
Workspace. Ein neues Token beendet alle Sitzungen damit sofort (siehe
|
||||||
|
`BETRIEB.md`).
|
||||||
- **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es
|
- **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es
|
||||||
wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche
|
wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche
|
||||||
Zusage ist es nicht.
|
Zusage ist es nicht.
|
||||||
|
|
@ -407,6 +412,10 @@ trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jed
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
|
Was im Ernstfall zu tun ist (Server startet nicht, Platte voll, Key geleakt,
|
||||||
|
Wiederherstellen aus der Sicherung), steht im Betriebshandbuch
|
||||||
|
[`BETRIEB.md`](BETRIEB.md).
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -459,7 +468,9 @@ Für Cron, täglich um 3 Uhr mit 14 Tagen Aufbewahrung:
|
||||||
Da jede Abteilung ihre eigene Datei hat, lässt sich eine einzelne davon auch
|
Da jede Abteilung ihre eigene Datei hat, lässt sich eine einzelne davon auch
|
||||||
gezielt sichern oder wiederherstellen, ohne die übrigen anzufassen: Server
|
gezielt sichern oder wiederherstellen, ohne die übrigen anzufassen: Server
|
||||||
stoppen, die gewünschte `.db`-Datei aus einem Sicherungsordner zurückkopieren,
|
stoppen, die gewünschte `.db`-Datei aus einem Sicherungsordner zurückkopieren,
|
||||||
Server wieder starten.
|
Server wieder starten. Die genauen Schritte und was dabei zu beachten ist
|
||||||
|
(Hilfsdateien `-wal`/`-shm`, wieder gültige Keys) stehen in
|
||||||
|
[`BETRIEB.md`](BETRIEB.md#aus-einer-sicherung-wiederherstellen).
|
||||||
|
|
||||||
**Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand
|
**Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand
|
||||||
aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace,
|
aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace,
|
||||||
|
|
@ -503,6 +514,15 @@ Scheitert der Export eines Workspace, laufen die übrigen trotzdem durch.
|
||||||
`./munin -export` endet dann aber mit einem Fehlercode, damit ein Cronjob die
|
`./munin -export` endet dann aber mit einem Fehlercode, damit ein Cronjob die
|
||||||
Sicherung nicht für vollständig hält.
|
Sicherung nicht für vollständig hält.
|
||||||
|
|
||||||
|
**Download als ZIP.** Unabhängig von `EXPORT_DIR` lässt sich derselbe Inhalt
|
||||||
|
auch direkt im Browser herunterladen: ein Projekt über *Als ZIP herunterladen*
|
||||||
|
über der Eintragsliste (Rolle `admin`), ein ganzer Workspace über *Alle als ZIP
|
||||||
|
herunterladen* unter *Projekte*. Das ZIP entsteht bei jedem Klick frisch und
|
||||||
|
wird nicht auf dem Server abgelegt. Es hat denselben Aufbau wie der Export und
|
||||||
|
passt deshalb ohne Umpacken in den Import. Jeder Download steht im Protokoll
|
||||||
|
(`export_download`). Wer außer Admins herunterladen darf, steht an einer
|
||||||
|
einzigen Stelle: `Session.CanDownloadProject` in `internal/web/context.go`.
|
||||||
|
|
||||||
Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien
|
Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien
|
||||||
wirken nicht zurück und gehen beim nächsten Lauf verloren.
|
wirken nicht zurück und gehen beim nächsten Lauf verloren.
|
||||||
|
|
||||||
|
|
|
||||||
87
REPOMAP.md
87
REPOMAP.md
|
|
@ -9,12 +9,14 @@ munin/
|
||||||
│ Mindestlänge), öffnet Kontroll-Datenbank und
|
│ Mindestlänge), öffnet Kontroll-Datenbank und
|
||||||
│ Workspace-Verwaltung (legt beim ersten Start den
|
│ Workspace-Verwaltung (legt beim ersten Start den
|
||||||
│ Workspace "Intern" an), setzt die
|
│ Workspace "Intern" an), setzt die
|
||||||
│ Bestandteile zusammen und startet den Server.
|
│ Bestandteile zusammen (baueHandler, auch vom
|
||||||
|
│ Lasttest genutzt) und startet den Server.
|
||||||
│ Enthält außerdem die Server-Timeouts (gegen
|
│ Enthält außerdem die Server-Timeouts (gegen
|
||||||
│ Slowloris), die Body-Größenlimits und die
|
│ Slowloris), die Body-Größenlimits und die
|
||||||
│ täglichen Aufgaben (Protokoll aufräumen,
|
│ täglichen Aufgaben (Protokoll aufräumen,
|
||||||
│ Export), die über fuerJedenWorkspace alle
|
│ Export), die über fuerJedenWorkspace alle
|
||||||
│ Workspaces erreichen. Tests: main_test.go.
|
│ Workspaces erreichen. Tests: main_test.go,
|
||||||
|
│ Lasttest: last_test.go (nur mit -tags last).
|
||||||
│
|
│
|
||||||
├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten.
|
├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten.
|
||||||
│
|
│
|
||||||
|
|
@ -33,9 +35,14 @@ munin/
|
||||||
│ damit sie den Speicher tatsächlich nutzen:
|
│ damit sie den Speicher tatsächlich nutzen:
|
||||||
│ Projektprompt zum Übernehmen, slug-Regeln,
|
│ Projektprompt zum Übernehmen, slug-Regeln,
|
||||||
│ Muster für einzelne Aufgaben.
|
│ Muster für einzelne Aufgaben.
|
||||||
|
├── BETRIEB.md ← Betriebshandbuch für den Ernstfall: Server startet
|
||||||
|
│ nicht, Platte voll, Key oder Geheimnis geleakt,
|
||||||
|
│ Wiederherstellen aus der Sicherung, Updates.
|
||||||
├── AUSBAUSTUFEN.md ← Was bewusst nicht gebaut wurde: Messwerte,
|
├── AUSBAUSTUFEN.md ← Was bewusst nicht gebaut wurde: Messwerte,
|
||||||
│ Begründung und die Auslöser, an denen man
|
│ Begründung und die Auslöser, an denen man
|
||||||
│ merkt, dass es Zeit dafür wird.
|
│ merkt, dass es Zeit dafür wird.
|
||||||
|
├── LASTTEST.md ← Lasttest: Ausführung, Schalter, Ergebnisse mit
|
||||||
|
│ Datum und ihre Grenzen.
|
||||||
├── REPOMAP.md ← Diese Datei.
|
├── REPOMAP.md ← Diese Datei.
|
||||||
│
|
│
|
||||||
├── internal/
|
├── internal/
|
||||||
|
|
@ -168,7 +175,9 @@ munin/
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── session.go ← Signierte Sitzungs-Cookies (HMAC-SHA256). Der
|
│ │ ├── session.go ← Signierte Sitzungs-Cookies (HMAC-SHA256). Der
|
||||||
│ │ │ Cookie trägt nur eine Behauptung ("ich bin Key X"),
|
│ │ │ Cookie trägt nur eine Behauptung ("ich bin Key X"),
|
||||||
│ │ │ nie den Key selbst.
|
│ │ │ nie den Key selbst. Die Sitzung des Plattform-
|
||||||
|
│ │ │ Admins trägt einen Fingerabdruck des ADMIN_TOKEN,
|
||||||
|
│ │ │ damit ein neues Token sie sofort beendet.
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── csrf.go ← CSRF-Tokens aus Sitzung und Geheimnis, ohne Zustand
|
│ │ ├── csrf.go ← CSRF-Tokens aus Sitzung und Geheimnis, ohne Zustand
|
||||||
│ │ │ auf dem Server. Der Kopfkommentar erklärt den
|
│ │ │ auf dem Server. Der Kopfkommentar erklärt den
|
||||||
|
|
@ -214,17 +223,21 @@ munin/
|
||||||
│ │ "?"-Links der Oberfläche darauf zeigen können.
|
│ │ "?"-Links der Oberfläche darauf zeigen können.
|
||||||
│ │
|
│ │
|
||||||
│ ├── export/ ── Sicherung als Markdown ─────────────────────────
|
│ ├── export/ ── Sicherung als Markdown ─────────────────────────
|
||||||
│ │ └── export.go ← Schreibt jeden Workspace als Ordner
|
│ │ ├── export.go ← Schreibt jeden Workspace als Ordner
|
||||||
│ │ (<workspace-id>/, mit _workspace.md) und darin
|
│ │ │ (<workspace-id>/, mit _workspace.md) und darin
|
||||||
│ │ jedes Projekt als Ordner voller
|
│ │ │ jedes Projekt als Ordner voller
|
||||||
│ │ Markdown-Dateien, mit allen Feldern im Dateikopf.
|
│ │ │ Markdown-Dateien, mit allen Feldern im Dateikopf.
|
||||||
│ │ Kennt selbst keine Datenbank: Der Aufrufer gibt
|
│ │ │ Kennt selbst keine Datenbank: Der Aufrufer gibt
|
||||||
│ │ die Workspace-Datei je Lauf mit.
|
│ │ │ die Workspace-Datei je Lauf mit.
|
||||||
│ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne
|
│ │ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne
|
||||||
│ │ inhaltliche Änderungen fasst keine Datei an und
|
│ │ │ inhaltliche Änderungen fasst keine Datei an und
|
||||||
│ │ erzeugt deshalb auch keinen Diff. Verwaiste
|
│ │ │ erzeugt deshalb auch keinen Diff. Verwaiste
|
||||||
│ │ Dateien gelöschter Einträge werden entfernt,
|
│ │ │ Dateien gelöschter Einträge werden entfernt,
|
||||||
│ │ fremde Dateien im Zielordner bleiben unberührt.
|
│ │ │ fremde Dateien im Zielordner bleiben unberührt.
|
||||||
|
│ │ └── zip.go ← Dieselben Dateien als ZIP für den Download.
|
||||||
|
│ │ Erst alles aus der Datenbank sammeln, dann
|
||||||
|
│ │ packen — sonst bekäme der Browser bei einem
|
||||||
|
│ │ Fehler ein halbes Archiv statt einer Fehlerseite.
|
||||||
│ │
|
│ │
|
||||||
│ ├── importer/ ── Gegenstück zum Export ──────────────────────────
|
│ ├── importer/ ── Gegenstück zum Export ──────────────────────────
|
||||||
│ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit
|
│ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit
|
||||||
|
|
@ -250,6 +263,11 @@ munin/
|
||||||
│ │ Indizes statt eines gemeinsamen mit Filter: So wird
|
│ │ Indizes statt eines gemeinsamen mit Filter: So wird
|
||||||
│ │ die Abschottung strukturell, und die BM25-Werte
|
│ │ die Abschottung strukturell, und die BM25-Werte
|
||||||
│ │ werden nicht über Projektgrenzen hinweg verzerrt.
|
│ │ werden nicht über Projektgrenzen hinweg verzerrt.
|
||||||
|
│ │ Refresh liest nach jedem Schreiben aus der
|
||||||
|
│ │ Datenbank nach, damit sich überholende Schreiber
|
||||||
|
│ │ keinen alten Stand im Index hinterlassen. Jedes
|
||||||
|
│ │ Projekt hat eine eigene Sperre, damit ein langer
|
||||||
|
│ │ Aufbau nicht den ganzen Workspace aufhält.
|
||||||
│ │
|
│ │
|
||||||
│ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ───────────────────
|
│ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ───────────────────
|
||||||
│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel) und
|
│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel) und
|
||||||
|
|
@ -263,7 +281,9 @@ munin/
|
||||||
│ ├── web/ ── HTTP: Browser und API ──────────────────────────
|
│ ├── web/ ── HTTP: Browser und API ──────────────────────────
|
||||||
│ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die
|
│ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die
|
||||||
│ │ │ Ablage im Request-Context. CanAccessProject ist
|
│ │ │ Ablage im Request-Context. CanAccessProject ist
|
||||||
│ │ │ die Mandantentrennung der Weboberfläche.
|
│ │ │ die Mandantentrennung der Weboberfläche. Die
|
||||||
|
│ │ │ Can…-Methoden sind die einzige Stelle, an der
|
||||||
|
│ │ │ der Workspace-Admin Rechte bekommt.
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"),
|
│ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"),
|
||||||
│ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung.
|
│ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung.
|
||||||
|
|
@ -287,9 +307,18 @@ munin/
|
||||||
│ │ │ Dazu Vorschau (preview), Vorschläge für [[…]]
|
│ │ │ Dazu Vorschau (preview), Vorschläge für [[…]]
|
||||||
│ │ │ (suggest), Fassungen und Vergleich, Graph-Daten
|
│ │ │ (suggest), Fassungen und Vergleich, Graph-Daten
|
||||||
│ │ │ und Schlagworte für viele Einträge (bulk-tags).
|
│ │ │ und Schlagworte für viele Einträge (bulk-tags).
|
||||||
|
│ │ ├── handlers_tags.go ← Seite "Alle Schlagworte" (/p/{projekt}/tags),
|
||||||
|
│ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die
|
||||||
|
│ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit
|
||||||
|
│ │ │ in server.go).
|
||||||
│ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage),
|
│ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage),
|
||||||
│ │ │ für jede Rolle. Die Admin-Projektliste nutzt
|
│ │ │ für jede Rolle. Die Admin-Projektliste nutzt
|
||||||
│ │ │ dieselben Zahlen (projectListItems).
|
│ │ │ dieselben Zahlen (projectListItems).
|
||||||
|
│ │ ├── handlers_download.go
|
||||||
|
│ │ │ ← Download als ZIP: ein Projekt (/p/{projekt}/
|
||||||
|
│ │ │ export.zip, wer darf: Session.CanDownloadProject)
|
||||||
|
│ │ │ oder der ganze Workspace (/admin/export.zip).
|
||||||
|
│ │ │ Unabhängig von EXPORT_DIR, nichts bleibt liegen.
|
||||||
│ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import),
|
│ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import),
|
||||||
│ │ │ nur mit Rolle admin. Läuft im Server, damit der
|
│ │ │ nur mit Rolle admin. Läuft im Server, damit der
|
||||||
│ │ │ Suchindex im selben Zug nachgezogen wird.
|
│ │ │ Suchindex im selben Zug nachgezogen wird.
|
||||||
|
|
@ -307,6 +336,11 @@ munin/
|
||||||
│ │ │ löschen (nur Plattform-Admin). Löscht jemand den
|
│ │ │ löschen (nur Plattform-Admin). Löscht jemand den
|
||||||
│ │ │ Workspace der eigenen Sitzung, wird die Auswahl
|
│ │ │ Workspace der eigenen Sitzung, wird die Auswahl
|
||||||
│ │ │ zurückgesetzt, statt ihn auszusperren.
|
│ │ │ zurückgesetzt, statt ihn auszusperren.
|
||||||
|
│ │ ├── handlers_workspace_admins.go
|
||||||
|
│ │ │ ← Workspace-Admins (wa_…, AUSBAUSTUFEN 4.8)
|
||||||
|
│ │ │ anlegen und zurückziehen, nur durch den
|
||||||
|
│ │ │ Plattform-Admin, auf der Bearbeitungsseite des
|
||||||
|
│ │ │ Workspace.
|
||||||
│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht, Export. Die
|
│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht, Export. Die
|
||||||
│ │ Bearbeitungsseite je Projekt (Name,
|
│ │ Bearbeitungsseite je Projekt (Name,
|
||||||
│ │ Beschreibung, Schreibbudget, Löschen) liegt
|
│ │ Beschreibung, Schreibbudget, Löschen) liegt
|
||||||
|
|
@ -346,8 +380,10 @@ munin/
|
||||||
│ ├── autocomplete.js ← Vorschläge nach "[[" im Textfeld, per Tastatur
|
│ ├── autocomplete.js ← Vorschläge nach "[[" im Textfeld, per Tastatur
|
||||||
│ │ und Maus bedienbar.
|
│ │ und Maus bedienbar.
|
||||||
│ ├── graph.js ← Selbst geschriebene Kräftesimulation und
|
│ ├── graph.js ← Selbst geschriebene Kräftesimulation und
|
||||||
│ │ SVG-Darstellung für die Graph-Ansicht. Texte nur
|
│ │ SVG-Darstellung für die Graph-Ansicht. Zoom zieht
|
||||||
│ │ über textContent, nie innerHTML.
|
│ │ Knoten auseinander, Namen nur, wo Platz ist.
|
||||||
|
│ │ Texte nur über textContent, nie innerHTML.
|
||||||
|
│ ├── tagfilter.js ← Filterfeld auf der Seite "Alle Schlagworte".
|
||||||
│ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne
|
│ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne
|
||||||
│ Hintergrund). Die Anmeldeseite wählt je nach
|
│ Hintergrund). Die Anmeldeseite wählt je nach
|
||||||
│ Farbschema die passende.
|
│ Farbschema die passende.
|
||||||
|
|
@ -371,6 +407,14 @@ weitere Vorbereitung — die Datenbanktests legen jeweils eine echte SQLite-Date
|
||||||
in einem Testverzeichnis an, damit auch Migrationen und Fremdschlüssel geprüft
|
in einem Testverzeichnis an, damit auch Migrationen und Fremdschlüssel geprüft
|
||||||
werden.
|
werden.
|
||||||
|
|
||||||
|
Ausnahme ist der Lasttest `last_test.go`: Er braucht Zeit und lastet die
|
||||||
|
Maschine aus und läuft deshalb nur auf Wunsch, mit
|
||||||
|
`go test -tags last -run TestLast -v .` (Schalter für Agentenzahl, Dauer usw.
|
||||||
|
oben in der Datei). Er misst über echtes HTTP und MCP drei Aufstellungen (ein
|
||||||
|
Projekt, mehrere Projekte in einem Workspace, mehrere Workspaces) und prüft am
|
||||||
|
Ende, dass kein Schreibvorgang verloren ging und der Suchindex aktuell ist.
|
||||||
|
Ausführung, Schalter und Ergebnisse: `LASTTEST.md`.
|
||||||
|
|
||||||
| Datei | Schwerpunkt |
|
| Datei | Schwerpunkt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For |
|
| `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For |
|
||||||
|
|
@ -383,9 +427,12 @@ werden.
|
||||||
| `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe |
|
| `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe |
|
||||||
| `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen |
|
| `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen |
|
||||||
| `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen |
|
| `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen |
|
||||||
|
| `internal/search/registry_test.go` | Index folgt der Datenbank, auch wenn sich Schreiber überholen, ein Eintrag gleich wieder gelöscht wird oder während des Aufbaus geschrieben wird; ein langsamer Aufbau bremst andere Projekte nicht |
|
||||||
|
| `internal/mcpserver/gleichzeitig_test.go` | Viele Agenten schreiben über MCP gleichzeitig denselben Eintrag: keine verlorene Fassung, Index aktuell |
|
||||||
| `internal/store/related_test.go` | Verwandte Einträge über zwei Ebenen, Papierkorb als Sackgasse, Grenze für veraltete Einträge, übernommene Zeitstempel beim Anlegen |
|
| `internal/store/related_test.go` | Verwandte Einträge über zwei Ebenen, Papierkorb als Sackgasse, Grenze für veraltete Einträge, übernommene Zeitstempel beim Anlegen |
|
||||||
| `internal/importer/importer_test.go` | **Rundlauf Export → ZIP → Import ohne Verlust**, Vorschau, Überspringen/Überschreiben, Papierkorb, doppelte Bezeichner, fremde Köpfe, Grenzen des Archivs |
|
| `internal/importer/importer_test.go` | **Rundlauf Export → ZIP → Import ohne Verlust**, auch mit dem ZIP aus dem Download, Vorschau, Überspringen/Überschreiben, Papierkorb, doppelte Bezeichner, fremde Köpfe, Grenzen des Archivs |
|
||||||
| `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe |
|
| `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe |
|
||||||
|
| `internal/web/download_test.go` | ZIP-Download: Inhalt und Protokolleintrag, nur Admins, kein fremdes Projekt, Workspace-ZIP für Plattform- und Workspace-Admin, sichere Dateinamen |
|
||||||
| `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route |
|
| `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route |
|
||||||
| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse |
|
| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse |
|
||||||
| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** |
|
| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** |
|
||||||
|
|
@ -423,7 +470,7 @@ KI-Werkzeug POST /mcp { … "name": "remember", "arguments": { "slug": …, "c
|
||||||
· ExpectedVersion prüfen → ggf. Konflikt
|
· ExpectedVersion prüfen → ggf. Konflikt
|
||||||
· Schlagworte und Verweise ersetzen
|
· Schlagworte und Verweise ersetzen
|
||||||
· Herkunft (agent) und Zugangsname setzen
|
· Herkunft (agent) und Zugangsname setzen
|
||||||
→ search/registry.go (Update) Index nachziehen
|
→ search/registry.go (Refresh) Index nachziehen
|
||||||
→ store/audit.go (LogAudit) wer, was, wann
|
→ store/audit.go (LogAudit) wer, was, wann
|
||||||
→ Antwort: Bezeichner, neue Fassung, angelegt oder aktualisiert
|
→ Antwort: Bezeichner, neue Fassung, angelegt oder aktualisiert
|
||||||
```
|
```
|
||||||
|
|
@ -439,7 +486,7 @@ Browser POST /p/plattform/e/deployment/save
|
||||||
→ web/middleware.go (requireWrite) Rollenprüfung
|
→ web/middleware.go (requireWrite) Rollenprüfung
|
||||||
→ web/handlers_entries.go (handleEntrySave)
|
→ web/handlers_entries.go (handleEntrySave)
|
||||||
→ store/entries.go (PutEntry) Herkunft: human, Autor: Key-Name
|
→ store/entries.go (PutEntry) Herkunft: human, Autor: Key-Name
|
||||||
→ search/registry.go (Update)
|
→ search/registry.go (Refresh)
|
||||||
→ store/audit.go (LogAudit)
|
→ store/audit.go (LogAudit)
|
||||||
→ Weiterleitung zur Detailansicht
|
→ Weiterleitung zur Detailansicht
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,31 @@ func TestLooksLikeKey(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdminTokenIsNoKey: Die beiden Arten dürfen sich nie
|
||||||
|
// überschneiden. Ginge ein Workspace-Admin-Token als API-Key durch, stünde
|
||||||
|
// ihm die MCP-Schnittstelle offen — und umgekehrt käme ein Projekt-Key an
|
||||||
|
// die Workspace-Verwaltung.
|
||||||
|
func TestWorkspaceAdminTokenIsNoKey(t *testing.T) {
|
||||||
|
token, err := GenerateWorkspaceAdminToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(token, WorkspaceAdminPrefix) || !LooksLikeWorkspaceAdminToken(token) {
|
||||||
|
t.Errorf("selbst erzeugtes Token besteht die Formprüfung nicht: %q", token)
|
||||||
|
}
|
||||||
|
if LooksLikeKey(token) {
|
||||||
|
t.Error("ein Workspace-Admin-Token geht als API-Key durch")
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := GenerateKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if LooksLikeWorkspaceAdminToken(key) {
|
||||||
|
t.Error("ein API-Key geht als Workspace-Admin-Token durch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rollen ───────────────────────────────────────────────────────────────────
|
// ── Rollen ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRolePermissions(t *testing.T) {
|
func TestRolePermissions(t *testing.T) {
|
||||||
|
|
@ -485,3 +510,35 @@ func TestSetTrustedProxiesRejectsGarbage(t *testing.T) {
|
||||||
}
|
}
|
||||||
SetTrustedProxies("")
|
SetTrustedProxies("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlatformSubjectHinUndZurueck(t *testing.T) {
|
||||||
|
faelle := []struct{ fingerabdruck, workspaceID string }{
|
||||||
|
{"abc123", ""},
|
||||||
|
{"abc123", "ws-1"},
|
||||||
|
}
|
||||||
|
for _, f := range faelle {
|
||||||
|
subject := PlatformSubject(f.fingerabdruck, f.workspaceID)
|
||||||
|
fp, ws, ok := ParsePlatformSubject(subject)
|
||||||
|
if !ok || fp != f.fingerabdruck || ws != f.workspaceID {
|
||||||
|
t.Errorf("%q → (%q, %q, %v), erwartet (%q, %q, true)", subject, fp, ws, ok, f.fingerabdruck, f.workspaceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alte Formen ohne Fingerabdruck und fremde Subjects sind keine.
|
||||||
|
for _, subject := range []string{"platform", "platform:ws-1", "platform/", "platform/:ws-1", "key:ws:1"} {
|
||||||
|
if _, _, ok := ParsePlatformSubject(subject); ok {
|
||||||
|
t.Errorf("%q wurde als Subject des Plattform-Admins erkannt", subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprintHaengtVomGeheimnisAb(t *testing.T) {
|
||||||
|
a := NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert")
|
||||||
|
b := NewSigner([]byte("geheimnis-zwei-geheimnis-zwei-geheimnis")).Fingerprint("wert")
|
||||||
|
if a == b {
|
||||||
|
t.Error("gleicher Fingerabdruck trotz anderem SESSION_SECRET")
|
||||||
|
}
|
||||||
|
if a != NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert") {
|
||||||
|
t.Error("Fingerabdruck ist nicht stabil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,12 @@
|
||||||
// einmalige Anmeldung mit demselben API-Key und trägt danach nur noch eine
|
// einmalige Anmeldung mit demselben API-Key und trägt danach nur noch eine
|
||||||
// signierte Behauptung ("ich bin Key X"), nie den Key selbst.
|
// signierte Behauptung ("ich bin Key X"), nie den Key selbst.
|
||||||
//
|
//
|
||||||
// Dazu kommt der Plattform-Admin (Umgebungsvariable ADMIN_TOKEN), der Projekte
|
// Dazu kommen zwei Verwaltungszugänge, die nicht an ein Projekt gebunden sind
|
||||||
// anlegen und löschen darf — der einzige Zugang, der nicht an ein Projekt gebunden ist.
|
// und nur für die Web-Oberfläche gelten:
|
||||||
|
//
|
||||||
|
// - der Plattform-Admin (Umgebungsvariable ADMIN_TOKEN) für alle Workspaces,
|
||||||
|
// - Workspace-Admins ("wa_…", AUSBAUSTUFEN.md 4.8), die in genau einem
|
||||||
|
// Workspace Projekte und Keys verwalten.
|
||||||
//
|
//
|
||||||
// # Was hier NICHT liegt
|
// # Was hier NICHT liegt
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,15 @@ import (
|
||||||
// Secret Scanning und ähnliche) suchen nach genau solchen Präfixen.
|
// Secret Scanning und ähnliche) suchen nach genau solchen Präfixen.
|
||||||
const KeyPrefix = "wk_"
|
const KeyPrefix = "wk_"
|
||||||
|
|
||||||
|
// WorkspaceAdminPrefix steht vor jedem Token eines Workspace-Admins
|
||||||
|
// (AUSBAUSTUFEN.md 4.8).
|
||||||
|
//
|
||||||
|
// Das eigene Präfix ist mehr als Kosmetik: APIAuth lässt nur zu, was
|
||||||
|
// LooksLikeKey erkennt. Ein Workspace-Admin-Token scheitert dort schon an der
|
||||||
|
// Form und kommt so nie an die MCP-Schnittstelle — der Zugang ist ausdrücklich
|
||||||
|
// nur für die Weboberfläche gedacht.
|
||||||
|
const WorkspaceAdminPrefix = "wa_"
|
||||||
|
|
||||||
// keyBytes ist die Länge des Zufallsanteils eines Keys.
|
// keyBytes ist die Länge des Zufallsanteils eines Keys.
|
||||||
// 32 Byte = 256 Bit — weit jenseits von allem, was sich durchprobieren lässt.
|
// 32 Byte = 256 Bit — weit jenseits von allem, was sich durchprobieren lässt.
|
||||||
const keyBytes = 32
|
const keyBytes = 32
|
||||||
|
|
@ -64,11 +73,23 @@ func (r Role) CanManageKeys() bool {
|
||||||
// Der Klartext existiert nur genau einmal — direkt nach dem Erzeugen wird er dem
|
// Der Klartext existiert nur genau einmal — direkt nach dem Erzeugen wird er dem
|
||||||
// Nutzer angezeigt und danach nirgends gespeichert (siehe HashKey).
|
// Nutzer angezeigt und danach nirgends gespeichert (siehe HashKey).
|
||||||
func GenerateKey() (string, error) {
|
func GenerateKey() (string, error) {
|
||||||
|
return generateWithPrefix(KeyPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateWorkspaceAdminToken erzeugt das Token eines Workspace-Admins im
|
||||||
|
// Klartext. Aufbau und Stärke wie bei GenerateKey, nur mit eigenem Präfix —
|
||||||
|
// siehe WorkspaceAdminPrefix, warum das wichtig ist.
|
||||||
|
func GenerateWorkspaceAdminToken() (string, error) {
|
||||||
|
return generateWithPrefix(WorkspaceAdminPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateWithPrefix erzeugt keyBytes Zufallsbytes und stellt das Präfix davor.
|
||||||
|
func generateWithPrefix(prefix string) (string, error) {
|
||||||
buf := make([]byte, keyBytes)
|
buf := make([]byte, keyBytes)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return "", fmt.Errorf("Zufallszahlen konnten nicht erzeugt werden: %w", err)
|
return "", fmt.Errorf("Zufallszahlen konnten nicht erzeugt werden: %w", err)
|
||||||
}
|
}
|
||||||
return KeyPrefix + hex.EncodeToString(buf), nil
|
return prefix + hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashKey bildet den Speicher-Hash eines API-Keys.
|
// HashKey bildet den Speicher-Hash eines API-Keys.
|
||||||
|
|
@ -98,10 +119,21 @@ func HashKey(key string) string {
|
||||||
// Das ist keine Sicherheitsprüfung, sondern spart einen Datenbankzugriff bei
|
// Das ist keine Sicherheitsprüfung, sondern spart einen Datenbankzugriff bei
|
||||||
// offensichtlichem Unsinn (leerer Header, abgeschnittener Key, Tippfehler).
|
// offensichtlichem Unsinn (leerer Header, abgeschnittener Key, Tippfehler).
|
||||||
func LooksLikeKey(key string) bool {
|
func LooksLikeKey(key string) bool {
|
||||||
if !strings.HasPrefix(key, KeyPrefix) {
|
return looksLike(key, KeyPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LooksLikeWorkspaceAdminToken prüft die äußere Form eines
|
||||||
|
// Workspace-Admin-Tokens — Gegenstück zu LooksLikeKey.
|
||||||
|
func LooksLikeWorkspaceAdminToken(token string) bool {
|
||||||
|
return looksLike(token, WorkspaceAdminPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLike prüft Präfix, Länge und Hex-Form.
|
||||||
|
func looksLike(key, prefix string) bool {
|
||||||
|
if !strings.HasPrefix(key, prefix) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
rest := key[len(KeyPrefix):]
|
rest := key[len(prefix):]
|
||||||
if len(rest) != keyBytes*2 {
|
if len(rest) != keyBytes*2 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,17 @@ const SessionCookieName = "munin_session"
|
||||||
// SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt.
|
// SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt.
|
||||||
const SessionTTL = 30 * 24 * time.Hour
|
const SessionTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
// SubjectPlatformAdmin ist das Session-Subject des Plattform-Admins (ADMIN_TOKEN).
|
// SubjectPlatformAdmin ist der Anfang des Session-Subjects des Plattform-Admins
|
||||||
|
// (ADMIN_TOKEN). Das vollständige Subject baut PlatformSubject.
|
||||||
const SubjectPlatformAdmin = "platform"
|
const SubjectPlatformAdmin = "platform"
|
||||||
|
|
||||||
// SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung.
|
// SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung.
|
||||||
const SubjectKeyPrefix = "key:"
|
const SubjectKeyPrefix = "key:"
|
||||||
|
|
||||||
|
// SubjectWorkspaceAdminPrefix steht vor "<workspace-id>:<zugangs-id>" im
|
||||||
|
// Session-Subject eines Workspace-Admins.
|
||||||
|
const SubjectWorkspaceAdminPrefix = "wsadmin:"
|
||||||
|
|
||||||
// Signer signiert und prüft alles, was der Server einem Browser mitgibt:
|
// Signer signiert und prüft alles, was der Server einem Browser mitgibt:
|
||||||
// Session-Cookies und CSRF-Tokens.
|
// Session-Cookies und CSRF-Tokens.
|
||||||
//
|
//
|
||||||
|
|
@ -55,7 +60,8 @@ func (s *Signer) sign(payload string) string {
|
||||||
|
|
||||||
// SignSession erstellt den Cookie-Wert für ein Subject.
|
// SignSession erstellt den Cookie-Wert für ein Subject.
|
||||||
//
|
//
|
||||||
// subject ist entweder SubjectPlatformAdmin oder "key:<api-key-id>".
|
// subject stammt aus PlatformSubject oder beginnt mit SubjectKeyPrefix bzw.
|
||||||
|
// SubjectWorkspaceAdminPrefix.
|
||||||
//
|
//
|
||||||
// Aufbau: base64("<subject>|<ablauf-unix>") + "." + HMAC
|
// Aufbau: base64("<subject>|<ablauf-unix>") + "." + HMAC
|
||||||
//
|
//
|
||||||
|
|
@ -102,6 +108,53 @@ func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) {
|
||||||
return subject, true
|
return subject, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fingerprint leitet aus einem Wert einen kurzen, nicht umkehrbaren
|
||||||
|
// Fingerabdruck ab, der nur mit dem SESSION_SECRET zu berechnen ist.
|
||||||
|
//
|
||||||
|
// Gebraucht wird er für die Sitzung des Plattform-Admins (siehe
|
||||||
|
// PlatformSubject). Der Cookie ist lesbar, deshalb steht darin nicht der Hash
|
||||||
|
// des ADMIN_TOKEN selbst, sondern nur dieser Abdruck davon.
|
||||||
|
func (s *Signer) Fingerprint(value string) string {
|
||||||
|
return s.sign("fingerabdruck|" + value)[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlatformSubject baut das Session-Subject des Plattform-Admins:
|
||||||
|
//
|
||||||
|
// "platform/<fingerabdruck>" noch kein Workspace gewählt
|
||||||
|
// "platform/<fingerabdruck>:<workspace-id>" mit gewähltem Workspace
|
||||||
|
//
|
||||||
|
// # Warum ein Fingerabdruck des ADMIN_TOKEN im Subject?
|
||||||
|
//
|
||||||
|
// Bei Keys und Workspace-Admins prüft der Server bei jedem Request in der
|
||||||
|
// Datenbank, ob der Zugang noch gilt. Beim Plattform-Admin gibt es keinen
|
||||||
|
// Datensatz, nur die Umgebungsvariable. Früher stand im Subject einfach
|
||||||
|
// "platform", und eine Sitzung blieb gültig, solange die Signatur stimmte.
|
||||||
|
// Wer sich mit einem geleakten ADMIN_TOKEN angemeldet hatte, blieb deshalb
|
||||||
|
// bis zu 30 Tage angemeldet, auch nachdem das Token längst ersetzt war.
|
||||||
|
//
|
||||||
|
// Mit dem Fingerabdruck vergleicht der Server bei jedem Request, ob die Sitzung
|
||||||
|
// mit dem *aktuellen* ADMIN_TOKEN entstanden ist. Ein neues Token beendet so
|
||||||
|
// sofort alle Admin-Sitzungen, während Teams mit ihren Keys angemeldet bleiben.
|
||||||
|
func PlatformSubject(fingerprint, workspaceID string) string {
|
||||||
|
subject := SubjectPlatformAdmin + "/" + fingerprint
|
||||||
|
if workspaceID != "" {
|
||||||
|
subject += ":" + workspaceID
|
||||||
|
}
|
||||||
|
return subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePlatformSubject zerlegt ein Subject aus PlatformSubject. ok ist false,
|
||||||
|
// wenn es kein Subject des Plattform-Admins ist, auch bei der alten Form ohne
|
||||||
|
// Fingerabdruck. workspaceID ist leer, solange kein Workspace gewählt ist.
|
||||||
|
func ParsePlatformSubject(subject string) (fingerprint, workspaceID string, ok bool) {
|
||||||
|
rest, ok := strings.CutPrefix(subject, SubjectPlatformAdmin+"/")
|
||||||
|
if !ok || rest == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
fingerprint, workspaceID, _ = strings.Cut(rest, ":")
|
||||||
|
return fingerprint, workspaceID, fingerprint != ""
|
||||||
|
}
|
||||||
|
|
||||||
// KeyIDFromSubject holt die API-Key-ID aus einem Session-Subject.
|
// KeyIDFromSubject holt die API-Key-ID aus einem Session-Subject.
|
||||||
// Beim Plattform-Admin gibt es keine — dann ist ok false.
|
// Beim Plattform-Admin gibt es keine — dann ist ok false.
|
||||||
func KeyIDFromSubject(subject string) (keyID string, ok bool) {
|
func KeyIDFromSubject(subject string) (keyID string, ok bool) {
|
||||||
|
|
|
||||||
|
|
@ -86,19 +86,16 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st
|
||||||
var stats Stats
|
var stats Stats
|
||||||
|
|
||||||
// Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim
|
// Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim
|
||||||
// Projekt-Bezeichner direkt darunter. IDs aus store.NewID bestehen nur aus
|
// Projekt-Bezeichner (siehe projektDateien). IDs aus store.NewID bestehen
|
||||||
// Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese Prüfung.
|
// nur aus Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese
|
||||||
|
// Prüfung.
|
||||||
if !store.ValidSlug(project.WorkspaceID) {
|
if !store.ValidSlug(project.WorkspaceID) {
|
||||||
return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID)
|
return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe
|
dateien, err := projektDateien(ctx, s, project)
|
||||||
// store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier
|
if err != nil {
|
||||||
// noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts
|
return stats, err
|
||||||
// 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.WorkspaceID, project.Slug)
|
ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug)
|
||||||
|
|
@ -106,35 +103,20 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st
|
||||||
return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
|
return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
eintraege, err := s.AllEntries(ctx, project.ID)
|
|
||||||
if err != nil {
|
|
||||||
return stats, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Namen merken, um danach zu wissen, was übrig bleibt.
|
// Namen merken, um danach zu wissen, was übrig bleibt.
|
||||||
erwartet := make(map[string]bool, len(eintraege)+1)
|
erwartet := make(map[string]bool, len(dateien))
|
||||||
erwartet[indexDateiname] = true
|
for _, d := range dateien {
|
||||||
|
// Im Ordner des Projekts zählt nur der Dateiname, ohne den
|
||||||
for _, eintrag := range eintraege {
|
// Projektordner davor.
|
||||||
if !store.ValidSlug(eintrag.Slug) {
|
name := filepath.Base(d.Pfad)
|
||||||
return stats, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
name := eintrag.Slug + ".md"
|
|
||||||
erwartet[name] = true
|
erwartet[name] = true
|
||||||
|
|
||||||
if err := schreibeDatei(filepath.Join(ziel, name), rendere(eintrag)); err != nil {
|
if err := schreibeDatei(filepath.Join(ziel, name), d.Inhalt); err != nil {
|
||||||
return stats, err
|
return stats, err
|
||||||
}
|
}
|
||||||
stats.Written++
|
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)
|
entfernt, err := raeumeAuf(ziel, erwartet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stats, err
|
return stats, err
|
||||||
|
|
@ -181,6 +163,50 @@ func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store
|
||||||
return gesamt, nil
|
return gesamt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projektDateien stellt alle Dateien eines Projekts zusammen, ohne etwas zu
|
||||||
|
// schreiben: je Eintrag eine Datei und die Übersicht _index.md, alle unter
|
||||||
|
// "<projekt-slug>/".
|
||||||
|
//
|
||||||
|
// Export auf die Platte und Download als ZIP (siehe zip.go) bauen beide
|
||||||
|
// hierauf auf. So legt nur eine Stelle fest, wie ein Export aussieht — und
|
||||||
|
// ein heruntergeladenes ZIP passt genauso in den Import wie ein Ordner aus
|
||||||
|
// EXPORT_DIR.
|
||||||
|
func projektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) {
|
||||||
|
// 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 außerhalb des Zielordners geschrieben wird.
|
||||||
|
if !store.ValidSlug(project.Slug) {
|
||||||
|
return nil, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
eintraege, err := s.AllEntries(ctx, project.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dateien := make([]Datei, 0, len(eintraege)+1)
|
||||||
|
for _, eintrag := range eintraege {
|
||||||
|
if !store.ValidSlug(eintrag.Slug) {
|
||||||
|
return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug)
|
||||||
|
}
|
||||||
|
dateien = append(dateien, Datei{
|
||||||
|
Pfad: project.Slug + "/" + eintrag.Slug + ".md",
|
||||||
|
Inhalt: rendere(eintrag),
|
||||||
|
Geaendert: eintrag.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist.
|
||||||
|
dateien = append(dateien, Datei{
|
||||||
|
Pfad: project.Slug + "/" + indexDateiname,
|
||||||
|
Inhalt: rendereIndex(project, eintraege),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dateien, nil
|
||||||
|
}
|
||||||
|
|
||||||
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
|
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
|
||||||
const indexDateiname = "_index.md"
|
const indexDateiname = "_index.md"
|
||||||
|
|
||||||
|
|
|
||||||
101
internal/export/zip.go
Normal file
101
internal/export/zip.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Datei ist eine Datei des Exports, noch nicht geschrieben.
|
||||||
|
type Datei struct {
|
||||||
|
// Pfad ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter
|
||||||
|
// Windows — so verlangt es das ZIP-Format.
|
||||||
|
Pfad string
|
||||||
|
Inhalt string
|
||||||
|
|
||||||
|
// Geaendert ist der Zeitpunkt, den die Datei im ZIP trägt. Bei Einträgen
|
||||||
|
// ihre letzte Änderung, damit ein entpacktes Archiv in jedem Dateimanager
|
||||||
|
// die richtige Reihenfolge zeigt. Leer bedeutet: Zeitpunkt des Downloads.
|
||||||
|
Geaendert time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjektDateien stellt den Export eines einzelnen Projekts zusammen:
|
||||||
|
//
|
||||||
|
// <projekt>/
|
||||||
|
// _index.md
|
||||||
|
// <eintrag>.md
|
||||||
|
//
|
||||||
|
// Das ist genau das, was der Import eines Projekts erwartet (Unterordner
|
||||||
|
// erlaubt, Dateien mit "_" am Anfang werden übergangen).
|
||||||
|
func ProjektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) {
|
||||||
|
return projektDateien(ctx, s, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspaceDateien stellt den Export aller Projekte eines Workspace zusammen,
|
||||||
|
// im selben Aufbau wie ein Workspace-Ordner unter EXPORT_DIR:
|
||||||
|
//
|
||||||
|
// _workspace.md
|
||||||
|
// <projekt-a>/…
|
||||||
|
// <projekt-b>/…
|
||||||
|
func WorkspaceDateien(ctx context.Context, s *store.Store, ws store.Workspace) ([]Datei, error) {
|
||||||
|
dateien := []Datei{{Pfad: workspaceDateiname, Inhalt: rendereWorkspace(ws)}}
|
||||||
|
|
||||||
|
projekte, err := s.ListProjects(ctx, ws.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range projekte {
|
||||||
|
projekt, err := projektDateien(ctx, s, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Projekt %q: %w", p.Slug, err)
|
||||||
|
}
|
||||||
|
dateien = append(dateien, projekt...)
|
||||||
|
}
|
||||||
|
return dateien, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchreibeZIP packt die Dateien als ZIP-Archiv nach w.
|
||||||
|
//
|
||||||
|
// # Warum erst sammeln, dann packen?
|
||||||
|
//
|
||||||
|
// Beim Download schreibt w direkt in die Antwort an den Browser. Ist der
|
||||||
|
// erste Teil des Archivs einmal unterwegs, lässt sich ein Fehler nicht mehr
|
||||||
|
// als Fehlerseite melden — der Browser bekäme nur ein kaputtes ZIP. Deshalb
|
||||||
|
// liest der Aufrufer zuerst alles aus der Datenbank (ProjektDateien,
|
||||||
|
// WorkspaceDateien). Was danach noch schiefgehen kann, ist nur noch die
|
||||||
|
// Verbindung selbst.
|
||||||
|
func SchreibeZIP(w io.Writer, dateien []Datei) error {
|
||||||
|
jetzt := time.Now()
|
||||||
|
archiv := zip.NewWriter(w)
|
||||||
|
|
||||||
|
for _, d := range dateien {
|
||||||
|
geaendert := d.Geaendert
|
||||||
|
if geaendert.IsZero() {
|
||||||
|
geaendert = jetzt
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateHeader statt Create, um Komprimierung und Zeitstempel
|
||||||
|
// selbst festzulegen. Deflate ist das Verfahren, das jedes
|
||||||
|
// Entpackprogramm kennt.
|
||||||
|
f, err := archiv.CreateHeader(&zip.FileHeader{
|
||||||
|
Name: d.Pfad,
|
||||||
|
Method: zip.Deflate,
|
||||||
|
Modified: geaendert,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(f, d.Inhalt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close schreibt das Inhaltsverzeichnis ans Ende des Archivs. Ohne diesen
|
||||||
|
// Schritt ist das ZIP unbrauchbar, auch wenn alle Dateien darin stehen.
|
||||||
|
return archiv.Close()
|
||||||
|
}
|
||||||
|
|
@ -84,5 +84,11 @@ Jeder Key hat genau eine Rolle.
|
||||||
| `write` | zusätzlich schreiben und löschen | der Normalfall für Menschen und beaufsichtigte Agenten |
|
| `write` | zusätzlich schreiben und löschen | der Normalfall für Menschen und beaufsichtigte Agenten |
|
||||||
| `admin` | zusätzlich die Zugänge des Projekts verwalten | Menschen, die für das Projekt verantwortlich sind |
|
| `admin` | zusätzlich die Zugänge des Projekts verwalten | Menschen, die für das Projekt verantwortlich sind |
|
||||||
|
|
||||||
Darüber steht der **Plattform-Admin**. Er meldet sich mit einem eigenen
|
Darüber stehen zwei Verwaltungszugänge, die nicht an ein Projekt gebunden
|
||||||
Admin-Token an, legt Workspaces und Projekte an und kommt an jedes Projekt.
|
sind und nur für die Weboberfläche gelten:
|
||||||
|
|
||||||
|
- Der **Workspace-Admin** verwaltet in genau einem Workspace die Projekte und
|
||||||
|
ihre Zugänge und kann alle Projekte dort lesen (siehe
|
||||||
|
[Zugänge und Sicherheit](/hilfe/zugaenge#workspace-admin)).
|
||||||
|
- Der **Plattform-Admin** meldet sich mit einem eigenen Admin-Token an, legt
|
||||||
|
Workspaces und Projekte an und kommt an jedes Projekt.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe
|
weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe
|
||||||
(`{{SERVER}}/hilfe`) reicht als Einstieg.
|
(`{{SERVER}}/hilfe`) reicht als Einstieg.
|
||||||
|
|
||||||
|
Soll eine Abteilung ihre Projekte selbst anlegen, reicht es, ihr einen
|
||||||
|
[Workspace-Admin](#workspace-admins) einzurichten. Die Schritte 2 und 3
|
||||||
|
übernimmt dann sie.
|
||||||
|
|
||||||
## Workspaces
|
## Workspaces
|
||||||
|
|
||||||
| Aktion | Wo |
|
| Aktion | Wo |
|
||||||
|
|
@ -27,6 +31,32 @@ auf diesen Workspace.
|
||||||
Einträgen und Keys. Zur Bestätigung muss der Name eingetippt werden. Rückgängig
|
Einträgen und Keys. Zur Bestätigung muss der Name eingetippt werden. Rückgängig
|
||||||
machen lässt sich das nur aus einer Sicherung.
|
machen lässt sich das nur aus einer Sicherung.
|
||||||
|
|
||||||
|
## Workspace-Admins
|
||||||
|
|
||||||
|
Ein Workspace-Admin legt im eigenen Workspace Projekte an, bearbeitet und
|
||||||
|
löscht sie und verwaltet deren Zugänge, nur über die Weboberfläche. Was er
|
||||||
|
genau darf und was nicht, steht unter
|
||||||
|
[Zugänge und Sicherheit](/hilfe/zugaenge#workspace-admin).
|
||||||
|
|
||||||
|
**Anlegen:** *Workspaces* → *Bearbeiten* → Abschnitt *Workspace-Admins*. Den
|
||||||
|
Namen der Person eintragen. Das Token (beginnt mit `wa_`) wird genau einmal
|
||||||
|
angezeigt und muss der Person sicher übergeben werden. Mit ihm meldet sie sich
|
||||||
|
an derselben Stelle an wie mit einem Key.
|
||||||
|
|
||||||
|
**Zurückziehen:** am selben Ort. Das Token gilt sofort nicht mehr, auch eine
|
||||||
|
laufende Sitzung endet. Beim Löschen des Workspace verschwinden seine
|
||||||
|
Workspace-Admins mit.
|
||||||
|
|
||||||
|
**Ungenutzte Zugänge:** Wie bei den Keys trägt ein aktiver Workspace-Admin,
|
||||||
|
der seit über 90 Tagen nicht benutzt wurde, die Marke *ungenutzt*. Die
|
||||||
|
Übersicht unter *Workspaces* zeigt je Workspace, wie viele Workspace-Admins
|
||||||
|
aktiv sind und wie viele davon ungenutzt. Ein Klick auf die Marke führt zur
|
||||||
|
Bearbeitungsseite. Ein vergessenes Token öffnet den ganzen Workspace, deshalb
|
||||||
|
im Zweifel zurückziehen.
|
||||||
|
|
||||||
|
Anlegen und Zurückziehen erscheinen im *Protokoll (alle)* des betroffenen
|
||||||
|
Workspace, ebenso jede Anmeldung eines Workspace-Admins.
|
||||||
|
|
||||||
## Projekte
|
## Projekte
|
||||||
|
|
||||||
Unter *Projekte → Verwalten…* stehen alle Projekte des Workspace mit
|
Unter *Projekte → Verwalten…* stehen alle Projekte des Workspace mit
|
||||||
|
|
@ -34,8 +64,9 @@ Eintragszahl und dem Stand ihres Schreibbudgets. Über *Bearbeiten* lassen sich
|
||||||
Name, Beschreibung und die **eigene Obergrenze fürs Schreibbudget** ändern.
|
Name, Beschreibung und die **eigene Obergrenze fürs Schreibbudget** ändern.
|
||||||
Dort liegt auch das Löschen.
|
Dort liegt auch das Löschen.
|
||||||
|
|
||||||
Das Schreibbudget kann nur der Plattform-Admin ändern. Sonst könnte ein Team
|
Das Schreibbudget kann nur der Plattform-Admin ändern, auch ein
|
||||||
die Grenze selbst aufheben, die gerade vor durchdrehenden Agenten schützen soll.
|
Workspace-Admin nicht. Sonst könnte ein Team die Grenze selbst aufheben, die
|
||||||
|
gerade vor durchdrehenden Agenten schützen soll.
|
||||||
|
|
||||||
## Protokoll über alle Projekte
|
## Protokoll über alle Projekte
|
||||||
|
|
||||||
|
|
@ -54,7 +85,13 @@ zusätzliche, lesbare Sicherung, etwa für ein Git-Repository. Unter *Projekte*
|
||||||
exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten
|
exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten
|
||||||
Workspace, zum Beispiel vor einer größeren Änderung.
|
Workspace, zum Beispiel vor einer größeren Änderung.
|
||||||
|
|
||||||
|
Ohne Exportverzeichnis und ohne Zugriff auf den Server geht es über den
|
||||||
|
Download: **Alle als ZIP herunterladen** unter *Projekte* liefert alle
|
||||||
|
Projekte des gewählten Workspace in einem Archiv, *Als ZIP herunterladen* über
|
||||||
|
der Eintragsliste ein einzelnes Projekt. Beides dürfen auch Workspace-Admins. Jeder
|
||||||
|
Download steht im Protokoll.
|
||||||
|
|
||||||
Zurück kommt ein Export über den [Import](/hilfe/weboberflaeche#import) im
|
Zurück kommt ein Export über den [Import](/hilfe/weboberflaeche#import) im
|
||||||
jeweiligen Projekt: den Projektordner aus dem Export als ZIP-Archiv packen und
|
jeweiligen Projekt: das ZIP eines Projekts direkt hochladen, oder den
|
||||||
hochladen. So lässt sich auch ein einzelnes Projekt wiederherstellen, ohne
|
Projektordner aus dem Exportverzeichnis als ZIP-Archiv packen und hochladen. So lässt sich auch ein einzelnes Projekt wiederherstellen, ohne
|
||||||
die ganze Workspace-Datei zurückzuspielen.
|
die ganze Workspace-Datei zurückzuspielen.
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,19 @@ zurückgezogen, endet auch die Sitzung im Browser sofort.
|
||||||
Schlagworte und Inhalt. Titel zählen am stärksten. Umlaute sind egal:
|
Schlagworte und Inhalt. Titel zählen am stärksten. Umlaute sind egal:
|
||||||
„Uberblick“ findet auch „Überblick“. Ein Wortanfang reicht, „deploy“ findet
|
„Uberblick“ findet auch „Überblick“. Ein Wortanfang reicht, „deploy“ findet
|
||||||
auch „Deployment“.
|
auch „Deployment“.
|
||||||
- **Nach Schlagwort filtern:** In der Seitenleiste stehen alle Schlagworte des
|
- **Nach Schlagwort filtern:** In der Seitenleiste stehen die zehn häufigsten
|
||||||
Projekts, die häufigsten zuerst. Ein Klick filtert die Liste.
|
Schlagworte des Projekts. Ein Klick filtert die Liste. Gibt es mehr, führt
|
||||||
|
*Alle Schlagworte…* zu einer Seite mit der vollständigen Liste, nach
|
||||||
|
Häufigkeit oder alphabetisch sortiert und mit einem Filterfeld.
|
||||||
- **Verweisen folgen:** Unter jedem Eintrag steht, welche anderen Einträge auf
|
- **Verweisen folgen:** Unter jedem Eintrag steht, welche anderen Einträge auf
|
||||||
ihn verweisen.
|
ihn verweisen.
|
||||||
- **Graph:** Unter *Graph* in der Seitenleiste zeigt Munin, wie die Einträge
|
- **Graph:** Unter *Graph* in der Seitenleiste zeigt Munin, wie die Einträge
|
||||||
über Verweise zusammenhängen. Gestrichelte Knoten sind Verweisziele, zu denen
|
über Verweise zusammenhängen. Gestrichelte Knoten sind Verweisziele, zu denen
|
||||||
es noch keinen Eintrag gibt. Mausrad zoomt, Ziehen verschiebt, ein Klick
|
es noch keinen Eintrag gibt. Mausrad zoomt, Ziehen verschiebt, ein Klick
|
||||||
öffnet den Eintrag. Bei sehr großen Projekten zeigt der Graph nur die 150 am
|
öffnet den Eintrag. Beschriftet sind zunächst nur die wichtigsten Knoten;
|
||||||
|
beim Hineinzoomen rücken die Knoten auseinander und weitere Namen
|
||||||
|
erscheinen. Zeigt die Maus auf einen Knoten, treten er und seine direkten
|
||||||
|
Nachbarn hervor. Bei sehr großen Projekten zeigt der Graph nur die 150 am
|
||||||
stärksten verknüpften Einträge.
|
stärksten verknüpften Einträge.
|
||||||
|
|
||||||
## Schreiben
|
## Schreiben
|
||||||
|
|
@ -155,8 +160,9 @@ Mit der Rolle `admin` gibt es in der Seitenleiste den Punkt *Import*. Dort
|
||||||
lässt sich ein ZIP-Archiv mit Markdown-Dateien hochladen. Jede Datei wird ein
|
lässt sich ein ZIP-Archiv mit Markdown-Dateien hochladen. Jede Datei wird ein
|
||||||
Eintrag. Das passt für drei Fälle:
|
Eintrag. Das passt für drei Fälle:
|
||||||
|
|
||||||
- **Wiederherstellen** aus dem Markdown-Export: den Ordner eines Projekts
|
- **Wiederherstellen** aus dem Markdown-Export: das ZIP aus *Als ZIP herunterladen*
|
||||||
packen und hochladen. Schlagworte, Autor, Herkunft, Prüfvermerk und
|
direkt hochladen, oder den Ordner eines Projekts aus dem Exportverzeichnis
|
||||||
|
des Servers packen und hochladen. Schlagworte, Autor, Herkunft, Prüfvermerk und
|
||||||
Änderungsdatum bleiben erhalten.
|
Änderungsdatum bleiben erhalten.
|
||||||
- **Umzug** eines Projekts auf einen anderen Server oder in einen anderen
|
- **Umzug** eines Projekts auf einen anderen Server oder in einen anderen
|
||||||
Workspace, auf demselben Weg.
|
Workspace, auf demselben Weg.
|
||||||
|
|
@ -177,6 +183,18 @@ dasselbe Archiv ohne den Haken noch einmal hochladen.
|
||||||
Nicht übernommen wird der Verlauf, denn der Export enthält nur den aktuellen
|
Nicht übernommen wird der Verlauf, denn der Export enthält nur den aktuellen
|
||||||
Stand. Ein importierter Eintrag beginnt deshalb wieder bei Fassung 1.
|
Stand. Ein importierter Eintrag beginnt deshalb wieder bei Fassung 1.
|
||||||
|
|
||||||
|
## Export als ZIP
|
||||||
|
|
||||||
|
Mit der Rolle `admin` steht über der Eintragsliste der Knopf *Als ZIP
|
||||||
|
herunterladen*. Ein Klick lädt alle Einträge des Projekts als ZIP-Archiv herunter: je Eintrag
|
||||||
|
eine Markdown-Datei mit allen Feldern im Kopf, dazu eine Übersicht
|
||||||
|
`_index.md`. Der Papierkorb und der Verlauf älterer Fassungen sind nicht
|
||||||
|
enthalten.
|
||||||
|
|
||||||
|
Das Archiv passt unverändert in den [Import](#import), in dieses oder ein
|
||||||
|
anderes Projekt, auch auf einem anderen Server. Jeder Download steht im
|
||||||
|
Protokoll.
|
||||||
|
|
||||||
## Protokoll
|
## Protokoll
|
||||||
|
|
||||||
Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas
|
Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,33 @@ das Team gewechselt haben, oder von abgeschalteten Pipelines. Jeder davon ist
|
||||||
ein offener Zugang. Wenn niemand mehr weiß, wofür er da ist, am besten
|
ein offener Zugang. Wenn niemand mehr weiß, wofür er da ist, am besten
|
||||||
zurückziehen.
|
zurückziehen.
|
||||||
|
|
||||||
|
## Workspace-Admin
|
||||||
|
|
||||||
|
Ein Workspace-Admin kümmert sich um die Projekte einer ganzen Abteilung, ohne
|
||||||
|
dafür Plattform-Admin sein zu müssen. Er meldet sich mit einem eigenen Token
|
||||||
|
(beginnt mit `wa_`) in der Weboberfläche an und landet direkt unter
|
||||||
|
*Projekte → Verwalten…*.
|
||||||
|
|
||||||
|
| Darf | Darf nicht |
|
||||||
|
|---|---|
|
||||||
|
| Projekte des eigenen Workspace anlegen, umbenennen und löschen | Einträge schreiben, löschen oder importieren |
|
||||||
|
| Zugänge aller Projekte dort anlegen und zurückziehen | das Schreibbudget ändern oder auf die Platte des Servers exportieren |
|
||||||
|
| alle Projekte dort lesen, samt Protokoll und Nutzung | andere Workspaces sehen |
|
||||||
|
| einzelne Projekte oder den ganzen Workspace als ZIP herunterladen | |
|
||||||
|
| | sich über MCP verbinden (das Token taugt dafür nicht) |
|
||||||
|
|
||||||
|
Einträge ändern Teams über ihre eigenen Keys, damit im Protokoll eindeutig
|
||||||
|
steht, wer etwas geschrieben hat. Ein Workspace-Admin, der selbst mitarbeiten
|
||||||
|
will, legt sich dafür einen Key im jeweiligen Projekt an.
|
||||||
|
|
||||||
|
Weil er Zugänge anlegen darf, kann ein Workspace-Admin sich faktisch Zugriff
|
||||||
|
auf jedes Projekt seines Workspace verschaffen. Die Rolle gehört deshalb nur an
|
||||||
|
Personen, denen die Abteilung ohnehin alle ihre Projekte anvertraut. Was er
|
||||||
|
tut, steht mit seinem Namen im Protokoll.
|
||||||
|
|
||||||
|
Workspace-Admins legt der Plattform-Admin an und zieht sie zurück. Ein
|
||||||
|
zurückgezogenes Token gilt sofort nicht mehr, auch eine laufende Sitzung endet.
|
||||||
|
|
||||||
## Welche Rolle für wen?
|
## Welche Rolle für wen?
|
||||||
|
|
||||||
| Wer | Empfohlene Rolle |
|
| Wer | Empfohlene Rolle |
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,56 @@ func TestRoundTripFromExport(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRoundTripFromDownload: Das ZIP aus dem Download (export.SchreibeZIP)
|
||||||
|
// muss ohne Umweg wieder in den Import passen — genau dafür ist es da.
|
||||||
|
func TestRoundTripFromDownload(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
quelle, qp := testStore(t, "quelle")
|
||||||
|
|
||||||
|
for _, w := range []store.EntryWrite{
|
||||||
|
{Slug: "deployment", Title: "Deployment", Content: "siehe [[rollback]]", Tags: []string{"ops"}, Source: store.SourceAgent, Author: "agent-1"},
|
||||||
|
{Slug: "rollback", Title: "Rollback", Content: "Zurück.", Source: store.SourceHuman, Author: "anna"},
|
||||||
|
} {
|
||||||
|
w.ProjectID = qp.ID
|
||||||
|
if _, err := quelle.PutEntry(ctx, w); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dateien, err := export.ProjektDateien(ctx, quelle, qp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var zip bytes.Buffer
|
||||||
|
if err := export.SchreibeZIP(&zip, dateien); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gelesen, err := LeseZip(zip.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ziel, zp := testStore(t, "ziel")
|
||||||
|
bericht, err := Importiere(ctx, ziel, zp.ID, gelesen, Optionen{Modus: ModusUeberspringen, Autor: "importeur"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bericht.Neu != 2 || bericht.Fehler != 0 {
|
||||||
|
t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, slug := range []string{"deployment", "rollback"} {
|
||||||
|
a, _ := quelle.EntryBySlug(ctx, qp.ID, slug)
|
||||||
|
b, err := ziel.EntryBySlug(ctx, zp.ID, slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s fehlt nach dem Import: %v", slug, err)
|
||||||
|
}
|
||||||
|
if a.Title != b.Title || a.Content != b.Content || !slices.Equal(a.Tags, b.Tags) || a.Author != b.Author {
|
||||||
|
t.Errorf("%s nach dem Import verändert:\nvorher %+v\nnachher %+v", slug, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConflictModes(t *testing.T) {
|
func TestConflictModes(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
s, p := testStore(t, "test")
|
s, p := testStore(t, "test")
|
||||||
|
|
|
||||||
159
internal/mcpserver/gleichzeitig_test.go
Normal file
159
internal/mcpserver/gleichzeitig_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package mcpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/store"
|
||||||
|
"munin/internal/web"
|
||||||
|
"munin/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verbinde öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann.
|
||||||
|
//
|
||||||
|
// Anders als ruf meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf
|
||||||
|
// darf nur aus der Goroutine des Tests selbst kommen, nicht aus den
|
||||||
|
// nebenläufigen Agenten unten.
|
||||||
|
func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name string) (*mcp.ClientSession, func(), error) {
|
||||||
|
sess := web.Session{
|
||||||
|
Subject: "key:" + ws.ID + ":" + name,
|
||||||
|
WorkspaceID: ws.ID,
|
||||||
|
Key: store.APIKey{ID: name, Name: name, ProjectID: projekt.ID, Role: string(auth.RoleWrite)},
|
||||||
|
Project: projekt,
|
||||||
|
Role: auth.RoleWrite,
|
||||||
|
}
|
||||||
|
ctx := web.WithSessionForTest(context.Background(), sess)
|
||||||
|
ctx = web.WithWorkspaceForTest(ctx, ws)
|
||||||
|
|
||||||
|
clientTransport, serverTransport := mcp.NewInMemoryTransports()
|
||||||
|
serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: name, Version: "1"}, nil)
|
||||||
|
clientSession, err := client.Connect(ctx, clientTransport, nil)
|
||||||
|
if err != nil {
|
||||||
|
serverSession.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
schliessen := func() {
|
||||||
|
clientSession.Close()
|
||||||
|
serverSession.Close()
|
||||||
|
}
|
||||||
|
return clientSession, schliessen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGleichzeitigesSchreibenHaeltSuchindexAktuell prüft, dass der Suchindex
|
||||||
|
// nach gleichzeitigen Schreibvorgängen auf denselben Eintrag den Stand der
|
||||||
|
// Datenbank kennt.
|
||||||
|
//
|
||||||
|
// Die Gefahr: remember schreibt erst in die Datenbank und aktualisiert danach
|
||||||
|
// den Index — zwei getrennte Schritte. Überholen sich zwei Agenten zwischen
|
||||||
|
// diesen Schritten, landet Fassung 3 in der Datenbank, aber Fassung 2 im Index.
|
||||||
|
// recall fände den Eintrag dann bis zum Neustart über veraltete Wörter.
|
||||||
|
//
|
||||||
|
// Jeder Schreibvorgang bekommt deshalb ein eigenes, eindeutiges Wort. Am Ende
|
||||||
|
// muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden
|
||||||
|
// sein.
|
||||||
|
func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) {
|
||||||
|
h, ws, projekt := aufbauMitBudget(t, 100000)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der
|
||||||
|
// Test würde nichts prüfen (siehe Registry.Update).
|
||||||
|
idx, err := ws.Search.For(ctx, projekt.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runden, agenten, jeAgent = 20, 8, 10
|
||||||
|
|
||||||
|
for runde := 0; runde < runden; runde++ {
|
||||||
|
slug := fmt.Sprintf("umkaempft-%02d", runde)
|
||||||
|
|
||||||
|
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()
|
||||||
|
melde := func(err error) {
|
||||||
|
mu.Lock()
|
||||||
|
fehler = append(fehler, err)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
client, schliessen, err := verbinde(h, ws, projekt, fmt.Sprintf("agent-%d", a))
|
||||||
|
if err != nil {
|
||||||
|
melde(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer schliessen()
|
||||||
|
|
||||||
|
for i := 0; i < jeAgent; i++ {
|
||||||
|
// Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt.
|
||||||
|
wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende")
|
||||||
|
erg, err := client.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: "remember",
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"slug": slug, "title": "Umkämpft", "content": "Stand " + wort,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
melde(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if erg.IsError {
|
||||||
|
melde(fmt.Errorf("remember: %s", text(erg)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(a)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if len(fehler) > 0 {
|
||||||
|
t.Fatalf("Runde %d: %d Fehler, erster: %v", runde, len(fehler), fehler[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := ws.Store.EntryBySlug(ctx, projekt.ID, slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if e.Version != agenten*jeAgent {
|
||||||
|
t.Errorf("Runde %d: Fassung %d, erwartet %d — ein Schreibvorgang ging verloren",
|
||||||
|
runde, e.Version, agenten*jeAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Wort der letzten Fassung steht nach "Stand " im Inhalt.
|
||||||
|
letztesWort := e.Content[len("Stand "):]
|
||||||
|
gefunden := false
|
||||||
|
for _, r := range idx.Search(letztesWort, 5) {
|
||||||
|
if r.DocID == slug {
|
||||||
|
gefunden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
t.Errorf("Runde %d: Datenbank hat Fassung %d mit %q, der Suchindex kennt das Wort nicht — "+
|
||||||
|
"er hält einen älteren Stand", runde, e.Version, letztesWort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buchstaben schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9.
|
||||||
|
// So entsteht für jeden Schreibvorgang ein eindeutiges Wort ohne Ziffern.
|
||||||
|
func buchstaben(n int) string {
|
||||||
|
s := fmt.Sprintf("%06d", n)
|
||||||
|
b := []byte(s)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = 'a' + (b[i] - '0')
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
@ -427,7 +427,7 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
|
||||||
return nil, rememberOutput{}, err
|
return nil, rememberOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.Search.Update(project.ID, geschrieben)
|
ws.Search.Refresh(ctx, project.ID, geschrieben.Slug)
|
||||||
|
|
||||||
sess, _ := web.SessionFromContext(ctx)
|
sess, _ := web.SessionFromContext(ctx)
|
||||||
ws.Store.LogAudit(ctx, store.AuditEvent{
|
ws.Store.LogAudit(ctx, store.AuditEvent{
|
||||||
|
|
@ -505,7 +505,7 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) {
|
||||||
return nil, forgetOutput{}, err
|
return nil, forgetOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.Search.Delete(project.ID, in.Slug)
|
ws.Search.Refresh(ctx, project.ID, in.Slug)
|
||||||
|
|
||||||
sess, _ := web.SessionFromContext(ctx)
|
sess, _ := web.SessionFromContext(ctx)
|
||||||
ws.Store.LogAudit(ctx, store.AuditEvent{
|
ws.Store.LogAudit(ctx, store.AuditEvent{
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ package search
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"munin/internal/store"
|
"munin/internal/store"
|
||||||
|
|
@ -23,8 +25,32 @@ import (
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
|
|
||||||
|
// mu schützt nur die Zuordnung Projekt → Fach und wird immer nur kurz
|
||||||
|
// gehalten. Die eigentliche Arbeit (Aufbau, Aktualisierung) läuft unter
|
||||||
|
// der Sperre des jeweiligen Fachs.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
indexes map[string]*Index // Schlüssel: Projekt-ID
|
faecher map[string]*fach // Schlüssel: Projekt-ID
|
||||||
|
|
||||||
|
// nachDemLesen wird nur von Tests gesetzt: Es läuft beim Aufbau zwischen
|
||||||
|
// dem Lesen der Einträge und dem Befüllen des Index. So lässt sich ein
|
||||||
|
// langsamer Aufbau nachstellen.
|
||||||
|
nachDemLesen func(projectID string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fach hält den Index eines einzelnen Projekts.
|
||||||
|
//
|
||||||
|
// # Warum eine Sperre je Projekt?
|
||||||
|
//
|
||||||
|
// Früher gab es nur eine Sperre für alle Projekte des Workspace. Der erste
|
||||||
|
// Zugriff auf ein großes Projekt nach einem Neustart liest alle Einträge ein
|
||||||
|
// (etwa 170 ms je 10.000 Einträge) — und so lange mussten auch alle anderen
|
||||||
|
// Projekte desselben Workspace warten, selbst wenn ihr Index längst fertig war.
|
||||||
|
//
|
||||||
|
// Mit einer Sperre je Projekt wartet nur, wer auf genau dieses Projekt
|
||||||
|
// zugreift. Das muss er auch: Ohne fertigen Index gibt es nichts zu suchen.
|
||||||
|
type fach struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
idx *Index // nil, solange der Index noch nicht (oder nicht mehr) aufgebaut ist
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry erstellt eine leere Registry. Die Indizes entstehen beim ersten
|
// NewRegistry erstellt eine leere Registry. Die Indizes entstehen beim ersten
|
||||||
|
|
@ -32,30 +58,60 @@ type Registry struct {
|
||||||
func NewRegistry(s *store.Store) *Registry {
|
func NewRegistry(s *store.Store) *Registry {
|
||||||
return &Registry{
|
return &Registry{
|
||||||
store: s,
|
store: s,
|
||||||
indexes: make(map[string]*Index),
|
faecher: make(map[string]*fach),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fachFuer gibt das Fach eines Projekts zurück und legt es bei Bedarf an —
|
||||||
|
// zunächst leer, aufgebaut wird erst in For.
|
||||||
|
func (r *Registry) fachFuer(projectID string) *fach {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
f, ok := r.faecher[projectID]
|
||||||
|
if !ok {
|
||||||
|
f = &fach{}
|
||||||
|
r.faecher[projectID] = f
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// vorhandenesFach gibt das Fach eines Projekts zurück, ohne eines anzulegen.
|
||||||
|
func (r *Registry) vorhandenesFach(projectID string) (*fach, bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
f, ok := r.faecher[projectID]
|
||||||
|
return f, ok
|
||||||
|
}
|
||||||
|
|
||||||
// For gibt den Index eines Projekts zurück und baut ihn beim ersten Zugriff auf.
|
// 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
|
// Der verzögerte Aufbau hat einen praktischen Grund: Beim Serverstart alle
|
||||||
// Projekte einzulesen würde bei vielen Projekten den Start ausbremsen, obwohl
|
// Projekte einzulesen würde bei vielen Projekten den Start ausbremsen, obwohl
|
||||||
// die meisten davon in dieser Laufzeit vielleicht nie angefragt werden. So zahlt
|
// die meisten davon in dieser Laufzeit vielleicht nie angefragt werden. So zahlt
|
||||||
// nur der erste Zugriff je Projekt.
|
// nur der erste Zugriff je Projekt.
|
||||||
|
//
|
||||||
|
// Greifen mehrere gleichzeitig auf ein Projekt ohne Index zu, baut ihn nur der
|
||||||
|
// erste auf. Die anderen warten an der Sperre des Fachs und bekommen danach
|
||||||
|
// den fertigen Index.
|
||||||
func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) {
|
func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) {
|
||||||
r.mu.Lock()
|
f := r.fachFuer(projectID)
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
if idx, ok := r.indexes[projectID]; ok {
|
f.mu.Lock()
|
||||||
return idx, nil
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
if f.idx != nil {
|
||||||
|
return f.idx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
idx := NewIndex()
|
idx := NewIndex()
|
||||||
if err := r.fill(ctx, idx, projectID); err != nil {
|
if err := r.fill(ctx, idx, projectID); err != nil {
|
||||||
|
// Fach bleibt leer, der nächste Zugriff versucht es erneut.
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
r.indexes[projectID] = idx
|
f.idx = idx
|
||||||
return idx, nil
|
return idx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,31 +122,68 @@ func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error
|
||||||
return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err)
|
return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.nachDemLesen != nil {
|
||||||
|
r.nachDemLesen(projectID)
|
||||||
|
}
|
||||||
|
|
||||||
for _, e := range eintraege {
|
for _, e := range eintraege {
|
||||||
idx.Index(e.Slug, e.Title, e.Content, e.Tags)
|
idx.Index(e.Slug, e.Title, e.Content, e.Tags)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update nimmt einen geänderten Eintrag in den Index auf.
|
// Refresh bringt einen Eintrag im Index auf den Stand der Datenbank — nach
|
||||||
|
// jedem Schreiben, Löschen oder Wiederherstellen aufrufen, nachdem die
|
||||||
|
// Änderung gespeichert ist.
|
||||||
|
//
|
||||||
// Existiert für das Projekt noch kein Index, passiert nichts — er wird beim
|
// Existiert für das Projekt noch kein Index, passiert nichts — er wird beim
|
||||||
// ersten Zugriff ohnehin vollständig aufgebaut.
|
// ersten Zugriff ohnehin vollständig aufgebaut.
|
||||||
func (r *Registry) Update(projectID string, e store.Entry) {
|
//
|
||||||
r.mu.Lock()
|
// # Warum aus der Datenbank lesen statt den geschriebenen Stand übernehmen?
|
||||||
defer r.mu.Unlock()
|
//
|
||||||
|
// Speichern und Index aktualisieren sind zwei getrennte Schritte. Schreiben
|
||||||
if idx, ok := r.indexes[projectID]; ok {
|
// zwei Agenten gleichzeitig denselben Eintrag, können sie sich dazwischen
|
||||||
idx.Index(e.Slug, e.Title, e.Content, e.Tags)
|
// überholen: Fassung 3 ist gespeichert, aber die Meldung für Fassung 2 kommt
|
||||||
|
// als letzte beim Index an. Früher übernahm der Index den mitgegebenen Stand
|
||||||
|
// und hielt dann bis zum Neustart Fassung 2. Genauso konnte ein gerade
|
||||||
|
// gelöschter Eintrag durch eine verspätete Meldung wieder auftauchen.
|
||||||
|
//
|
||||||
|
// Hier wird stattdessen unter der Sperre nachgelesen, was jetzt gespeichert
|
||||||
|
// ist. Der letzte Aufruf liest damit immer den neuesten Stand, egal in welcher
|
||||||
|
// Reihenfolge die Aufrufe ankommen. Das kostet eine kleine Leseabfrage je
|
||||||
|
// Schreibvorgang.
|
||||||
|
func (r *Registry) Refresh(ctx context.Context, projectID, slug string) {
|
||||||
|
f, ok := r.vorhandenesFach(projectID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Delete entfernt einen Eintrag aus dem Index.
|
// Läuft gerade der Aufbau dieses Projekts, wartet Refresh hier, bis er
|
||||||
func (r *Registry) Delete(projectID, slug string) {
|
// fertig ist. Das ist wichtig: Der Aufbau hat die Einträge womöglich vor
|
||||||
r.mu.Lock()
|
// dieser Änderung gelesen. Erst danach nachzulesen holt sie sicher nach.
|
||||||
defer r.mu.Unlock()
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
if idx, ok := r.indexes[projectID]; ok {
|
if f.idx == nil {
|
||||||
idx.Remove(slug)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithoutCancel: Die Änderung ist schon gespeichert. Bricht der Client die
|
||||||
|
// Verbindung jetzt ab, muss der Index trotzdem nachziehen — sonst würde
|
||||||
|
// der Abbruch als Lesefehler gelten und den ganzen Index verwerfen.
|
||||||
|
e, err := r.store.EntryBySlug(context.WithoutCancel(ctx), projectID, slug)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrNotFound):
|
||||||
|
// Gelöscht oder im Papierkorb: gehört nicht in die Suche.
|
||||||
|
f.idx.Remove(slug)
|
||||||
|
case err != nil:
|
||||||
|
// Der aktuelle Stand ist unbekannt. Statt zu raten, den ganzen Index
|
||||||
|
// des Projekts verwerfen — der nächste Zugriff baut ihn aus der
|
||||||
|
// Datenbank neu auf.
|
||||||
|
log.Printf("Suchindex: %q in Projekt %s nicht lesbar, Index wird neu aufgebaut: %v", slug, projectID, err)
|
||||||
|
f.idx = nil
|
||||||
|
default:
|
||||||
|
f.idx.Index(e.Slug, e.Title, e.Content, e.Tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,5 +191,5 @@ func (r *Registry) Delete(projectID, slug string) {
|
||||||
func (r *Registry) Forget(projectID string) {
|
func (r *Registry) Forget(projectID string) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
delete(r.indexes, projectID)
|
delete(r.faecher, projectID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
208
internal/search/registry_test.go
Normal file
208
internal/search/registry_test.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// aufbauRegistry legt eine Datenbank mit einem Projekt und eine Registry mit
|
||||||
|
// bereits aufgebautem Index an.
|
||||||
|
func aufbauRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Index) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewRegistry(db)
|
||||||
|
// Den Index vorher anlegen — sonst sind Aktualisierungen ein No-op.
|
||||||
|
idx, err := r.For(ctx, projekt.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r, db, projekt, idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func schreibe(t *testing.T, db *store.Store, projekt store.Project, slug, inhalt string) store.Entry {
|
||||||
|
t.Helper()
|
||||||
|
e, err := db.PutEntry(context.Background(), store.EntryWrite{
|
||||||
|
ProjectID: projekt.ID, Slug: slug, Title: "Eintrag", Content: inhalt, Source: store.SourceAgent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func findet(idx *Index, wort, slug string) bool {
|
||||||
|
for _, r := range idx.Search(wort, 10) {
|
||||||
|
if r.DocID == slug {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUeberholteAktualisierungHaeltIndexAktuell stellt den Fall nach, dass sich
|
||||||
|
// zwei Schreiber zwischen Datenbank und Index überholen: Fassung 2 und 3 sind
|
||||||
|
// gespeichert, aber die Meldung für Fassung 3 kommt beim Index zuerst an.
|
||||||
|
//
|
||||||
|
// Unter Last lässt sich das kaum zuverlässig auslösen (siehe
|
||||||
|
// TestGleichzeitigesSchreibenHaeltSuchindexAktuell im Paket mcpserver), hier
|
||||||
|
// wird die Reihenfolge deshalb von Hand vertauscht.
|
||||||
|
func TestUeberholteAktualisierungHaeltIndexAktuell(t *testing.T) {
|
||||||
|
r, db, projekt, idx := aufbauRegistry(t)
|
||||||
|
|
||||||
|
schreibe(t, db, projekt, "umkaempft", "Stand wortzwei")
|
||||||
|
schreibe(t, db, projekt, "umkaempft", "Stand wortdrei")
|
||||||
|
|
||||||
|
r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 3
|
||||||
|
r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 2, verspätet
|
||||||
|
|
||||||
|
if !findet(idx, "wortdrei", "umkaempft") {
|
||||||
|
t.Error("der Index kennt den aktuellen Stand (Fassung 3) nicht")
|
||||||
|
}
|
||||||
|
if findet(idx, "wortzwei", "umkaempft") {
|
||||||
|
t.Error("der Index hält einen überholten Stand (Fassung 2)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerspaeteteAktualisierungNachLoeschenBleibtWeg: Ein Eintrag wird
|
||||||
|
// geschrieben und gleich darauf gelöscht. Kommt die Meldung des Schreibers erst
|
||||||
|
// nach der des Löschens beim Index an, darf der Eintrag nicht wieder auftauchen.
|
||||||
|
func TestVerspaeteteAktualisierungNachLoeschenBleibtWeg(t *testing.T) {
|
||||||
|
r, db, projekt, idx := aufbauRegistry(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
schreibe(t, db, projekt, "kurzlebig", "Stand wortgeloescht")
|
||||||
|
if err := db.MoveEntryToTrash(ctx, projekt.ID, "kurzlebig", "test"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Refresh(ctx, projekt.ID, "kurzlebig") // Löschender
|
||||||
|
r.Refresh(ctx, projekt.ID, "kurzlebig") // Schreiber, verspätet
|
||||||
|
|
||||||
|
if findet(idx, "wortgeloescht", "kurzlebig") {
|
||||||
|
t.Error("ein gelöschter Eintrag ist wieder im Suchindex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLangsamerAufbauBremstAndereProjekteNicht: Während der Index von Projekt A
|
||||||
|
// noch aufgebaut wird, muss Projekt B ohne Wartezeit durchkommen. Früher hielt
|
||||||
|
// der Aufbau eine Sperre für den ganzen Workspace.
|
||||||
|
func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) {
|
||||||
|
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
gross, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
klein, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewRegistry(db)
|
||||||
|
imAufbau := make(chan struct{})
|
||||||
|
weiter := make(chan struct{})
|
||||||
|
r.nachDemLesen = func(projectID string) {
|
||||||
|
if projectID == gross.ID {
|
||||||
|
close(imAufbau)
|
||||||
|
<-weiter // Aufbau hängt, bis der Test ihn freigibt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fertig := make(chan error)
|
||||||
|
go func() {
|
||||||
|
_, err := r.For(ctx, gross.ID)
|
||||||
|
fertig <- err
|
||||||
|
}()
|
||||||
|
<-imAufbau
|
||||||
|
|
||||||
|
// Projekt B, während A noch aufgebaut wird.
|
||||||
|
klar := make(chan error)
|
||||||
|
go func() {
|
||||||
|
_, err := r.For(ctx, klein.ID)
|
||||||
|
klar <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-klar:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Projekt B wartet auf den Aufbau von Projekt A")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(weiter)
|
||||||
|
if err := <-fertig; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAenderungWaehrendDesAufbausGehtNichtVerloren: Der Aufbau hat die
|
||||||
|
// Einträge schon gelesen, da wird einer davon geändert. Das Refresh des
|
||||||
|
// Schreibers muss warten und danach den neuen Stand nachtragen — sonst hielte
|
||||||
|
// der fertige Index den Stand von vor der Änderung.
|
||||||
|
func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) {
|
||||||
|
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
schreibe(t, db, projekt, "eintrag", "Stand wortalt")
|
||||||
|
|
||||||
|
r := NewRegistry(db)
|
||||||
|
refreshFertig := make(chan struct{})
|
||||||
|
r.nachDemLesen = func(string) {
|
||||||
|
// Die Einträge sind gelesen, der Index noch nicht befüllt. Jetzt
|
||||||
|
// schreibt jemand und meldet es, wie es die Handler tun.
|
||||||
|
schreibe(t, db, projekt, "eintrag", "Stand wortneu")
|
||||||
|
go func() {
|
||||||
|
r.Refresh(ctx, projekt.ID, "eintrag")
|
||||||
|
close(refreshFertig)
|
||||||
|
}()
|
||||||
|
// Refresh darf nicht durchlaufen, solange der Aufbau läuft.
|
||||||
|
select {
|
||||||
|
case <-refreshFertig:
|
||||||
|
t.Error("Refresh lief während des Aufbaus durch")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, err := r.For(ctx, projekt.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-refreshFertig
|
||||||
|
|
||||||
|
if !findet(idx, "wortneu", "eintrag") {
|
||||||
|
t.Error("die Änderung während des Aufbaus fehlt im Index")
|
||||||
|
}
|
||||||
|
if findet(idx, "wortalt", "eintrag") {
|
||||||
|
t.Error("der Index hält den Stand von vor der Änderung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,12 @@ const (
|
||||||
// einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt.
|
// einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt.
|
||||||
ActionEntryImport = "entry_import"
|
ActionEntryImport = "entry_import"
|
||||||
|
|
||||||
|
// ActionExportDownload: Ein Projekt oder ein ganzer Workspace wurde als
|
||||||
|
// ZIP heruntergeladen. Mit Projekt-ID beim Projekt, ohne beim Workspace.
|
||||||
|
// Ein Download verändert nichts, steht aber im Protokoll, weil er den
|
||||||
|
// gesamten Bestand auf einmal aus dem Haus trägt.
|
||||||
|
ActionExportDownload = "export_download"
|
||||||
|
|
||||||
ActionLoginSuccess = "login_success"
|
ActionLoginSuccess = "login_success"
|
||||||
ActionAuthFailure = "auth_failure"
|
ActionAuthFailure = "auth_failure"
|
||||||
ActionProjectCreated = "project_created"
|
ActionProjectCreated = "project_created"
|
||||||
|
|
@ -33,6 +39,11 @@ const (
|
||||||
ActionProjectUpdated = "project_updated"
|
ActionProjectUpdated = "project_updated"
|
||||||
ActionKeyCreated = "key_created"
|
ActionKeyCreated = "key_created"
|
||||||
ActionKeyRevoked = "key_revoked"
|
ActionKeyRevoked = "key_revoked"
|
||||||
|
|
||||||
|
// Workspace-Admin-Zugänge (AUSBAUSTUFEN.md 4.8). Stehen im Protokoll des
|
||||||
|
// betroffenen Workspace, ohne Projekt — sichtbar unter /admin/audit.
|
||||||
|
ActionWorkspaceAdminCreated = "workspace_admin_created"
|
||||||
|
ActionWorkspaceAdminRevoked = "workspace_admin_revoked"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Feste Werte für das Feld Detail bei ActionEntryWrite. Feste Wörter statt
|
// Feste Werte für das Feld Detail bei ActionEntryWrite. Feste Wörter statt
|
||||||
|
|
|
||||||
|
|
@ -98,3 +98,75 @@ func TestListWorkspaces(t *testing.T) {
|
||||||
t.Errorf("unbekannte ID: err = %v, erwartet ErrNotFound", err)
|
t.Errorf("unbekannte ID: err = %v, erwartet ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdmins deckt den Lebenslauf eines Workspace-Admin-Zugangs ab:
|
||||||
|
// anlegen, auflösen, zurückziehen — und dass ein anderer Workspace ihn nicht
|
||||||
|
// zurückziehen kann.
|
||||||
|
func TestWorkspaceAdmins(t *testing.T) {
|
||||||
|
c := testControl(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
wsA, err := c.CreateWorkspace(ctx, "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wsB, err := c.CreateWorkspace(ctx, "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.CreateWorkspaceAdmin(ctx, wsA.ID, " ", "hash-leer"); err == nil {
|
||||||
|
t.Error("ein Zugang ohne Namen wurde angelegt")
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := c.CreateWorkspaceAdmin(ctx, wsA.ID, "Erika", "hash-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gefunden, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1")
|
||||||
|
if err != nil || gefunden.ID != admin.ID || gefunden.WorkspaceID != wsA.ID {
|
||||||
|
t.Fatalf("ResolveWorkspaceAdminHash = %+v, %v", gefunden, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.RevokeWorkspaceAdmin(ctx, wsB.ID, admin.ID); err != ErrNotFound {
|
||||||
|
t.Errorf("Zurückziehen aus fremdem Workspace: err = %v, erwartet ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.RevokeWorkspaceAdmin(ctx, wsA.ID, admin.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1"); err != ErrNotFound {
|
||||||
|
t.Errorf("zurückgezogener Zugang löst noch auf: err = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Zeile bleibt für die Verwaltung und das Protokoll erhalten.
|
||||||
|
gelesen, err := c.WorkspaceAdminByID(ctx, admin.ID)
|
||||||
|
if err != nil || !gelesen.Revoked() {
|
||||||
|
t.Errorf("WorkspaceAdminByID = %+v, %v — erwartet zurückgezogen", gelesen, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdminsVanishWithWorkspace: Wird ein Workspace gelöscht, fallen
|
||||||
|
// seine Workspace-Admins mit weg (ON DELETE CASCADE) — sonst bliebe eine
|
||||||
|
// Sitzung auf einen nicht mehr vorhandenen Workspace übrig.
|
||||||
|
func TestWorkspaceAdminsVanishWithWorkspace(t *testing.T) {
|
||||||
|
c := testControl(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ws, err := c.CreateWorkspace(ctx, "Weg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin, err := c.CreateWorkspaceAdmin(ctx, ws.ID, "Erika", "hash-weg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.DeleteWorkspace(ctx, ws.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := c.WorkspaceAdminByID(ctx, admin.ID); err != ErrNotFound {
|
||||||
|
t.Errorf("Workspace-Admin nach dem Löschen des Workspace: err = %v, erwartet ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
27
internal/store/migrations_control/002_workspace_admins.sql
Normal file
27
internal/store/migrations_control/002_workspace_admins.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- Workspace-Admins (AUSBAUSTUFEN.md 4.8).
|
||||||
|
--
|
||||||
|
-- Ein Workspace-Admin verwaltet in genau einem Workspace Projekte und deren
|
||||||
|
-- Keys — nur über die Weboberfläche, nie über MCP. Er ist damit das Gegenstück
|
||||||
|
-- zum Plattform-Admin (ADMIN_TOKEN), aber ohne Zugriff auf andere Workspaces,
|
||||||
|
-- das Schreibbudget oder den Export.
|
||||||
|
--
|
||||||
|
-- Die Tabelle liegt in der Kontroll-Datenbank und nicht in der Workspace-Datei:
|
||||||
|
-- Beim Anmelden ist der Workspace noch unbekannt, und genau diese Frage — zu
|
||||||
|
-- welchem Workspace gehört dieses Token? — beantwortet die Kontroll-Datenbank.
|
||||||
|
--
|
||||||
|
-- Wie bei den API-Keys steht hier nur der Hash, nie der Klartext. Ein
|
||||||
|
-- zurückgezogener Zugang bleibt als Zeile erhalten (revoked_at gesetzt), damit
|
||||||
|
-- das Protokoll weiterhin sagen kann, wer etwas getan hat.
|
||||||
|
--
|
||||||
|
-- Leerer Text statt NULL für "noch nie benutzt" und "aktiv" — wie bei api_keys.
|
||||||
|
CREATE TABLE workspace_admins (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT NOT NULL DEFAULT '',
|
||||||
|
revoked_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_workspace_admins_workspace ON workspace_admins (workspace_id);
|
||||||
|
|
@ -83,12 +83,19 @@ const UnusedAfter = 90 * 24 * time.Hour
|
||||||
// jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen
|
// jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen
|
||||||
// Zeitpunkt vorgeben können.
|
// Zeitpunkt vorgeben können.
|
||||||
func (k APIKey) Unused(jetzt time.Time) bool {
|
func (k APIKey) Unused(jetzt time.Time) bool {
|
||||||
if k.Revoked() {
|
return ungenutzt(k.Revoked(), k.LastUsedAt, k.CreatedAt, jetzt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ungenutzt ist die gemeinsame Regel hinter APIKey.Unused und
|
||||||
|
// WorkspaceAdmin.Unused — beide Zugangsarten sollen nach demselben Maßstab
|
||||||
|
// als „ungenutzt“ gelten.
|
||||||
|
func ungenutzt(zurueckgezogen bool, zuletztBenutzt, angelegt, jetzt time.Time) bool {
|
||||||
|
if zurueckgezogen {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
zuletzt := k.LastUsedAt
|
zuletzt := zuletztBenutzt
|
||||||
if zuletzt.IsZero() {
|
if zuletzt.IsZero() {
|
||||||
zuletzt = k.CreatedAt
|
zuletzt = angelegt
|
||||||
}
|
}
|
||||||
return jetzt.Sub(zuletzt) > UnusedAfter
|
return jetzt.Sub(zuletzt) > UnusedAfter
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,32 @@ func TestAPIKeyUnused(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdminUnused: Workspace-Admins folgen derselben Regel wie Keys.
|
||||||
|
func TestWorkspaceAdminUnused(t *testing.T) {
|
||||||
|
jetzt := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) }
|
||||||
|
|
||||||
|
faelle := []struct {
|
||||||
|
name string
|
||||||
|
admin WorkspaceAdmin
|
||||||
|
erwartet bool
|
||||||
|
}{
|
||||||
|
{"gestern benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(1)}, false},
|
||||||
|
{"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(91)}, true},
|
||||||
|
{"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: tage(3)}, false},
|
||||||
|
{"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: tage(120)}, true},
|
||||||
|
{"zurückgezogen", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range faelle {
|
||||||
|
t.Run(f.name, func(t *testing.T) {
|
||||||
|
if got := f.admin.Unused(jetzt); got != f.erwartet {
|
||||||
|
t.Errorf("Unused = %v, erwartet %v", got, f.erwartet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateAPIKeyRequiresName(t *testing.T) {
|
func TestCreateAPIKeyRequiresName(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
p := testProject(t, s)
|
p := testProject(t, s)
|
||||||
|
|
|
||||||
191
internal/store/workspace_admins.go
Normal file
191
internal/store/workspace_admins.go
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkspaceAdmin ist ein Zugang, der in genau einem Workspace Projekte und
|
||||||
|
// deren Keys verwalten darf (AUSBAUSTUFEN.md 4.8).
|
||||||
|
//
|
||||||
|
// Er lebt in der Kontroll-Datenbank, nicht in der Workspace-Datei — siehe
|
||||||
|
// migrations_control/002_workspace_admins.sql, warum.
|
||||||
|
type WorkspaceAdmin struct {
|
||||||
|
ID string
|
||||||
|
WorkspaceID string
|
||||||
|
Name string
|
||||||
|
Hash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastUsedAt time.Time // Nullzeitpunkt = noch nie benutzt
|
||||||
|
RevokedAt time.Time // Nullzeitpunkt = aktiv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoked sagt, ob der Zugang zurückgezogen wurde.
|
||||||
|
func (a WorkspaceAdmin) Revoked() bool {
|
||||||
|
return !a.RevokedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused sagt, ob ein aktiver Zugang seit mehr als UnusedAfter nicht benutzt
|
||||||
|
// wurde — dieselbe Regel wie bei APIKey.Unused.
|
||||||
|
//
|
||||||
|
// Bei einem Workspace-Admin wiegt das schwerer als bei einem Projekt-Key: Ein
|
||||||
|
// vergessenes Token öffnet nicht ein Projekt, sondern den ganzen Workspace.
|
||||||
|
func (a WorkspaceAdmin) Unused(jetzt time.Time) bool {
|
||||||
|
return ungenutzt(a.Revoked(), a.LastUsedAt, a.CreatedAt, jetzt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspaceAdminColumns steht in jeder Abfrage auf workspace_admins.
|
||||||
|
const workspaceAdminColumns = `id, workspace_id, name, hash, created_at, last_used_at, revoked_at`
|
||||||
|
|
||||||
|
// scanWorkspaceAdmin liest eine Zeile aus workspace_admins.
|
||||||
|
func scanWorkspaceAdmin(scan func(...any) error) (WorkspaceAdmin, error) {
|
||||||
|
var a WorkspaceAdmin
|
||||||
|
var createdAt, lastUsedAt, revokedAt string
|
||||||
|
|
||||||
|
if err := scan(&a.ID, &a.WorkspaceID, &a.Name, &a.Hash,
|
||||||
|
&createdAt, &lastUsedAt, &revokedAt); err != nil {
|
||||||
|
return WorkspaceAdmin{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.CreatedAt = parseTime(createdAt)
|
||||||
|
a.LastUsedAt = parseTime(lastUsedAt)
|
||||||
|
a.RevokedAt = parseTime(revokedAt)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWorkspaceAdmin legt einen Workspace-Admin-Zugang an. Übergeben wird der
|
||||||
|
// Hash, nie der Klartext — wie bei CreateAPIKey.
|
||||||
|
func (c *ControlStore) CreateWorkspaceAdmin(ctx context.Context, workspaceID, name, hash string) (WorkspaceAdmin, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return WorkspaceAdmin{}, fmt.Errorf("der Zugang braucht einen Namen — daran erkennt man im Protokoll, wer etwas getan hat")
|
||||||
|
}
|
||||||
|
|
||||||
|
a := WorkspaceAdmin{
|
||||||
|
ID: NewID(),
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
|
Name: name,
|
||||||
|
Hash: hash,
|
||||||
|
}
|
||||||
|
jetzt := NowString()
|
||||||
|
|
||||||
|
if _, err := c.schreiben.ExecContext(ctx,
|
||||||
|
`INSERT INTO workspace_admins (id, workspace_id, name, hash, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
a.ID, a.WorkspaceID, a.Name, a.Hash, jetzt,
|
||||||
|
); err != nil {
|
||||||
|
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht angelegt werden: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.CreatedAt = parseTime(jetzt)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveWorkspaceAdminHash schlägt einen aktiven Zugang anhand seines Hashes
|
||||||
|
// nach — der Weg beim Anmelden.
|
||||||
|
//
|
||||||
|
// Zurückgezogene Zugänge gelten als nicht vorhanden, wie bei ResolveKeyHash.
|
||||||
|
func (c *ControlStore) ResolveWorkspaceAdminHash(ctx context.Context, hash string) (WorkspaceAdmin, error) {
|
||||||
|
row := c.lesen.QueryRowContext(ctx,
|
||||||
|
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE hash = ? AND revoked_at = ''`, hash)
|
||||||
|
|
||||||
|
a, err := scanWorkspaceAdmin(row.Scan)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return WorkspaceAdmin{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht aufgelöst werden: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspaceAdminByID liest einen Zugang anhand seiner ID — auch einen
|
||||||
|
// zurückgezogenen. Ob er noch gilt, entscheidet der Aufrufer über Revoked().
|
||||||
|
func (c *ControlStore) WorkspaceAdminByID(ctx context.Context, id string) (WorkspaceAdmin, error) {
|
||||||
|
row := c.lesen.QueryRowContext(ctx,
|
||||||
|
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE id = ?`, id)
|
||||||
|
|
||||||
|
a, err := scanWorkspaceAdmin(row.Scan)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return WorkspaceAdmin{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWorkspaceAdmins gibt alle Zugänge eines Workspace zurück, neueste zuerst.
|
||||||
|
// Zurückgezogene sind dabei, damit die Verwaltung sie noch anzeigen kann.
|
||||||
|
func (c *ControlStore) ListWorkspaceAdmins(ctx context.Context, workspaceID string) ([]WorkspaceAdmin, error) {
|
||||||
|
rows, err := c.lesen.QueryContext(ctx,
|
||||||
|
`SELECT `+workspaceAdminColumns+` FROM workspace_admins
|
||||||
|
WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var admins []WorkspaceAdmin
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanWorkspaceAdmin(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
admins = append(admins, a)
|
||||||
|
}
|
||||||
|
return admins, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveWorkspaceAdmins gibt die aktiven Zugänge ALLER Workspaces zurück —
|
||||||
|
// für die Übersicht des Plattform-Admins, in einer Abfrage statt einer je
|
||||||
|
// Workspace.
|
||||||
|
func (c *ControlStore) ListActiveWorkspaceAdmins(ctx context.Context) ([]WorkspaceAdmin, error) {
|
||||||
|
rows, err := c.lesen.QueryContext(ctx,
|
||||||
|
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE revoked_at = ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var admins []WorkspaceAdmin
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanWorkspaceAdmin(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
admins = append(admins, a)
|
||||||
|
}
|
||||||
|
return admins, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeWorkspaceAdmin zieht einen Zugang zurück.
|
||||||
|
//
|
||||||
|
// Die Workspace-ID steht mit in der Bedingung: Eine untergeschobene ID aus
|
||||||
|
// einem anderen Workspace trifft dann keine Zeile und endet als ErrNotFound.
|
||||||
|
func (c *ControlStore) RevokeWorkspaceAdmin(ctx context.Context, workspaceID, id string) error {
|
||||||
|
res, err := c.schreiben.ExecContext(ctx,
|
||||||
|
`UPDATE workspace_admins SET revoked_at = ?
|
||||||
|
WHERE id = ? AND workspace_id = ? AND revoked_at = ''`,
|
||||||
|
NowString(), id, workspaceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Workspace-Admin konnte nicht zurückgezogen werden: %w", err)
|
||||||
|
}
|
||||||
|
return requireOneRow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchWorkspaceAdmin vermerkt, dass ein Zugang gerade benutzt wurde —
|
||||||
|
// höchstens einmal pro Stunde, aus demselben Grund wie bei TouchAPIKey.
|
||||||
|
func (c *ControlStore) TouchWorkspaceAdmin(ctx context.Context, id string, lastUsed time.Time) error {
|
||||||
|
if time.Since(lastUsed) < time.Hour {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := c.schreiben.ExecContext(ctx,
|
||||||
|
`UPDATE workspace_admins SET last_used_at = ? WHERE id = ?`, NowString(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
@ -13,20 +13,34 @@ import (
|
||||||
|
|
||||||
// Session beschreibt, wer gerade zugreift.
|
// Session beschreibt, wer gerade zugreift.
|
||||||
//
|
//
|
||||||
// Es gibt zwei Ausprägungen:
|
// Es gibt drei Ausprägungen:
|
||||||
//
|
//
|
||||||
// - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf
|
// - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf
|
||||||
// jedes Projekt eines gewählten Workspace zugreifen. Key und Project sind
|
// jedes Projekt eines gewählten Workspace zugreifen. Key und Project sind
|
||||||
// leer. WorkspaceID ist leer, solange noch kein Workspace gewählt wurde —
|
// leer. WorkspaceID ist leer, solange noch kein Workspace gewählt wurde —
|
||||||
// eine solche Sitzung darf ausschließlich die Workspace-Auswahl aufrufen
|
// eine solche Sitzung darf ausschließlich die Workspace-Auswahl aufrufen
|
||||||
// (siehe requireWorkspace in middleware.go).
|
// (siehe requireWorkspace in middleware.go).
|
||||||
|
// - Workspace-Admin ("wa_…", AUSBAUSTUFEN.md 4.8): verwaltet Projekte und
|
||||||
|
// Keys in genau einem Workspace und darf dessen Projekte lesen. Er darf
|
||||||
|
// weder Einträge schreiben noch das Schreibbudget ändern, exportieren oder
|
||||||
|
// andere Workspaces sehen. Key und Project sind leer.
|
||||||
// - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem
|
// - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem
|
||||||
// Projekt in genau einem Workspace gehört. Was er darf, sagt Role.
|
// Projekt in genau einem Workspace gehört. Was er darf, sagt Role.
|
||||||
|
//
|
||||||
|
// # Warum ein eigenes Feld statt IsPlatformAdmin mitzubenutzen?
|
||||||
|
//
|
||||||
|
// Jede bestehende Prüfung auf IsPlatformAdmin weist den Workspace-Admin damit
|
||||||
|
// von selbst ab. Was er darf, ist unten an den Can…-Methoden einzeln
|
||||||
|
// freigeschaltet — was dort nicht steht, darf er nicht. Vergisst man bei einer
|
||||||
|
// künftigen Plattform-Funktion, an ihn zu denken, ist sie für ihn gesperrt,
|
||||||
|
// nicht offen.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Subject string // für die CSRF-Token-Berechnung
|
Subject string // für die CSRF-Token-Berechnung
|
||||||
IsPlatformAdmin bool
|
IsPlatformAdmin bool
|
||||||
|
IsWorkspaceAdmin bool
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
WorkspaceName string // nur beim Plattform-Admin gefüllt, für den Umschalter in der Kopfleiste
|
WorkspaceName string // bei Plattform- und Workspace-Admin gefüllt, für die Kopfleiste
|
||||||
|
WorkspaceAdmin store.WorkspaceAdmin
|
||||||
Key store.APIKey
|
Key store.APIKey
|
||||||
Project store.Project
|
Project store.Project
|
||||||
Role auth.Role
|
Role auth.Role
|
||||||
|
|
@ -36,21 +50,64 @@ type Session struct {
|
||||||
//
|
//
|
||||||
// Hier steckt die Mandantentrennung der Weboberfläche: Ein Projekt-Key kommt
|
// Hier steckt die Mandantentrennung der Weboberfläche: Ein Projekt-Key kommt
|
||||||
// ausschließlich an sein eigenes Projekt, ganz gleich welche URL aufgerufen wird.
|
// ausschließlich an sein eigenes Projekt, ganz gleich welche URL aufgerufen wird.
|
||||||
|
//
|
||||||
|
// Die beiden Admins kommen an jedes Projekt — aber nur an die ihres
|
||||||
|
// Workspace: requireProject sucht das Projekt ausschließlich in der Datei des
|
||||||
|
// Workspace, an den die Sitzung gebunden ist. Ein Projekt eines anderen
|
||||||
|
// Workspace gibt es dort schlicht nicht.
|
||||||
func (s Session) CanAccessProject(projectID string) bool {
|
func (s Session) CanAccessProject(projectID string) bool {
|
||||||
if s.IsPlatformAdmin {
|
if s.IsPlatformAdmin || s.IsWorkspaceAdmin {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s.Key.ProjectID == projectID
|
return s.Key.ProjectID == projectID
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanWrite sagt, ob Einträge angelegt oder geändert werden dürfen.
|
// CanWrite sagt, ob Einträge angelegt oder geändert werden dürfen.
|
||||||
|
//
|
||||||
|
// Der Workspace-Admin bewusst nicht: Einträge ändern die Teams über ihre
|
||||||
|
// Keys, damit im Protokoll eindeutig steht, welcher Zugang etwas geschrieben hat.
|
||||||
func (s Session) CanWrite() bool {
|
func (s Session) CanWrite() bool {
|
||||||
return s.IsPlatformAdmin || s.Role.CanWrite()
|
return s.IsPlatformAdmin || s.Role.CanWrite()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanManageKeys sagt, ob die Keys eines Projekts verwaltet werden dürfen.
|
// CanManageKeys sagt, ob die Keys eines Projekts verwaltet werden dürfen.
|
||||||
func (s Session) CanManageKeys() bool {
|
func (s Session) CanManageKeys() bool {
|
||||||
return s.IsPlatformAdmin || s.Role.CanManageKeys()
|
return s.IsPlatformAdmin || s.IsWorkspaceAdmin || s.Role.CanManageKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanImport sagt, ob ein ZIP-Archiv ins Projekt importiert werden darf.
|
||||||
|
//
|
||||||
|
// Ein Import schreibt Einträge, braucht also Schreibrecht — und legt viele
|
||||||
|
// auf einmal an, deshalb zusätzlich die Rolle der Key-Verwaltung. Für den
|
||||||
|
// Workspace-Admin fällt das damit von selbst weg (kein Schreibrecht).
|
||||||
|
func (s Session) CanImport() bool {
|
||||||
|
return s.CanWrite() && s.CanManageKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanDownloadProject sagt, ob ein ganzes Projekt als ZIP heruntergeladen
|
||||||
|
// werden darf.
|
||||||
|
//
|
||||||
|
// Vorerst nur mit der Rolle admin (dazu Plattform- und Workspace-Admin),
|
||||||
|
// entschieden am 2026-10-06. Fachlich wäre auch jeder mit Lesezugriff
|
||||||
|
// vertretbar — er kommt über die Oberfläche und MCP ohnehin an jeden Eintrag,
|
||||||
|
// das ZIP ist nur bequemer. Soll der Download breiter freigegeben werden,
|
||||||
|
// reicht es, diese eine Funktion zu ändern: Route, Seitenleiste und Tests
|
||||||
|
// richten sich danach.
|
||||||
|
func (s Session) CanDownloadProject() bool {
|
||||||
|
return s.CanManageKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanManageProjects sagt, ob Projekte des Workspace angelegt, bearbeitet und
|
||||||
|
// gelöscht werden dürfen.
|
||||||
|
func (s Session) CanManageProjects() bool {
|
||||||
|
return s.IsPlatformAdmin || s.IsWorkspaceAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanEditWriteBudget sagt, ob das Schreibbudget eines Projekts geändert werden
|
||||||
|
// darf. Nur der Plattform-Admin: Das Budget schützt den gemeinsamen Server,
|
||||||
|
// nicht den einzelnen Workspace (siehe AUSBAUSTUFEN.md 3.8 und 4.8).
|
||||||
|
func (s Session) CanEditWriteBudget() bool {
|
||||||
|
return s.IsPlatformAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActorName ist der Name, unter dem diese Sitzung im Audit-Log auftaucht.
|
// ActorName ist der Name, unter dem diese Sitzung im Audit-Log auftaucht.
|
||||||
|
|
@ -58,6 +115,9 @@ func (s Session) ActorName() string {
|
||||||
if s.IsPlatformAdmin {
|
if s.IsPlatformAdmin {
|
||||||
return "platform-admin"
|
return "platform-admin"
|
||||||
}
|
}
|
||||||
|
if s.IsWorkspaceAdmin {
|
||||||
|
return "workspace-admin:" + s.WorkspaceAdmin.Name
|
||||||
|
}
|
||||||
return s.Key.Name
|
return s.Key.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
186
internal/web/download_test.go
Normal file
186
internal/web/download_test.go
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tests für den Download als ZIP (handlers_download.go).
|
||||||
|
|
||||||
|
// ladeHerunter ruft einen Download ab und gibt die Antwort zurück.
|
||||||
|
func ladeHerunter(h http.Handler, pfad string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, pfad, nil)
|
||||||
|
r.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// zipInhalt öffnet ein heruntergeladenes ZIP und gibt Pfad → Inhalt zurück.
|
||||||
|
func zipInhalt(t *testing.T, daten []byte) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("kein gültiges ZIP: %v", err)
|
||||||
|
}
|
||||||
|
inhalt := make(map[string]string)
|
||||||
|
for _, f := range archiv.File {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
inhalt[f.Name] = string(b)
|
||||||
|
}
|
||||||
|
return inhalt
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjektDownloadAlsAdmin(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: "deployment", Title: "Deployment",
|
||||||
|
Content: "Wir deployen freitags nie.", Source: store.SourceHuman,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin))
|
||||||
|
w := ladeHerunter(h, "/p/testprojekt/export.zip", cookie)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Status %d, erwartet 200: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "application/zip" {
|
||||||
|
t.Errorf("Content-Type %q", ct)
|
||||||
|
}
|
||||||
|
if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="munin-testprojekt-`) {
|
||||||
|
t.Errorf("Content-Disposition %q", cd)
|
||||||
|
}
|
||||||
|
|
||||||
|
inhalt := zipInhalt(t, w.Body.Bytes())
|
||||||
|
if !strings.Contains(inhalt["testprojekt/deployment.md"], "freitags nie") {
|
||||||
|
t.Errorf("Eintrag fehlt im ZIP, enthalten: %v", schluessel(inhalt))
|
||||||
|
}
|
||||||
|
if _, ok := inhalt["testprojekt/_index.md"]; !ok {
|
||||||
|
t.Error("_index.md fehlt im ZIP")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Download steht im Protokoll des Projekts.
|
||||||
|
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
|
||||||
|
gefunden := false
|
||||||
|
for _, e := range ereignisse {
|
||||||
|
if e.Action == store.ActionExportDownload && e.Actor == "verwalterin" {
|
||||||
|
gefunden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
t.Error("Download fehlt im Protokoll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjektDownloadNurFuerAdmins: Lesen und Schreiben reichen (vorerst)
|
||||||
|
// nicht, siehe Session.CanDownloadProject.
|
||||||
|
func TestProjektDownloadNurFuerAdmins(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
|
||||||
|
for _, rolle := range []auth.Role{auth.RoleRead, auth.RoleWrite} {
|
||||||
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "key-"+string(rolle), rolle))
|
||||||
|
if w := ladeHerunter(h, "/p/testprojekt/export.zip", cookie); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("Rolle %s: Status %d, erwartet 403", rolle, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjektDownloadFremdesProjekt: Der Admin von Projekt A kommt nicht an
|
||||||
|
// das ZIP von Projekt B.
|
||||||
|
func TestProjektDownloadFremdesProjekt(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
|
||||||
|
if _, err := db.CreateProject(context.Background(), projekt.WorkspaceID, "anderes", "Anderes", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin))
|
||||||
|
|
||||||
|
if w := ladeHerunter(h, "/p/anderes/export.zip", cookie); w.Code == http.StatusOK {
|
||||||
|
t.Error("ZIP eines fremden Projekts ausgeliefert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceDownload(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
anderes, err := db.CreateProject(ctx, projekt.WorkspaceID, "anderes", "Anderes", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range []store.Project{projekt, anderes} {
|
||||||
|
if _, err := db.PutEntry(ctx, store.EntryWrite{
|
||||||
|
ProjectID: p.ID, Slug: "notiz", Title: "Notiz", Content: "aus " + p.Slug, Source: store.SourceHuman,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||||||
|
plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||||||
|
|
||||||
|
for name, cookie := range map[string]*http.Cookie{"Workspace-Admin": wsAdmin.cookie, "Plattform-Admin": plattform} {
|
||||||
|
w := ladeHerunter(h, "/admin/export.zip", cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: Status %d, erwartet 200", name, w.Code)
|
||||||
|
}
|
||||||
|
inhalt := zipInhalt(t, w.Body.Bytes())
|
||||||
|
for _, pfad := range []string{"_workspace.md", "testprojekt/notiz.md", "anderes/notiz.md"} {
|
||||||
|
if _, ok := inhalt[pfad]; !ok {
|
||||||
|
t.Errorf("%s: %s fehlt im ZIP, enthalten: %v", name, pfad, schluessel(inhalt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Projekt-Admin verwaltet keine Projekte, also auch kein Workspace-ZIP.
|
||||||
|
key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin))
|
||||||
|
if w := ladeHerunter(h, "/admin/export.zip", key); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("Projekt-Admin: Status %d, erwartet 403", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDateinamenTeil(t *testing.T) {
|
||||||
|
faelle := map[string]string{
|
||||||
|
"Plattform": "plattform",
|
||||||
|
"Büro Süd & Nord": "buero-sued-nord",
|
||||||
|
`Böse "Name"` + "\r\n": "boese-name",
|
||||||
|
"!!!": "workspace",
|
||||||
|
"": "workspace",
|
||||||
|
}
|
||||||
|
for eingabe, erwartet := range faelle {
|
||||||
|
if got := dateinamenTeil(eingabe, "workspace"); got != erwartet {
|
||||||
|
t.Errorf("dateinamenTeil(%q) = %q, erwartet %q", eingabe, got, erwartet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func schluessel(m map[string]string) []string {
|
||||||
|
var liste []string
|
||||||
|
for k := range m {
|
||||||
|
liste = append(liste, k)
|
||||||
|
}
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,8 @@ type projectListItem struct {
|
||||||
EigenesLimit int
|
EigenesLimit int
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleProjectList zeigt alle Projekte — nur für den Plattform-Admin.
|
// handleProjectList zeigt alle Projekte des Workspace — für Plattform- und
|
||||||
|
// Workspace-Admin.
|
||||||
func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
|
||||||
ws, sess := s.workspaceAndSession(r)
|
ws, sess := s.workspaceAndSession(r)
|
||||||
|
|
||||||
|
|
@ -87,7 +88,7 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
ProjectID: projekt.ID, Actor: "platform-admin",
|
ProjectID: projekt.ID, Actor: sess.ActorName(),
|
||||||
Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -98,7 +99,7 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// handleProjectDelete löscht ein Projekt samt Einträgen und Keys.
|
// handleProjectDelete löscht ein Projekt samt Einträgen und Keys.
|
||||||
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
ws, _ := s.workspaceAndSession(r)
|
ws, sess := s.workspaceAndSession(r)
|
||||||
id := r.FormValue("id")
|
id := r.FormValue("id")
|
||||||
|
|
||||||
projekt, err := ws.Store.ProjectByID(r.Context(), id)
|
projekt, err := ws.Store.ProjectByID(r.Context(), id)
|
||||||
|
|
@ -127,7 +128,7 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
ws.Search.Forget(id)
|
ws.Search.Forget(id)
|
||||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
ProjectID: id, Actor: "platform-admin",
|
ProjectID: id, Actor: sess.ActorName(),
|
||||||
Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||||
Detail: projekt.Slug,
|
Detail: projekt.Slug,
|
||||||
})
|
})
|
||||||
|
|
@ -214,8 +215,12 @@ func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleProjectEditSave speichert Name, Beschreibung und Obergrenze.
|
// handleProjectEditSave speichert Name, Beschreibung und Obergrenze.
|
||||||
|
//
|
||||||
|
// Die Obergrenze (Schreibbudget) darf nur der Plattform-Admin ändern. Beim
|
||||||
|
// Workspace-Admin wird das Feld nicht nur im Formular ausgeblendet, sondern
|
||||||
|
// hier ignoriert — ein von Hand nachgebautes Formular ändert sie also nicht.
|
||||||
func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
|
||||||
ws, _ := s.workspaceAndSession(r)
|
ws, sess := s.workspaceAndSession(r)
|
||||||
|
|
||||||
projekt, ok := s.projectFromSlug(w, r)
|
projekt, ok := s.projectFromSlug(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -230,13 +235,17 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
limit := projekt.WriteLimitHourly
|
||||||
|
if sess.CanEditWriteBudget() {
|
||||||
roh := strings.TrimSpace(r.FormValue("write_limit_hourly"))
|
roh := strings.TrimSpace(r.FormValue("write_limit_hourly"))
|
||||||
limit, err := strconv.Atoi(roh)
|
var err error
|
||||||
|
limit, err = strconv.Atoi(roh)
|
||||||
if err != nil || limit < 0 {
|
if err != nil || limit < 0 {
|
||||||
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "",
|
s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "",
|
||||||
"Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).")
|
"Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
aenderungen := projektAenderungen(projekt, name, beschreibung, limit)
|
aenderungen := projektAenderungen(projekt, name, beschreibung, limit)
|
||||||
if len(aenderungen) == 0 {
|
if len(aenderungen) == 0 {
|
||||||
|
|
@ -258,7 +267,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
|
||||||
// interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine
|
// interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine
|
||||||
// Betriebsentscheidung.
|
// Betriebsentscheidung.
|
||||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
ProjectID: projekt.ID, Actor: "platform-admin",
|
ProjectID: projekt.ID, Actor: sess.ActorName(),
|
||||||
Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||||
Detail: strings.Join(aenderungen, "; "),
|
Detail: strings.Join(aenderungen, "; "),
|
||||||
})
|
})
|
||||||
|
|
@ -289,9 +298,10 @@ func projektAenderungen(alt store.Project, name, beschreibung string, limit int)
|
||||||
// projectFromSlug löst den Bezeichner aus der URL zu einem Projekt auf.
|
// projectFromSlug löst den Bezeichner aus der URL zu einem Projekt auf.
|
||||||
//
|
//
|
||||||
// Eigene Funktion statt der Middleware requireProject: Die liegt unter
|
// Eigene Funktion statt der Middleware requireProject: Die liegt unter
|
||||||
// /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier ist ohnehin
|
// /p/{project}/ und prüft den Zugriff eines Projekt-Zugangs. Hier sind ohnehin
|
||||||
// schon nur der Plattform-Admin unterwegs (siehe Routes), und der Bezeichner
|
// nur Plattform- und Workspace-Admin unterwegs (siehe Routes), und der
|
||||||
// steht unter einem anderen Platzhalter.
|
// Bezeichner steht unter einem anderen Platzhalter. Gesucht wird nur in der
|
||||||
|
// Datei des eigenen Workspace.
|
||||||
func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) {
|
func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) {
|
||||||
ws, sess := s.workspaceAndSession(r)
|
ws, sess := s.workspaceAndSession(r)
|
||||||
|
|
||||||
|
|
@ -482,7 +492,8 @@ func (s *Server) handleProjectAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderAudit(w, r, store.AuditFilter{ProjectID: project.ID}, false, "Protokoll – "+project.Name)
|
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.
|
// handleGlobalAudit zeigt das Protokoll aller Projekte des Workspace — für
|
||||||
|
// Plattform- und Workspace-Admin.
|
||||||
func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderAudit(w, r, store.AuditFilter{}, true, "Protokoll")
|
s.renderAudit(w, r, store.AuditFilter{}, true, "Protokoll")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import (
|
||||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
sess, _ := SessionFrom(r)
|
sess, _ := SessionFrom(r)
|
||||||
|
|
||||||
|
if sess.IsWorkspaceAdmin {
|
||||||
|
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
if sess.IsPlatformAdmin {
|
if sess.IsPlatformAdmin {
|
||||||
if sess.WorkspaceID == "" {
|
if sess.WorkspaceID == "" {
|
||||||
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
|
||||||
|
|
@ -68,7 +72,7 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
// resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück.
|
// resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück.
|
||||||
//
|
//
|
||||||
// Der Plattform-Admin bekommt hier noch keinen Workspace zugewiesen — das
|
// Der Plattform-Admin bekommt hier noch keinen Workspace zugewiesen — das
|
||||||
// Subject ist zunächst nur "platform". Erst über /admin/workspaces wird ein
|
// Subject enthält zunächst nur den Fingerabdruck des ADMIN_TOKEN. Erst über /admin/workspaces wird ein
|
||||||
// Workspace gewählt und das Subject um dessen ID ergänzt (siehe
|
// Workspace gewählt und das Subject um dessen ID ergänzt (siehe
|
||||||
// handleWorkspaceSelect).
|
// handleWorkspaceSelect).
|
||||||
func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
|
func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
|
||||||
|
|
@ -81,7 +85,11 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
|
||||||
// verglichen wird und die Antwortzeit nichts verrät.
|
// verglichen wird und die Antwortzeit nichts verrät.
|
||||||
if auth.HashKey(key) == s.adminTokenHash {
|
if auth.HashKey(key) == s.adminTokenHash {
|
||||||
log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r))
|
log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r))
|
||||||
return auth.SubjectPlatformAdmin, true
|
return s.plattformSubjekt(""), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.LooksLikeWorkspaceAdminToken(key) {
|
||||||
|
return s.resolveWorkspaceAdminLogin(r, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auth.LooksLikeKey(key) {
|
if !auth.LooksLikeKey(key) {
|
||||||
|
|
@ -111,6 +119,31 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
|
||||||
return auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, true
|
return auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveWorkspaceAdminLogin prüft das Token eines Workspace-Admins und gibt
|
||||||
|
// sein Session-Subject zurück.
|
||||||
|
//
|
||||||
|
// Die Anmeldung landet im Protokoll des Workspace, ohne Projekt — sie betrifft
|
||||||
|
// keines im Besonderen. Sichtbar ist sie im Protokoll aller Projekte
|
||||||
|
// (/admin/audit).
|
||||||
|
func (s *Server) resolveWorkspaceAdminLogin(r *http.Request, token string) (string, bool) {
|
||||||
|
admin, err := s.control.ResolveWorkspaceAdminHash(r.Context(), auth.HashKey(token))
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := s.workspaces.Get(admin.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
|
Actor: Session{IsWorkspaceAdmin: true, WorkspaceAdmin: admin}.ActorName(),
|
||||||
|
Action: store.ActionLoginSuccess, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||||
|
})
|
||||||
|
|
||||||
|
return auth.SubjectWorkspaceAdminPrefix + admin.WorkspaceID + ":" + admin.ID, true
|
||||||
|
}
|
||||||
|
|
||||||
// handleLogout löscht den Session-Cookie.
|
// handleLogout löscht den Session-Cookie.
|
||||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
cookie := &http.Cookie{
|
cookie := &http.Cookie{
|
||||||
|
|
@ -127,6 +160,12 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// plattformSubjekt baut das Session-Subject des Plattform-Admins mit dem
|
||||||
|
// Fingerabdruck des aktuellen ADMIN_TOKEN. workspaceID darf leer sein.
|
||||||
|
func (s *Server) plattformSubjekt(workspaceID string) string {
|
||||||
|
return auth.PlatformSubject(s.adminFingerprint, workspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
// setSessionCookie signiert das Subject und setzt es als Cookie.
|
// setSessionCookie signiert das Subject und setzt es als Cookie.
|
||||||
func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string) {
|
func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string) {
|
||||||
cookie := &http.Cookie{
|
cookie := &http.Cookie{
|
||||||
|
|
|
||||||
125
internal/web/handlers_download.go
Normal file
125
internal/web/handlers_download.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/export"
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Download als ZIP ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Der Export unter EXPORT_DIR landet auf der Platte des Servers — gut für
|
||||||
|
// Sicherungen und Git, aber für ein Team unerreichbar. Der Download erzeugt
|
||||||
|
// dasselbe als ZIP und schickt es direkt an den Browser:
|
||||||
|
//
|
||||||
|
// - unabhängig davon, ob EXPORT_DIR gesetzt ist
|
||||||
|
// - nichts wird auf dem Server gespeichert, jedes ZIP entsteht frisch
|
||||||
|
// - im selben Aufbau wie der Export, passt also direkt in den Import
|
||||||
|
// (/p/{projekt}/import) — auch auf einem anderen Server
|
||||||
|
|
||||||
|
// handleProjectDownload liefert ein Projekt als ZIP aus. Wer das darf,
|
||||||
|
// entscheidet Session.CanDownloadProject (Route in server.go).
|
||||||
|
func (s *Server) handleProjectDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws, sess := s.workspaceAndSession(r)
|
||||||
|
project, _ := ProjectFrom(r)
|
||||||
|
|
||||||
|
dateien, err := export.ProjektDateien(r.Context(), ws.Store, project)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Export fehlgeschlagen", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
|
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
||||||
|
Action: store.ActionExportDownload, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||||
|
Detail: fmt.Sprintf("ZIP mit %d Einträgen", len(dateien)-1), // ohne _index.md
|
||||||
|
})
|
||||||
|
|
||||||
|
sendeZIP(w, "munin-"+project.Slug, dateien)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWorkspaceDownload liefert alle Projekte des gewählten Workspace als
|
||||||
|
// ein ZIP aus — für Plattform- und Workspace-Admins.
|
||||||
|
func (s *Server) handleWorkspaceDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws, sess := s.workspaceAndSession(r)
|
||||||
|
|
||||||
|
// Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung — wie
|
||||||
|
// beim Export auf die Platte (handleExport).
|
||||||
|
eintrag, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Workspace konnte nicht gelesen werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dateien, err := export.WorkspaceDateien(r.Context(), ws.Store, eintrag)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Export fehlgeschlagen", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
|
Actor: sess.ActorName(), Action: store.ActionExportDownload, Source: store.SourceWeb,
|
||||||
|
IP: auth.ClientIP(r), Detail: fmt.Sprintf("ZIP des ganzen Workspace, %d Dateien", len(dateien)),
|
||||||
|
})
|
||||||
|
|
||||||
|
sendeZIP(w, "munin-"+dateinamenTeil(eintrag.Name, "workspace"), dateien)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendeZIP schickt die Dateien als ZIP-Download. name ist der Dateiname ohne
|
||||||
|
// Datum und Endung.
|
||||||
|
//
|
||||||
|
// Bis hierher ist alles aus der Datenbank gelesen. Was jetzt noch scheitern
|
||||||
|
// kann, ist nur die Verbindung — dann bekommt der Browser ein abgebrochenes
|
||||||
|
// Archiv, und eine Fehlerseite wäre ohnehin nicht mehr möglich, weil die
|
||||||
|
// Kopfzeilen schon unterwegs sind.
|
||||||
|
func sendeZIP(w http.ResponseWriter, name string, dateien []export.Datei) {
|
||||||
|
dateiname := name + "-" + time.Now().Format("2006-01-02") + ".zip"
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
// attachment: Der Browser speichert die Datei, statt sie anzeigen zu wollen.
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+dateiname+`"`)
|
||||||
|
// Der Inhalt ist der gesamte Bestand. Er gehört in keinen Zwischenspeicher,
|
||||||
|
// weder im Browser noch in einem Proxy dazwischen.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
if err := export.SchreibeZIP(w, dateien); err != nil {
|
||||||
|
// Nur ins Log: Antworten lässt sich nicht mehr.
|
||||||
|
log.Printf("ZIP-Download abgebrochen: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateinamenTeil macht aus einem frei gewählten Namen ein sicheres Stück
|
||||||
|
// Dateiname: Kleinbuchstaben, Ziffern und Bindestriche. Umlaute werden
|
||||||
|
// umschrieben. Bleibt nichts übrig, gilt ersatz.
|
||||||
|
//
|
||||||
|
// Wichtig ist das wegen der Kopfzeile Content-Disposition: Ein Anführungszeichen
|
||||||
|
// oder Zeilenumbruch im Workspace-Namen dürfte dort nicht hineingeraten.
|
||||||
|
func dateinamenTeil(name, ersatz string) string {
|
||||||
|
name = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss",
|
||||||
|
"Ä", "ae", "Ö", "oe", "Ü", "ue").Replace(strings.ToLower(name))
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
bindestrich := false
|
||||||
|
for _, z := range name {
|
||||||
|
switch {
|
||||||
|
case z >= 'a' && z <= 'z', z >= '0' && z <= '9':
|
||||||
|
b.WriteRune(z)
|
||||||
|
bindestrich = false
|
||||||
|
case !bindestrich && b.Len() > 0:
|
||||||
|
b.WriteByte('-')
|
||||||
|
bindestrich = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ergebnis := strings.TrimSuffix(b.String(), "-")
|
||||||
|
if ergebnis == "" {
|
||||||
|
return ersatz
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
@ -353,7 +353,7 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.Search.Update(project.ID, geschrieben)
|
ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug)
|
||||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
||||||
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb,
|
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb,
|
||||||
|
|
@ -398,7 +398,7 @@ func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.Search.Delete(project.ID, slug)
|
ws.Search.Refresh(r.Context(), project.ID, slug)
|
||||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
||||||
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb,
|
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb,
|
||||||
|
|
@ -447,7 +447,7 @@ func (s *Server) handleEntryBulkTags(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range geaendert {
|
for _, e := range geaendert {
|
||||||
ws.Search.Update(project.ID, e)
|
ws.Search.Refresh(r.Context(), project.ID, e.Slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(geaendert) > 0 {
|
if len(geaendert) > 0 {
|
||||||
|
|
@ -610,7 +610,7 @@ func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.Search.Update(project.ID, geschrieben)
|
ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug)
|
||||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
||||||
Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb,
|
Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ package web
|
||||||
// Nur mit der Rolle admin, wie die Zugänge. Ein Import schreibt womöglich
|
// Nur mit der Rolle admin, wie die Zugänge. Ein Import schreibt womöglich
|
||||||
// tausende Einträge auf einmal, und wer ihn im Modus „Überschreiben“ startet,
|
// tausende Einträge auf einmal, und wer ihn im Modus „Überschreiben“ startet,
|
||||||
// ändert Einträge, die andere geschrieben haben.
|
// ändert Einträge, die andere geschrieben haben.
|
||||||
|
//
|
||||||
|
// Der Workspace-Admin darf zwar Zugänge verwalten, aber keine Einträge
|
||||||
|
// schreiben — deshalb prüft die Route Session.CanImport und nicht allein die
|
||||||
|
// Key-Verwaltung.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -100,7 +104,7 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||||
// Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den
|
// Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den
|
||||||
// Fehler melden.
|
// Fehler melden.
|
||||||
for _, e := range bericht.Geschrieben {
|
for _, e := range bericht.Geschrieben {
|
||||||
ws.Search.Update(project.ID, e)
|
ws.Search.Refresh(r.Context(), project.ID, e.Slug)
|
||||||
}
|
}
|
||||||
if !daten.Vorschau && len(bericht.Geschrieben) > 0 {
|
if !daten.Vorschau && len(bericht.Geschrieben) > 0 {
|
||||||
// Ein Protokolleintrag für den ganzen Import statt einer je Datei.
|
// Ein Protokolleintrag für den ganzen Import statt einer je Datei.
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ func (s *Server) handleTrashRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
// eine eigene Protokollzeile — so lässt sich im Protokoll nach einem
|
// eine eigene Protokollzeile — so lässt sich im Protokoll nach einem
|
||||||
// einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen.
|
// einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen.
|
||||||
for _, e := range zurueck {
|
for _, e := range zurueck {
|
||||||
ws.Search.Update(project.ID, e)
|
ws.Search.Refresh(r.Context(), project.ID, e.Slug)
|
||||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
||||||
Action: store.ActionEntryRestore, EntrySlug: e.Slug, Source: store.SourceWeb,
|
Action: store.ActionEntryRestore, EntrySlug: e.Slug, Source: store.SourceWeb,
|
||||||
|
|
|
||||||
106
internal/web/handlers_tags.go
Normal file
106
internal/web/handlers_tags.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagListData sind die Daten der Seite "Alle Schlagworte".
|
||||||
|
type tagListData struct {
|
||||||
|
// Sortierung ist "anzahl" (häufigste zuerst, Standard) oder "name".
|
||||||
|
Sortierung string
|
||||||
|
|
||||||
|
// Tags ist die vollständige Liste — bei Sortierung "anzahl" genutzt.
|
||||||
|
Tags []store.TagCount
|
||||||
|
|
||||||
|
// Gruppen fasst die Schlagworte nach Anfangsbuchstaben zusammen — bei
|
||||||
|
// Sortierung "name" genutzt.
|
||||||
|
Gruppen []tagGruppe
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagGruppe ist ein Buchstabenabschnitt der alphabetischen Ansicht.
|
||||||
|
type tagGruppe struct {
|
||||||
|
Buchstabe string
|
||||||
|
Tags []store.TagCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTagList zeigt alle Schlagworte eines Projekts. Die Seitenleiste zeigt
|
||||||
|
// nur die häufigsten (siehe navTagLimit), hier steht der Rest.
|
||||||
|
func (s *Server) handleTagList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws, _ := WorkspaceFrom(r)
|
||||||
|
project, _ := ProjectFrom(r)
|
||||||
|
|
||||||
|
tags, err := ws.Store.ListTags(r.Context(), project.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Schlagworte konnten nicht geladen werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
daten := tagListData{Sortierung: "anzahl", Tags: tags}
|
||||||
|
if r.URL.Query().Get("sort") == "name" {
|
||||||
|
daten.Sortierung = "name"
|
||||||
|
daten.Gruppen = gruppiereTags(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.render(w, r, "tags.html", http.StatusOK, pageData{
|
||||||
|
Title: "Schlagworte – " + project.Name,
|
||||||
|
Bereich: "schlagworte",
|
||||||
|
Data: daten,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// gruppiereTags sortiert die Schlagworte alphabetisch und teilt sie nach
|
||||||
|
// ihrem Anfangsbuchstaben auf. Schlagworte, die nicht mit einem Buchstaben
|
||||||
|
// beginnen (etwa "2fa"), landen gemeinsam in der Gruppe "#".
|
||||||
|
func gruppiereTags(tags []store.TagCount) []tagGruppe {
|
||||||
|
// Eine Kopie sortieren, damit die Reihenfolge des Aufrufers erhalten bleibt.
|
||||||
|
sortiert := make([]store.TagCount, len(tags))
|
||||||
|
copy(sortiert, tags)
|
||||||
|
sort.Slice(sortiert, func(i, j int) bool {
|
||||||
|
a, b := sortierSchluessel(sortiert[i].Tag), sortierSchluessel(sortiert[j].Tag)
|
||||||
|
if a != b {
|
||||||
|
return a < b
|
||||||
|
}
|
||||||
|
// Gleicher Schlüssel (etwa "ubersicht" und "übersicht"): die
|
||||||
|
// ursprüngliche Schreibweise entscheidet, damit die Reihenfolge
|
||||||
|
// bei jedem Aufruf dieselbe ist.
|
||||||
|
return sortiert[i].Tag < sortiert[j].Tag
|
||||||
|
})
|
||||||
|
|
||||||
|
var gruppen []tagGruppe
|
||||||
|
for _, t := range sortiert {
|
||||||
|
buchstabe := anfangsbuchstabe(t.Tag)
|
||||||
|
// Die Liste ist sortiert, also reicht der Blick auf die letzte Gruppe.
|
||||||
|
if len(gruppen) == 0 || gruppen[len(gruppen)-1].Buchstabe != buchstabe {
|
||||||
|
gruppen = append(gruppen, tagGruppe{Buchstabe: buchstabe})
|
||||||
|
}
|
||||||
|
letzte := &gruppen[len(gruppen)-1]
|
||||||
|
letzte.Tags = append(letzte.Tags, t)
|
||||||
|
}
|
||||||
|
return gruppen
|
||||||
|
}
|
||||||
|
|
||||||
|
// umlautErsatz bildet Umlaute auf ihren Grundbuchstaben ab — so sortiert
|
||||||
|
// "übersicht" bei "u" statt hinter "z" (ein einfacher Byte-Vergleich würde
|
||||||
|
// alle Umlaute ans Ende stellen).
|
||||||
|
var umlautErsatz = strings.NewReplacer("ä", "a", "ö", "o", "ü", "u", "ß", "ss")
|
||||||
|
|
||||||
|
// sortierSchluessel liefert die Form eines Schlagworts, nach der sortiert wird.
|
||||||
|
func sortierSchluessel(tag string) string {
|
||||||
|
return umlautErsatz.Replace(strings.ToLower(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// anfangsbuchstabe liefert die Gruppenüberschrift für ein Schlagwort: den
|
||||||
|
// ersten Buchstaben groß und ohne Umlaut, oder "#" für alles andere.
|
||||||
|
func anfangsbuchstabe(tag string) string {
|
||||||
|
erstes, _ := utf8.DecodeRuneInString(sortierSchluessel(tag))
|
||||||
|
if !unicode.IsLetter(erstes) {
|
||||||
|
return "#"
|
||||||
|
}
|
||||||
|
return string(unicode.ToUpper(erstes))
|
||||||
|
}
|
||||||
100
internal/web/handlers_workspace_admins.go
Normal file
100
internal/web/handlers_workspace_admins.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workspace-Admins anlegen und zurückziehen (AUSBAUSTUFEN.md 4.8).
|
||||||
|
//
|
||||||
|
// Beides darf nur der Plattform-Admin (siehe Routes): Ein Workspace-Admin soll
|
||||||
|
// sich keine weiteren Workspace-Admins schaffen können — sonst ließe sich ein
|
||||||
|
// zurückgezogener Zugang durch einen vorher angelegten zweiten ersetzen, ohne
|
||||||
|
// dass der Plattform-Admin davon erfährt.
|
||||||
|
//
|
||||||
|
// Die Seite dazu ist die Bearbeitungsseite des Workspace
|
||||||
|
// (/admin/workspaces/{id}/edit), die Formulare senden an die Routen hier.
|
||||||
|
|
||||||
|
// handleWorkspaceAdminCreate erzeugt einen Workspace-Admin-Zugang und zeigt
|
||||||
|
// das Token genau einmal an.
|
||||||
|
func (s *Server) handleWorkspaceAdminCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws, ok := s.workspaceFromPath(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
|
||||||
|
klartext, err := auth.GenerateWorkspaceAdminToken()
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Token konnte nicht erzeugt werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := s.control.CreateWorkspaceAdmin(r.Context(), ws.ID, name, auth.HashKey(klartext))
|
||||||
|
if err != nil {
|
||||||
|
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, _ := SessionFrom(r)
|
||||||
|
s.logWorkspaceAdminEvent(r, ws.ID, sess, store.ActionWorkspaceAdminCreated, admin.Name)
|
||||||
|
|
||||||
|
s.renderWorkspaceEdit(w, r, http.StatusOK, ws,
|
||||||
|
workspaceEditData{NewToken: klartext, NewTokenName: admin.Name},
|
||||||
|
"Workspace-Admin angelegt. Das Token wird nur jetzt angezeigt — bitte gleich übernehmen.", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWorkspaceAdminRevoke zieht einen Workspace-Admin-Zugang zurück. Eine
|
||||||
|
// laufende Sitzung endet damit beim nächsten Seitenaufruf (siehe
|
||||||
|
// workspaceAdminSession in middleware.go).
|
||||||
|
func (s *Server) handleWorkspaceAdminRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws, ok := s.workspaceFromPath(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.FormValue("id")
|
||||||
|
admin, err := s.control.WorkspaceAdminByID(r.Context(), id)
|
||||||
|
// Gehört der Zugang zu einem anderen Workspace, wie "nicht vorhanden"
|
||||||
|
// behandeln — dieselbe Regel wie bei den Keys (handleKeyRevoke).
|
||||||
|
if errors.Is(err, store.ErrNotFound) || (err == nil && admin.WorkspaceID != ws.ID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Workspace-Admin konnte nicht gelesen werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.control.RevokeWorkspaceAdmin(r.Context(), ws.ID, id); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||||
|
s.serverError(w, "Workspace-Admin konnte nicht zurückgezogen werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, _ := SessionFrom(r)
|
||||||
|
s.logWorkspaceAdminEvent(r, ws.ID, sess, store.ActionWorkspaceAdminRevoked, admin.Name)
|
||||||
|
|
||||||
|
s.renderWorkspaceEdit(w, r, http.StatusOK, ws, workspaceEditData{},
|
||||||
|
"Zugang zurückgezogen. Er gilt ab sofort nicht mehr.", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// logWorkspaceAdminEvent schreibt ein Ereignis ins Protokoll des betroffenen
|
||||||
|
// Workspace — dort, wo auch alles steht, was der Zugang später tut. Der
|
||||||
|
// Workspace muss dafür nicht der gerade gewählte des Plattform-Admins sein.
|
||||||
|
func (s *Server) logWorkspaceAdminEvent(r *http.Request, workspaceID string, sess Session, aktion, name string) {
|
||||||
|
ws, err := s.workspaces.Get(workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Protokoll von Workspace %s nicht erreichbar: %v", workspaceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
|
Actor: sess.ActorName(), Action: aktion, Source: store.SourceWeb,
|
||||||
|
IP: auth.ClientIP(r), Detail: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"munin/internal/store"
|
"munin/internal/store"
|
||||||
)
|
)
|
||||||
|
|
@ -24,6 +25,11 @@ type workspaceItem struct {
|
||||||
Name string
|
Name string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
Projects int
|
Projects int
|
||||||
|
|
||||||
|
// Admins zählt die aktiven Workspace-Admins, AdminsUngenutzt die davon,
|
||||||
|
// die seit über 90 Tagen niemand benutzt hat (store.WorkspaceAdmin.Unused).
|
||||||
|
Admins int
|
||||||
|
AdminsUngenutzt int
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWorkspaceList zeigt alle Workspaces zur Auswahl.
|
// handleWorkspaceList zeigt alle Workspaces zur Auswahl.
|
||||||
|
|
@ -45,6 +51,23 @@ func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, sta
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die aktiven Workspace-Admins aller Workspaces in einer Abfrage, danach
|
||||||
|
// je Workspace gezählt.
|
||||||
|
admins, err := s.control.ListActiveWorkspaceAdmins(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Workspace-Admins konnten nicht geladen werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jetzt := time.Now()
|
||||||
|
aktiv := make(map[string]int)
|
||||||
|
ungenutzt := make(map[string]int)
|
||||||
|
for _, a := range admins {
|
||||||
|
aktiv[a.WorkspaceID]++
|
||||||
|
if a.Unused(jetzt) {
|
||||||
|
ungenutzt[a.WorkspaceID]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Neueste zuerst — genau umgekehrt zu ListWorkspaces, das für
|
// Neueste zuerst — genau umgekehrt zu ListWorkspaces, das für
|
||||||
// EnsureDefaultWorkspace die älteste zuerst braucht.
|
// EnsureDefaultWorkspace die älteste zuerst braucht.
|
||||||
items := make([]workspaceItem, len(workspaces))
|
items := make([]workspaceItem, len(workspaces))
|
||||||
|
|
@ -52,6 +75,8 @@ func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, sta
|
||||||
items[len(workspaces)-1-i] = workspaceItem{
|
items[len(workspaces)-1-i] = workspaceItem{
|
||||||
ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"),
|
ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"),
|
||||||
Projects: s.projectCount(r, ws.ID),
|
Projects: s.projectCount(r, ws.ID),
|
||||||
|
Admins: aktiv[ws.ID],
|
||||||
|
AdminsUngenutzt: ungenutzt[ws.ID],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +108,7 @@ func (s *Server) handleWorkspaceCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
sess, _ := SessionFrom(r)
|
sess, _ := SessionFrom(r)
|
||||||
log.Printf("Workspace %q (%s) angelegt von %s", ws.Name, ws.ID, sess.ActorName())
|
log.Printf("Workspace %q (%s) angelegt von %s", ws.Name, ws.ID, sess.ActorName())
|
||||||
|
|
||||||
s.setSessionCookie(w, r, "platform:"+ws.ID)
|
s.setSessionCookie(w, r, s.plattformSubjekt(ws.ID))
|
||||||
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +118,20 @@ func (s *Server) handleWorkspaceCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
type workspaceEditData struct {
|
type workspaceEditData struct {
|
||||||
Workspace workspaceItem
|
Workspace workspaceItem
|
||||||
Projects int
|
Projects int
|
||||||
|
|
||||||
|
// Admins sind die Workspace-Admin-Zugänge, auch zurückgezogene.
|
||||||
|
Admins []store.WorkspaceAdmin
|
||||||
|
|
||||||
|
// Jetzt ist der Bezugszeitpunkt für WorkspaceAdmin.Unused im Template,
|
||||||
|
// Ungenutzt die Zahl der aktiven Zugänge, die seit über 90 Tagen niemand
|
||||||
|
// benutzt hat — wie in keyListData.
|
||||||
|
Jetzt time.Time
|
||||||
|
Ungenutzt int
|
||||||
|
|
||||||
|
// NewToken wird nur unmittelbar nach dem Anlegen gesetzt und genau
|
||||||
|
// einmal angezeigt — wie NewKey in keyListData.
|
||||||
|
NewToken string
|
||||||
|
NewTokenName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWorkspaceEditForm zeigt das Formular zum Umbenennen und die Gefahrenzone.
|
// handleWorkspaceEditForm zeigt das Formular zum Umbenennen und die Gefahrenzone.
|
||||||
|
|
@ -101,7 +140,7 @@ func (s *Server) handleWorkspaceEditForm(w http.ResponseWriter, r *http.Request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderWorkspaceEdit(w, r, http.StatusOK, ws, "", "")
|
s.renderWorkspaceEdit(w, r, http.StatusOK, ws, workspaceEditData{}, "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWorkspaceEditSave benennt einen Workspace um.
|
// handleWorkspaceEditSave benennt einen Workspace um.
|
||||||
|
|
@ -113,7 +152,7 @@ func (s *Server) handleWorkspaceEditSave(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, "", "Der Name darf nicht leer sein.")
|
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "", "Der Name darf nicht leer sein.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,7 +181,7 @@ func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.FormValue("confirm") != ws.Name {
|
if r.FormValue("confirm") != ws.Name {
|
||||||
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, "",
|
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "",
|
||||||
"Zum Löschen muss der Workspace-Name \""+ws.Name+"\" zur Bestätigung eingegeben werden.")
|
"Zum Löschen muss der Workspace-Name \""+ws.Name+"\" zur Bestätigung eingegeben werden.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +203,7 @@ func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Printf("Workspace %q (%s) gelöscht von %s", ws.Name, ws.ID, sess.ActorName())
|
log.Printf("Workspace %q (%s) gelöscht von %s", ws.Name, ws.ID, sess.ActorName())
|
||||||
|
|
||||||
if sess.WorkspaceID == ws.ID {
|
if sess.WorkspaceID == ws.ID {
|
||||||
s.setSessionCookie(w, r, "platform")
|
s.setSessionCookie(w, r, s.plattformSubjekt(""))
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape("Workspace gelöscht"), http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape("Workspace gelöscht"), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
@ -190,16 +229,34 @@ func (s *Server) projectCount(r *http.Request, workspaceID string) int {
|
||||||
return len(projekte)
|
return len(projekte)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderWorkspaceEdit lädt die Projektzahl und rendert die Bearbeitungsseite.
|
// renderWorkspaceEdit lädt Projektzahl und Workspace-Admins und rendert die
|
||||||
|
// Bearbeitungsseite. In daten sind nur NewToken/NewTokenName von Belang, den
|
||||||
|
// Rest füllt diese Funktion.
|
||||||
func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, status int,
|
func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, status int,
|
||||||
ws workspaceItem, flash, fehler string) {
|
ws workspaceItem, daten workspaceEditData, flash, fehler string) {
|
||||||
|
|
||||||
|
admins, err := s.control.ListWorkspaceAdmins(r.Context(), ws.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Workspace-Admins konnten nicht geladen werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
daten.Workspace = ws
|
||||||
|
daten.Projects = s.projectCount(r, ws.ID)
|
||||||
|
daten.Admins = admins
|
||||||
|
daten.Jetzt = time.Now()
|
||||||
|
for _, a := range admins {
|
||||||
|
if a.Unused(daten.Jetzt) {
|
||||||
|
daten.Ungenutzt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.render(w, r, "workspace-edit.html", status, pageData{
|
s.render(w, r, "workspace-edit.html", status, pageData{
|
||||||
Title: ws.Name,
|
Title: ws.Name,
|
||||||
Bereich: "workspaces",
|
Bereich: "workspaces",
|
||||||
Flash: flash,
|
Flash: flash,
|
||||||
Error: fehler,
|
Error: fehler,
|
||||||
Data: workspaceEditData{Workspace: ws, Projects: s.projectCount(r, ws.ID)},
|
Data: daten,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,6 +295,6 @@ func (s *Server) handleWorkspaceSelect(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.setSessionCookie(w, r, "platform:"+id)
|
s.setSessionCookie(w, r, s.plattformSubjekt(id))
|
||||||
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -69,18 +70,34 @@ func (s *Server) requireSession(next http.Handler) http.Handler {
|
||||||
|
|
||||||
// sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung.
|
// sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung.
|
||||||
//
|
//
|
||||||
// Ein Subject hat eine von drei Formen:
|
// Ein Subject hat eine von vier Formen:
|
||||||
//
|
//
|
||||||
// - "platform" — Plattform-Admin, noch kein Workspace gewählt.
|
// - "platform/<fingerabdruck>" — Plattform-Admin, noch kein Workspace
|
||||||
// - "platform:<workspace-id>" — Plattform-Admin, mit gewähltem Workspace.
|
// gewählt. Der Fingerabdruck muss zum aktuellen ADMIN_TOKEN passen
|
||||||
|
// (siehe auth.PlatformSubject).
|
||||||
|
// - "platform/<fingerabdruck>:<workspace-id>" — Plattform-Admin, mit
|
||||||
|
// gewähltem Workspace.
|
||||||
|
// - "wsadmin:<workspace-id>:<zugangs-id>" — Workspace-Admin, fest an seinen
|
||||||
|
// Workspace gebunden (AUSBAUSTUFEN.md 4.8).
|
||||||
// - "key:<workspace-id>:<key-id>" — Projekt-Zugang, immer an genau einen
|
// - "key:<workspace-id>:<key-id>" — Projekt-Zugang, immer an genau einen
|
||||||
// Workspace gebunden (den seines Keys).
|
// Workspace gebunden (den seines Keys).
|
||||||
//
|
//
|
||||||
// Die Workspace-ID steht direkt im (signierten, also fälschungssicheren)
|
// Die Workspace-ID steht direkt im (signierten, also fälschungssicheren)
|
||||||
// Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank
|
// Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank
|
||||||
// der richtigen Workspace-Datei zuordnen.
|
// der richtigen Workspace-Datei zuordnen.
|
||||||
|
//
|
||||||
|
// Sitzungen aus der Zeit vor dem Fingerabdruck ("platform" ohne Zusatz) passen
|
||||||
|
// zu keiner Form mehr und enden. Der Plattform-Admin meldet sich einmal neu an.
|
||||||
func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) {
|
func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) {
|
||||||
if subject == auth.SubjectPlatformAdmin {
|
if fingerabdruck, workspaceID, ok := auth.ParsePlatformSubject(subject); ok {
|
||||||
|
// Mit einem anderen ADMIN_TOKEN entstanden: Das Token wurde seitdem
|
||||||
|
// ersetzt, die Sitzung gilt nicht mehr. Vergleich in konstanter Zeit,
|
||||||
|
// damit die Antwortzeit nichts über den richtigen Abdruck verrät.
|
||||||
|
if subtle.ConstantTimeCompare([]byte(fingerabdruck), []byte(s.adminFingerprint)) != 1 {
|
||||||
|
return Session{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if workspaceID == "" {
|
||||||
return Session{
|
return Session{
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
IsPlatformAdmin: true,
|
IsPlatformAdmin: true,
|
||||||
|
|
@ -88,20 +105,23 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
if rest, ok := strings.CutPrefix(subject, auth.SubjectPlatformAdmin+":"); ok {
|
ws, err := s.control.WorkspaceByID(r.Context(), workspaceID)
|
||||||
ws, err := s.control.WorkspaceByID(r.Context(), rest)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Session{}, false
|
return Session{}, false
|
||||||
}
|
}
|
||||||
return Session{
|
return Session{
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
IsPlatformAdmin: true,
|
IsPlatformAdmin: true,
|
||||||
WorkspaceID: rest,
|
WorkspaceID: workspaceID,
|
||||||
WorkspaceName: ws.Name,
|
WorkspaceName: ws.Name,
|
||||||
Role: auth.RoleAdmin,
|
Role: auth.RoleAdmin,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if rest, ok := strings.CutPrefix(subject, auth.SubjectWorkspaceAdminPrefix); ok {
|
||||||
|
return s.workspaceAdminSession(r, subject, rest)
|
||||||
|
}
|
||||||
|
|
||||||
rest, ok := auth.KeyIDFromSubject(subject)
|
rest, ok := auth.KeyIDFromSubject(subject)
|
||||||
if !ok {
|
if !ok {
|
||||||
return Session{}, false
|
return Session{}, false
|
||||||
|
|
@ -135,6 +155,45 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// workspaceAdminSession baut die Sitzung eines Workspace-Admins aus dem Rest
|
||||||
|
// seines Subjects ("<workspace-id>:<zugangs-id>").
|
||||||
|
//
|
||||||
|
// Wie bei den Keys wird der Zugang bei jedem Request frisch nachgesehen: Ein
|
||||||
|
// zurückgezogener Zugang beendet die Sitzung sofort, ebenso ein gelöschter
|
||||||
|
// Workspace (dessen Zugänge fallen per ON DELETE CASCADE mit weg).
|
||||||
|
//
|
||||||
|
// Der Vergleich der Workspace-IDs ist eine zweite Sicherung: Das Subject ist
|
||||||
|
// signiert und damit nicht fälschbar, aber die Bindung an den Workspace soll
|
||||||
|
// nicht allein davon abhängen, dass es beim Anmelden richtig gebildet wurde.
|
||||||
|
func (s *Server) workspaceAdminSession(r *http.Request, subject, rest string) (Session, bool) {
|
||||||
|
workspaceID, adminID, ok := strings.Cut(rest, ":")
|
||||||
|
if !ok {
|
||||||
|
return Session{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := s.control.WorkspaceAdminByID(r.Context(), adminID)
|
||||||
|
if err != nil || admin.Revoked() || admin.WorkspaceID != workspaceID {
|
||||||
|
return Session{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := s.control.WorkspaceByID(r.Context(), workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nebenbei die letzte Nutzung vermerken — eine Sitzung gilt 30 Tage, der
|
||||||
|
// Login allein sagt also wenig darüber, ob der Zugang noch gebraucht wird.
|
||||||
|
_ = s.control.TouchWorkspaceAdmin(r.Context(), admin.ID, admin.LastUsedAt)
|
||||||
|
|
||||||
|
return Session{
|
||||||
|
Subject: subject,
|
||||||
|
IsWorkspaceAdmin: true,
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
|
WorkspaceName: ws.Name,
|
||||||
|
WorkspaceAdmin: admin,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
// checkCSRF prüft bei schreibenden Requests das CSRF-Token.
|
// checkCSRF prüft bei schreibenden Requests das CSRF-Token.
|
||||||
//
|
//
|
||||||
// GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts
|
// GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts
|
||||||
|
|
@ -212,8 +271,9 @@ func (s *Server) requireProject(next http.Handler) http.Handler {
|
||||||
|
|
||||||
// requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch.
|
// requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch.
|
||||||
//
|
//
|
||||||
// Betrifft nur den Plattform-Admin: Eine Projekt-Sitzung hat ihren Workspace
|
// Betrifft nur den Plattform-Admin: Projekt- und Workspace-Admin-Sitzungen
|
||||||
// immer schon über ihren Key (siehe sessionFromSubject). Der Plattform-Admin
|
// haben ihren Workspace immer schon im Subject (siehe sessionFromSubject). Der
|
||||||
|
// Plattform-Admin
|
||||||
// dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er
|
// dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er
|
||||||
// verwalten will — dafür ist /admin/workspaces da.
|
// verwalten will — dafür ist /admin/workspaces da.
|
||||||
func (s *Server) requireWorkspace(next http.Handler) http.Handler {
|
func (s *Server) requireWorkspace(next http.Handler) http.Handler {
|
||||||
|
|
@ -250,6 +310,32 @@ func (s *Server) requireWrite(next http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireProjectManagement lässt nur Sitzungen durch, die Projekte verwalten
|
||||||
|
// dürfen: Plattform-Admin und Workspace-Admin.
|
||||||
|
func (s *Server) requireProjectManagement(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, ok := SessionFrom(r)
|
||||||
|
if !ok || !sess.CanManageProjects() {
|
||||||
|
http.Error(w, "Nur für Plattform- und Workspace-Admins verfügbar", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireImport lässt nur Sitzungen durch, die importieren dürfen (siehe
|
||||||
|
// Session.CanImport).
|
||||||
|
func (s *Server) requireImport(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, ok := SessionFrom(r)
|
||||||
|
if !ok || !sess.CanImport() {
|
||||||
|
http.Error(w, "Für den Import wird die Rolle \"admin\" benötigt.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen.
|
// requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen.
|
||||||
func (s *Server) requireKeyManagement(next http.Handler) http.Handler {
|
func (s *Server) requireKeyManagement(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -262,6 +348,19 @@ func (s *Server) requireKeyManagement(next http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireProjectDownload lässt nur Sitzungen durch, die ein Projekt als ZIP
|
||||||
|
// herunterladen dürfen (siehe Session.CanDownloadProject).
|
||||||
|
func (s *Server) requireProjectDownload(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, ok := SessionFrom(r)
|
||||||
|
if !ok || !sess.CanDownloadProject() {
|
||||||
|
http.Error(w, "Für den Download des ganzen Projekts wird die Rolle \"admin\" benötigt.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Allgemeine Schutzschichten ───────────────────────────────────────────────
|
// ── Allgemeine Schutzschichten ───────────────────────────────────────────────
|
||||||
|
|
||||||
// SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers.
|
// SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ type Server struct {
|
||||||
// nicht der Klartext — dann liegt das Geheimnis nicht dauerhaft im Speicher
|
// 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.
|
// herum und der Vergleich läuft über dieselbe Funktion wie bei den API-Keys.
|
||||||
adminTokenHash string
|
adminTokenHash string
|
||||||
|
|
||||||
|
// adminFingerprint steckt in jeder Sitzung des Plattform-Admins und wird
|
||||||
|
// bei jedem Request verglichen. Ändert sich das ADMIN_TOKEN, passt er
|
||||||
|
// nicht mehr, und alle Admin-Sitzungen enden (siehe auth.PlatformSubject).
|
||||||
|
adminFingerprint string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config sammelt die Angaben zum Bauen eines Servers.
|
// Config sammelt die Angaben zum Bauen eines Servers.
|
||||||
|
|
@ -63,6 +68,8 @@ func NewServer(cfg Config) (*Server, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
adminTokenHash := auth.HashKey(cfg.AdminToken)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
workspaces: cfg.Workspaces,
|
workspaces: cfg.Workspaces,
|
||||||
control: cfg.Control,
|
control: cfg.Control,
|
||||||
|
|
@ -72,7 +79,9 @@ func NewServer(cfg Config) (*Server, error) {
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
exporter: cfg.Exporter,
|
exporter: cfg.Exporter,
|
||||||
writeBudget: cfg.WriteBudget,
|
writeBudget: cfg.WriteBudget,
|
||||||
adminTokenHash: auth.HashKey(cfg.AdminToken),
|
adminTokenHash: adminTokenHash,
|
||||||
|
|
||||||
|
adminFingerprint: cfg.Signer.Fingerprint(adminTokenHash),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,23 +135,37 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
|
||||||
// /admin/workspaces, sonst wäre er mit "create"/"select"/"delete" mehrdeutig.
|
// /admin/workspaces, sonst wäre er mit "create"/"select"/"delete" mehrdeutig.
|
||||||
mux.Handle("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm)))
|
mux.Handle("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm)))
|
||||||
mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave)))
|
mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave)))
|
||||||
|
// Workspace-Admins eines Workspace anlegen und zurückziehen — nur der
|
||||||
|
// Plattform-Admin, siehe handlers_workspace_admins.go.
|
||||||
|
mux.Handle("POST /admin/workspaces/{id}/admins/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminCreate)))
|
||||||
|
mux.Handle("POST /admin/workspaces/{id}/admins/revoke", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminRevoke)))
|
||||||
|
|
||||||
// Plattform-Verwaltung. requireWorkspace stellt sicher, dass der
|
// Verwaltung eines Workspace. requireWorkspace stellt sicher, dass der
|
||||||
// Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte
|
// Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte
|
||||||
// verwaltet — siehe requireWorkspace in middleware.go.
|
// verwaltet — siehe requireWorkspace in middleware.go.
|
||||||
|
//
|
||||||
|
// Zwei Stufen: Projekte und das Protokoll aller Projekte darf auch der
|
||||||
|
// Workspace-Admin (AUSBAUSTUFEN.md 4.8), alles Übrige bleibt beim
|
||||||
|
// Plattform-Admin. Neue Routen gehören im Zweifel nach imWorkspace.
|
||||||
imWorkspace := func(h http.HandlerFunc) http.Handler {
|
imWorkspace := func(h http.HandlerFunc) http.Handler {
|
||||||
return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h)))
|
return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h)))
|
||||||
}
|
}
|
||||||
mux.Handle("GET /admin/projects", imWorkspace(s.handleProjectList))
|
projektVerwaltung := func(h http.HandlerFunc) http.Handler {
|
||||||
mux.Handle("POST /admin/projects/create", imWorkspace(s.handleProjectCreate))
|
return s.requireSession(s.requireWorkspace(s.requireProjectManagement(h)))
|
||||||
mux.Handle("POST /admin/projects/delete", imWorkspace(s.handleProjectDelete))
|
}
|
||||||
|
mux.Handle("GET /admin/projects", projektVerwaltung(s.handleProjectList))
|
||||||
|
mux.Handle("POST /admin/projects/create", projektVerwaltung(s.handleProjectCreate))
|
||||||
|
mux.Handle("POST /admin/projects/delete", projektVerwaltung(s.handleProjectDelete))
|
||||||
// Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst
|
// Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst
|
||||||
// wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen
|
// wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen
|
||||||
// Projekt-Bezeichner.
|
// Projekt-Bezeichner.
|
||||||
mux.Handle("GET /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditForm))
|
mux.Handle("GET /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditForm))
|
||||||
mux.Handle("POST /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditSave))
|
mux.Handle("POST /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditSave))
|
||||||
mux.Handle("GET /admin/audit", imWorkspace(s.handleGlobalAudit))
|
mux.Handle("GET /admin/audit", projektVerwaltung(s.handleGlobalAudit))
|
||||||
mux.Handle("POST /admin/export", imWorkspace(s.handleExport))
|
mux.Handle("POST /admin/export", imWorkspace(s.handleExport))
|
||||||
|
// Download als ZIP: unabhängig von EXPORT_DIR, für alle, die Projekte
|
||||||
|
// verwalten — also auch den Workspace-Admin (siehe handlers_download.go).
|
||||||
|
mux.Handle("GET /admin/export.zip", projektVerwaltung(s.handleWorkspaceDownload))
|
||||||
|
|
||||||
// Projektseiten.
|
// Projektseiten.
|
||||||
mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList))
|
mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList))
|
||||||
|
|
@ -155,6 +178,7 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
|
||||||
mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", mitSchreibrecht(s.handleEntryRestore))
|
mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", mitSchreibrecht(s.handleEntryRestore))
|
||||||
mux.Handle("GET /p/{project}/graph", imProjekt(s.handleEntryGraph))
|
mux.Handle("GET /p/{project}/graph", imProjekt(s.handleEntryGraph))
|
||||||
mux.Handle("GET /p/{project}/graph/data", imProjekt(s.handleEntryGraphData))
|
mux.Handle("GET /p/{project}/graph/data", imProjekt(s.handleEntryGraphData))
|
||||||
|
mux.Handle("GET /p/{project}/tags", imProjekt(s.handleTagList))
|
||||||
mux.Handle("GET /p/{project}/e/{slug}/edit", mitSchreibrecht(s.handleEntryEdit))
|
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}/save", mitSchreibrecht(s.handleEntrySave))
|
||||||
mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete))
|
mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete))
|
||||||
|
|
@ -168,12 +192,14 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
|
||||||
mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage))
|
mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage))
|
||||||
|
|
||||||
// Key-Verwaltung.
|
// Key-Verwaltung.
|
||||||
|
mux.Handle("GET /p/{project}/export.zip", s.requireSession(s.requireProject(s.requireProjectDownload(http.HandlerFunc(s.handleProjectDownload)))))
|
||||||
mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList)))))
|
mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList)))))
|
||||||
|
|
||||||
// Import: dieselbe Rolle wie die Key-Verwaltung, siehe handlers_import.go.
|
// Import: Rolle admin mit Schreibrecht, siehe Session.CanImport und
|
||||||
// Die größere Body-Grenze für POST setzt LimitBodies (middleware.go).
|
// handlers_import.go. Die größere Body-Grenze für POST setzt LimitBodies
|
||||||
mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImportForm)))))
|
// (middleware.go).
|
||||||
mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImport)))))
|
mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireImport(http.HandlerFunc(s.handleImportForm)))))
|
||||||
|
mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireImport(http.HandlerFunc(s.handleImport)))))
|
||||||
mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate)))))
|
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)))))
|
mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke)))))
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +228,8 @@ type pageData struct {
|
||||||
ExportEnabled bool
|
ExportEnabled bool
|
||||||
|
|
||||||
// Bereich markiert den aktiven Punkt in der Seitenleiste:
|
// Bereich markiert den aktiven Punkt in der Seitenleiste:
|
||||||
// "eintraege", "zugaenge", "protokoll", "projekte" oder "hilfe".
|
// etwa "eintraege", "graph", "schlagworte", "zugaenge", "protokoll",
|
||||||
|
// "projekte" oder "hilfe".
|
||||||
Bereich string
|
Bereich string
|
||||||
|
|
||||||
// Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit
|
// Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit
|
||||||
|
|
@ -217,14 +244,27 @@ type pageData struct {
|
||||||
|
|
||||||
// navData sind die Inhalte der Seitenleiste.
|
// navData sind die Inhalte der Seitenleiste.
|
||||||
type navData struct {
|
type navData struct {
|
||||||
// Projects ist nur beim Plattform-Admin gefüllt — ein Projekt-Key hat
|
// Projects ist nur bei Plattform- und Workspace-Admin gefüllt — ein
|
||||||
// ohnehin nur Zugriff auf sein eigenes Projekt.
|
// Projekt-Key hat ohnehin nur Zugriff auf sein eigenes Projekt.
|
||||||
Projects []store.Project
|
Projects []store.Project
|
||||||
|
|
||||||
// Tags sind die Schlagworte des aktuellen Projekts, häufigste zuerst.
|
// Tags sind die häufigsten Schlagworte des aktuellen Projekts (höchstens
|
||||||
|
// navTagLimit Stück), häufigste zuerst. Ist gerade ein Schlagwort gewählt,
|
||||||
|
// das nicht dazugehört, steht es zusätzlich am Ende — sonst wäre der
|
||||||
|
// aktive Filter in der Seitenleiste unsichtbar.
|
||||||
Tags []store.TagCount
|
Tags []store.TagCount
|
||||||
|
|
||||||
|
// TagsGesamt ist die Zahl aller Schlagworte des Projekts. Ist sie größer
|
||||||
|
// als len(Tags), zeigt die Seitenleiste einen Link auf die vollständige
|
||||||
|
// Liste (/p/{project}/tags).
|
||||||
|
TagsGesamt int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// navTagLimit begrenzt die Schlagworte in der Seitenleiste. Ohne Grenze wird
|
||||||
|
// die Leiste in großen Projekten so lang, dass die übrigen Punkte darin
|
||||||
|
// untergehen. Alle Schlagworte stehen auf einer eigenen Seite.
|
||||||
|
const navTagLimit = 10
|
||||||
|
|
||||||
// render führt ein Template aus und schickt das Ergebnis an den Browser.
|
// render führt ein Template aus und schickt das Ergebnis an den Browser.
|
||||||
//
|
//
|
||||||
// Gerendert wird zuerst in einen Puffer im Arbeitsspeicher. Erst wenn das
|
// Gerendert wird zuerst in einen Puffer im Arbeitsspeicher. Erst wenn das
|
||||||
|
|
@ -240,7 +280,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta
|
||||||
data.Project = project
|
data.Project = project
|
||||||
}
|
}
|
||||||
data.ExportEnabled = s.exporter != nil
|
data.ExportEnabled = s.exporter != nil
|
||||||
data.Nav = s.buildNav(r, data.Session, data.Project)
|
data.Nav = s.buildNav(r, data.Session, data.Project, data.AktivesTag)
|
||||||
|
|
||||||
var puffer bytes.Buffer
|
var puffer bytes.Buffer
|
||||||
if err := s.templates.ExecuteTemplate(&puffer, name, data); err != nil {
|
if err := s.templates.ExecuteTemplate(&puffer, name, data); err != nil {
|
||||||
|
|
@ -265,7 +305,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta
|
||||||
//
|
//
|
||||||
// Fehler brechen die Seite nicht ab: Eine unvollständige Navigation ist
|
// Fehler brechen die Seite nicht ab: Eine unvollständige Navigation ist
|
||||||
// ärgerlich, aber kein Grund, den eigentlichen Inhalt nicht anzuzeigen.
|
// ärgerlich, aber kein Grund, den eigentlichen Inhalt nicht anzuzeigen.
|
||||||
func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) navData {
|
func (s *Server) buildNav(r *http.Request, sess Session, project store.Project, aktivesTag string) navData {
|
||||||
var nav navData
|
var nav navData
|
||||||
|
|
||||||
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) gibt
|
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) gibt
|
||||||
|
|
@ -275,7 +315,7 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project)
|
||||||
return nav
|
return nav
|
||||||
}
|
}
|
||||||
|
|
||||||
if sess.IsPlatformAdmin {
|
if sess.CanManageProjects() {
|
||||||
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
|
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err)
|
log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err)
|
||||||
|
|
@ -289,13 +329,41 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err)
|
log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err)
|
||||||
} else {
|
} else {
|
||||||
nav.Tags = tags
|
nav.Tags = kuerzeTags(tags, navTagLimit, aktivesTag)
|
||||||
|
nav.TagsGesamt = len(tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nav
|
return nav
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kuerzeTags nimmt die ersten limit Schlagworte aus tags (die schon nach
|
||||||
|
// Häufigkeit sortiert sind). Gehört aktiv nicht dazu, wird es hinten
|
||||||
|
// angehängt, damit der gewählte Filter sichtbar bleibt.
|
||||||
|
func kuerzeTags(tags []store.TagCount, limit int, aktiv string) []store.TagCount {
|
||||||
|
if len(tags) <= limit {
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
// Eine Kopie statt tags[:limit]: append auf ein Teilstück würde sonst
|
||||||
|
// das darunterliegende Array von tags überschreiben.
|
||||||
|
kurz := make([]store.TagCount, limit, limit+1)
|
||||||
|
copy(kurz, tags[:limit])
|
||||||
|
if aktiv == "" {
|
||||||
|
return kurz
|
||||||
|
}
|
||||||
|
for _, t := range kurz {
|
||||||
|
if t.Tag == aktiv {
|
||||||
|
return kurz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range tags[limit:] {
|
||||||
|
if t.Tag == aktiv {
|
||||||
|
return append(kurz, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kurz
|
||||||
|
}
|
||||||
|
|
||||||
// workspaceAndSession liest Workspace und Sitzung der aktuellen Anfrage.
|
// workspaceAndSession liest Workspace und Sitzung der aktuellen Anfrage.
|
||||||
//
|
//
|
||||||
// Nur für Handler gedacht, die über requireWorkspace oder requireProject
|
// Nur für Handler gedacht, die über requireWorkspace oder requireProject
|
||||||
|
|
|
||||||
161
internal/web/tags_test.go
Normal file
161
internal/web/tags_test.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// holeSeite ruft eine Seite mit Lesezugang ab und gibt den HTML-Text zurück.
|
||||||
|
func holeSeite(t *testing.T, h http.Handler, cookie *http.Cookie, pfad string) string {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, pfad, nil)
|
||||||
|
r.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: Status = %d, erwartet 200", pfad, w.Code)
|
||||||
|
}
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// legeSchlagworteAn erzeugt anzahl Einträge mit je einem eigenen Schlagwort
|
||||||
|
// "tag-00", "tag-01", … — tag-00 kommt dabei am häufigsten vor, damit die
|
||||||
|
// Rangfolge nach Häufigkeit eindeutig ist.
|
||||||
|
func legeSchlagworteAn(t *testing.T, db *store.Store, projectID string, anzahl int) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < anzahl; i++ {
|
||||||
|
tags := []string{fmt.Sprintf("tag-%02d", i)}
|
||||||
|
if i > 0 {
|
||||||
|
tags = append(tags, "tag-00")
|
||||||
|
}
|
||||||
|
_, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: projectID, Slug: fmt.Sprintf("e-%02d", i),
|
||||||
|
Title: "E", Content: "x", Tags: tags, Source: store.SourceHuman})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNavZeigtNurHaeufigsteSchlagworte prüft die Kürzung der Seitenleiste:
|
||||||
|
// höchstens navTagLimit Schlagworte plus Link auf die vollständige Liste.
|
||||||
|
func TestNavZeigtNurHaeufigsteSchlagworte(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
legeSchlagworteAn(t, db, projekt.ID, navTagLimit+5)
|
||||||
|
|
||||||
|
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
|
||||||
|
cookie := anmelden(t, h, key)
|
||||||
|
|
||||||
|
// Geprüft wird auf den Text der Seitenleiste (nav-text) — die
|
||||||
|
// Eintragsliste selbst verlinkt die Schlagworte ihrer Einträge auch.
|
||||||
|
inLeiste := func(body string, i int) bool {
|
||||||
|
return strings.Contains(body, fmt.Sprintf(`<span class="nav-text">tag-%02d</span>`, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
body := holeSeite(t, h, cookie, "/p/testprojekt/")
|
||||||
|
if !inLeiste(body, 0) {
|
||||||
|
t.Error("das häufigste Schlagwort fehlt in der Seitenleiste")
|
||||||
|
}
|
||||||
|
if inLeiste(body, navTagLimit+4) {
|
||||||
|
t.Error("die Seitenleiste zeigt mehr als navTagLimit Schlagworte")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `href="/p/testprojekt/tags"`) {
|
||||||
|
t.Error("der Link auf alle Schlagworte fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ist ein seltenes Schlagwort gewählt, muss es trotzdem sichtbar sein.
|
||||||
|
body = holeSeite(t, h, cookie, fmt.Sprintf("/p/testprojekt/?tag=tag-%02d", navTagLimit+4))
|
||||||
|
if !inLeiste(body, navTagLimit+4) {
|
||||||
|
t.Error("das gewählte, seltene Schlagwort fehlt in der Seitenleiste")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNavOhneLinkBeiWenigenSchlagworten: Passen alle Schlagworte in die
|
||||||
|
// Seitenleiste, braucht es keinen Link auf die vollständige Liste.
|
||||||
|
func TestNavOhneLinkBeiWenigenSchlagworten(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
legeSchlagworteAn(t, db, projekt.ID, 3)
|
||||||
|
|
||||||
|
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
|
||||||
|
cookie := anmelden(t, h, key)
|
||||||
|
|
||||||
|
body := holeSeite(t, h, cookie, "/p/testprojekt/")
|
||||||
|
if strings.Contains(body, `href="/p/testprojekt/tags"`) {
|
||||||
|
t.Error("Link auf alle Schlagworte, obwohl schon alle in der Seitenleiste stehen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTagListSeite prüft die Seite mit allen Schlagworten in beiden
|
||||||
|
// Sortierungen.
|
||||||
|
func TestTagListSeite(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
legeSchlagworteAn(t, db, projekt.ID, navTagLimit+5)
|
||||||
|
|
||||||
|
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
|
||||||
|
cookie := anmelden(t, h, key)
|
||||||
|
|
||||||
|
body := holeSeite(t, h, cookie, "/p/testprojekt/tags")
|
||||||
|
for i := 0; i < navTagLimit+5; i++ {
|
||||||
|
if !strings.Contains(body, fmt.Sprintf(`data-tag="tag-%02d"`, i)) {
|
||||||
|
t.Errorf("tag-%02d fehlt auf der Übersichtsseite", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "data-tag-filter") {
|
||||||
|
t.Error("das Filterfeld fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
body = holeSeite(t, h, cookie, "/p/testprojekt/tags?sort=name")
|
||||||
|
if !strings.Contains(body, "<h2>T</h2>") {
|
||||||
|
t.Error("die alphabetische Ansicht zeigt keine Buchstabengruppe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKuerzeTags(t *testing.T) {
|
||||||
|
tags := []store.TagCount{{Tag: "a", Count: 5}, {Tag: "b", Count: 4}, {Tag: "c", Count: 3}, {Tag: "d", Count: 1}}
|
||||||
|
|
||||||
|
if got := kuerzeTags(tags, 10, ""); len(got) != 4 {
|
||||||
|
t.Errorf("unter dem Limit: %d Schlagworte, erwartet alle 4", len(got))
|
||||||
|
}
|
||||||
|
if got := kuerzeTags(tags, 2, ""); len(got) != 2 || got[1].Tag != "b" {
|
||||||
|
t.Errorf("gekürzt: %v, erwartet a, b", got)
|
||||||
|
}
|
||||||
|
if got := kuerzeTags(tags, 2, "d"); len(got) != 3 || got[2].Tag != "d" {
|
||||||
|
t.Errorf("mit seltenem aktivem Schlagwort: %v, erwartet a, b, d", got)
|
||||||
|
}
|
||||||
|
if got := kuerzeTags(tags, 2, "a"); len(got) != 2 {
|
||||||
|
t.Errorf("aktives Schlagwort schon enthalten: %v, erwartet a, b", got)
|
||||||
|
}
|
||||||
|
// Die Eingabe darf nicht verändert werden.
|
||||||
|
if tags[2].Tag != "c" {
|
||||||
|
t.Error("kuerzeTags hat die Eingabeliste überschrieben")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGruppiereTags(t *testing.T) {
|
||||||
|
tags := []store.TagCount{{Tag: "zebra"}, {Tag: "übersicht"}, {Tag: "api"}, {Tag: "2fa"}, {Tag: "umzug"}}
|
||||||
|
gruppen := gruppiereTags(tags)
|
||||||
|
|
||||||
|
var reihenfolge []string
|
||||||
|
for _, g := range gruppen {
|
||||||
|
var namen []string
|
||||||
|
for _, tc := range g.Tags {
|
||||||
|
namen = append(namen, tc.Tag)
|
||||||
|
}
|
||||||
|
reihenfolge = append(reihenfolge, g.Buchstabe+":"+strings.Join(namen, ","))
|
||||||
|
}
|
||||||
|
got := strings.Join(reihenfolge, " ")
|
||||||
|
want := "#:2fa A:api U:übersicht,umzug Z:zebra"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Gruppen = %q, erwartet %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -124,13 +124,21 @@ func erzeugeKey(t *testing.T, srv *Server, db *store.Store, projectID, name stri
|
||||||
return klartext
|
return klartext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// plattformSubjekt gibt das Session-Subject des Plattform-Admins zurück, so wie
|
||||||
|
// der Testserver es nach der Anmeldung mit testAdminToken vergibt — gebraucht,
|
||||||
|
// um passende CSRF-Tokens zu berechnen.
|
||||||
|
func plattformSubjekt(workspaceID string) string {
|
||||||
|
signer := auth.NewSigner([]byte(testSecret))
|
||||||
|
return auth.PlatformSubject(signer.Fingerprint(auth.HashKey(testAdminToken)), workspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
// waehleWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und
|
// waehleWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und
|
||||||
// gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList
|
// gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList
|
||||||
// in der Oberfläche anbietet (siehe handlers_workspaces.go).
|
// in der Oberfläche anbietet (siehe handlers_workspaces.go).
|
||||||
func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie {
|
func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin)
|
token := auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(""))
|
||||||
formular := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}}
|
formular := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}}
|
||||||
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode()))
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode()))
|
||||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
@ -444,7 +452,7 @@ func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) {
|
||||||
|
|
||||||
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||||||
token := auth.NewSigner([]byte(testSecret)).
|
token := auth.NewSigner([]byte(testSecret)).
|
||||||
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID)
|
CSRFToken(plattformSubjekt(projekt.WorkspaceID))
|
||||||
|
|
||||||
formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}}
|
formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}}
|
||||||
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(formular.Encode()))
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(formular.Encode()))
|
||||||
|
|
@ -528,7 +536,7 @@ func TestWorkspaceRename(t *testing.T) {
|
||||||
|
|
||||||
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||||||
token := auth.NewSigner([]byte(testSecret)).
|
token := auth.NewSigner([]byte(testSecret)).
|
||||||
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID)
|
CSRFToken(plattformSubjekt(projekt.WorkspaceID))
|
||||||
|
|
||||||
formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}}
|
formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}}
|
||||||
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+projekt.WorkspaceID+"/edit",
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+projekt.WorkspaceID+"/edit",
|
||||||
|
|
@ -560,7 +568,7 @@ func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) {
|
||||||
|
|
||||||
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||||||
token := auth.NewSigner([]byte(testSecret)).
|
token := auth.NewSigner([]byte(testSecret)).
|
||||||
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID)
|
CSRFToken(plattformSubjekt(projekt.WorkspaceID))
|
||||||
|
|
||||||
formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}}
|
formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}}
|
||||||
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode()))
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode()))
|
||||||
|
|
@ -592,7 +600,7 @@ func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) {
|
||||||
|
|
||||||
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||||||
token := auth.NewSigner([]byte(testSecret)).
|
token := auth.NewSigner([]byte(testSecret)).
|
||||||
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID)
|
CSRFToken(plattformSubjekt(projekt.WorkspaceID))
|
||||||
|
|
||||||
formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}}
|
formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}}
|
||||||
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode()))
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode()))
|
||||||
|
|
@ -670,6 +678,87 @@ func TestPlatformAdminCanAccessAnyProject(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusMitCookie ruft eine Seite mit dem gegebenen Cookie ab und gibt den
|
||||||
|
// Status zurück.
|
||||||
|
func statusMitCookie(h http.Handler, pfad string, cookie *http.Cookie) int {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, pfad, nil)
|
||||||
|
r.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNeuesAdminTokenBeendetAdminSitzungen stellt den Ernstfall nach: Das
|
||||||
|
// ADMIN_TOKEN ist geleakt und wird ersetzt, der Server neu gestartet. Wer sich
|
||||||
|
// mit dem alten Token angemeldet hat, muss sofort draußen sein — Teams mit
|
||||||
|
// ihren Keys dürfen davon nichts merken.
|
||||||
|
func TestNeuesAdminTokenBeendetAdminSitzungen(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
|
||||||
|
ohneWorkspace := anmelden(t, h, testAdminToken)
|
||||||
|
mitWorkspace := waehleWorkspace(t, h, ohneWorkspace, projekt.WorkspaceID)
|
||||||
|
team := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "team", auth.RoleAdmin))
|
||||||
|
|
||||||
|
if code := statusMitCookie(h, "/p/testprojekt/", mitWorkspace); code != http.StatusOK {
|
||||||
|
t.Fatalf("vor dem Wechsel: Status %d, erwartet 200", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neustart mit neuem ADMIN_TOKEN, aber demselben SESSION_SECRET und
|
||||||
|
// denselben Daten.
|
||||||
|
const neuesToken = "ein-ganz-neues-admin-token"
|
||||||
|
neu, err := NewServer(Config{
|
||||||
|
Workspaces: srv.workspaces,
|
||||||
|
Control: srv.control,
|
||||||
|
Signer: srv.signer,
|
||||||
|
Cookies: srv.cookies,
|
||||||
|
Limiter: auth.NewRateLimiter(),
|
||||||
|
Templates: templatesForTest(t),
|
||||||
|
AdminToken: neuesToken,
|
||||||
|
WriteBudget: srv.writeBudget,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hNeu := testHandler(t, neu)
|
||||||
|
|
||||||
|
for name, cookie := range map[string]*http.Cookie{
|
||||||
|
"ohne Workspace": ohneWorkspace,
|
||||||
|
"mit Workspace": mitWorkspace,
|
||||||
|
} {
|
||||||
|
code := statusMitCookie(hNeu, "/admin/workspaces", cookie)
|
||||||
|
if code != http.StatusSeeOther {
|
||||||
|
t.Errorf("alte Admin-Sitzung %s: Status %d, erwartet Weiterleitung zur Anmeldung", name, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := statusMitCookie(hNeu, "/p/testprojekt/", team); code != http.StatusOK {
|
||||||
|
t.Errorf("Sitzung eines Teams: Status %d, erwartet 200 — ein neues ADMIN_TOKEN darf Teams nicht abmelden", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
frisch := anmelden(t, hNeu, neuesToken)
|
||||||
|
if code := statusMitCookie(hNeu, "/admin/workspaces", frisch); code != http.StatusOK {
|
||||||
|
t.Errorf("Anmeldung mit dem neuen Token: Status %d, erwartet 200", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAlteAdminSitzungOhneFingerabdruckGiltNicht: Cookies aus der Zeit vor dem
|
||||||
|
// Fingerabdruck ("platform", "platform:<id>") sind korrekt signiert, dürfen
|
||||||
|
// aber nicht mehr gelten — sonst bliebe genau die Lücke offen, die der
|
||||||
|
// Fingerabdruck schließt.
|
||||||
|
func TestAlteAdminSitzungOhneFingerabdruckGiltNicht(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
signer := auth.NewSigner([]byte(testSecret))
|
||||||
|
|
||||||
|
for _, subject := range []string{"platform", "platform:" + projekt.WorkspaceID} {
|
||||||
|
cookie := &http.Cookie{Name: auth.SessionCookieName, Value: signer.SignSession(subject)}
|
||||||
|
if code := statusMitCookie(h, "/admin/workspaces", cookie); code != http.StatusSeeOther {
|
||||||
|
t.Errorf("%q: Status %d, erwartet Weiterleitung zur Anmeldung", subject, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rollen ───────────────────────────────────────────────────────────────────
|
// ── Rollen ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann.
|
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann.
|
||||||
|
|
@ -1800,7 +1889,7 @@ func adminPost(t *testing.T, h http.Handler, workspaceID, pfad string, formular
|
||||||
|
|
||||||
cookie := anmeldenAlsPlattformAdmin(t, h, workspaceID)
|
cookie := anmeldenAlsPlattformAdmin(t, h, workspaceID)
|
||||||
formular.Set(auth.CSRFFieldName,
|
formular.Set(auth.CSRFFieldName,
|
||||||
auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin+":"+workspaceID))
|
auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(workspaceID)))
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode()))
|
r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode()))
|
||||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
@ -2079,7 +2168,7 @@ func TestExportKnopfImZweitenWorkspace(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie := anmeldenAlsPlattformAdmin(t, h, wsB.ID)
|
cookie := anmeldenAlsPlattformAdmin(t, h, wsB.ID)
|
||||||
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin + ":" + wsB.ID)
|
token := auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(wsB.ID))
|
||||||
formular := url.Values{auth.CSRFFieldName: {token}}
|
formular := url.Values{auth.CSRFFieldName: {token}}
|
||||||
r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(formular.Encode()))
|
r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(formular.Encode()))
|
||||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
|
||||||
478
internal/web/workspace_admin_test.go
Normal file
478
internal/web/workspace_admin_test.go
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tests für den Workspace-Admin (AUSBAUSTUFEN.md 4.8).
|
||||||
|
//
|
||||||
|
// Der Schwerpunkt liegt auf dem, was er NICHT darf: Die Rolle ist nur dann
|
||||||
|
// ungefährlich, wenn sie an keiner Stelle über ihren Workspace, über die
|
||||||
|
// Weboberfläche oder über die Projekt- und Key-Verwaltung hinausreicht.
|
||||||
|
|
||||||
|
// workspaceAdmin bündelt, was ein Test über einen angemeldeten
|
||||||
|
// Workspace-Admin wissen muss.
|
||||||
|
type workspaceAdmin struct {
|
||||||
|
token string
|
||||||
|
cookie *http.Cookie
|
||||||
|
csrf string
|
||||||
|
}
|
||||||
|
|
||||||
|
// legeWorkspaceAdminAn erzeugt einen Workspace-Admin direkt in der
|
||||||
|
// Kontroll-Datenbank und gibt das Token im Klartext zurück — wie es der
|
||||||
|
// Plattform-Admin auf der Bearbeitungsseite des Workspace bekäme.
|
||||||
|
func legeWorkspaceAdminAn(t *testing.T, srv *Server, workspaceID, name string) (string, store.WorkspaceAdmin) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token, err := auth.GenerateWorkspaceAdminToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin, err := srv.control.CreateWorkspaceAdmin(context.Background(), workspaceID, name, auth.HashKey(token))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return token, admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// anmeldenAlsWorkspaceAdmin legt einen Workspace-Admin an, meldet ihn an und
|
||||||
|
// berechnet sein CSRF-Token.
|
||||||
|
func anmeldenAlsWorkspaceAdmin(t *testing.T, srv *Server, h http.Handler, workspaceID string) workspaceAdmin {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token, admin := legeWorkspaceAdminAn(t, srv, workspaceID, "Erika")
|
||||||
|
subject := auth.SubjectWorkspaceAdminPrefix + workspaceID + ":" + admin.ID
|
||||||
|
|
||||||
|
return workspaceAdmin{
|
||||||
|
token: token,
|
||||||
|
cookie: anmelden(t, h, token),
|
||||||
|
csrf: auth.NewSigner([]byte(testSecret)).CSRFToken(subject),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get ruft eine Seite mit der Sitzung des Workspace-Admins auf.
|
||||||
|
func (a workspaceAdmin) get(h http.Handler, pfad string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, pfad, nil)
|
||||||
|
r.AddCookie(a.cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// post schickt ein Formular mit gültigem CSRF-Token — abgewiesen werden darf
|
||||||
|
// es also nur an der Rechteprüfung, nicht am Token.
|
||||||
|
func (a workspaceAdmin) post(h http.Handler, pfad string, formular url.Values) *httptest.ResponseRecorder {
|
||||||
|
if formular == nil {
|
||||||
|
formular = url.Values{}
|
||||||
|
}
|
||||||
|
formular.Set(auth.CSRFFieldName, a.csrf)
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
r.AddCookie(a.cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Was er darf ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWorkspaceAdminLandsOnProjects(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||||||
|
|
||||||
|
w := admin.get(h, "/")
|
||||||
|
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/admin/projects" {
|
||||||
|
t.Fatalf("Startseite: Status %d, Ziel %q — erwartet Weiterleitung auf /admin/projects",
|
||||||
|
w.Code, w.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
w = admin.get(h, "/admin/projects")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Projektliste: Status %d, erwartet 200", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), projekt.Name) {
|
||||||
|
t.Error("die Projektliste zeigt das Projekt des Workspace nicht")
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), `action="/admin/export"`) {
|
||||||
|
t.Error("die Projektliste bietet dem Workspace-Admin den Export an")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceAdminManagesProjects(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Anlegen
|
||||||
|
w := admin.post(h, "/admin/projects/create", url.Values{"name": {"Neues Team"}, "slug": {"neu"}})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Anlegen: Status %d, erwartet 303 — %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
neu, err := db.ProjectBySlug(ctx, projekt.WorkspaceID, "neu")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("das angelegte Projekt fehlt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Umbenennen
|
||||||
|
w = admin.post(h, "/admin/projects/neu/edit", url.Values{"name": {"Umbenannt"}, "description": {""}})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Bearbeiten: Status %d, erwartet 303 — %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if p, _ := db.ProjectByID(ctx, neu.ID); p.Name != "Umbenannt" {
|
||||||
|
t.Errorf("Name = %q, erwartet \"Umbenannt\"", p.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Löschen
|
||||||
|
w = admin.post(h, "/admin/projects/delete", url.Values{"id": {neu.ID}, "confirm": {"neu"}})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Löschen: Status %d, erwartet 303", w.Code)
|
||||||
|
}
|
||||||
|
if _, err := db.ProjectByID(ctx, neu.ID); err == nil {
|
||||||
|
t.Error("das Projekt ist nach dem Löschen noch da")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Im Protokoll steht der Workspace-Admin mit Namen, nicht "platform-admin".
|
||||||
|
ereignisse, err := db.ListAudit(ctx, store.AuditFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gefunden := false
|
||||||
|
for _, e := range ereignisse {
|
||||||
|
if e.Action == store.ActionProjectCreated {
|
||||||
|
gefunden = true
|
||||||
|
if e.Actor != "workspace-admin:Erika" {
|
||||||
|
t.Errorf("Actor = %q, erwartet \"workspace-admin:Erika\"", e.Actor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
t.Error("das Anlegen des Projekts fehlt im Protokoll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceAdminManagesKeysAndReads(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||||||
|
|
||||||
|
for _, pfad := range []string{"/p/testprojekt/", "/p/testprojekt/keys", "/p/testprojekt/audit", "/admin/audit"} {
|
||||||
|
if w := admin.get(h, pfad); w.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET %s: Status %d, erwartet 200", pfad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w := admin.post(h, "/p/testprojekt/keys/create", url.Values{"name": {"team-lead"}, "role": {"admin"}})
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), auth.KeyPrefix) {
|
||||||
|
t.Fatalf("Key anlegen: Status %d, oder kein Key in der Antwort", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := db.ListAPIKeys(context.Background(), projekt.ID)
|
||||||
|
if err != nil || len(keys) != 1 {
|
||||||
|
t.Fatalf("erwartet genau einen Key, gefunden %d (%v)", len(keys), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = admin.post(h, "/p/testprojekt/keys/revoke", url.Values{"id": {keys[0].ID}})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Key zurückziehen: Status %d, erwartet 200", w.Code)
|
||||||
|
}
|
||||||
|
if k, _ := db.APIKeyByID(context.Background(), keys[0].ID); !k.Revoked() {
|
||||||
|
t.Error("der Key ist nach dem Zurückziehen noch aktiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Was er nicht darf ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestWorkspaceAdminCannotReachPlatformRoutes geht jede Route durch, die dem
|
||||||
|
// Plattform-Admin vorbehalten bleibt.
|
||||||
|
func TestWorkspaceAdminCannotReachPlatformRoutes(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||||||
|
id := projekt.WorkspaceID
|
||||||
|
|
||||||
|
gets := []string{
|
||||||
|
"/admin/workspaces",
|
||||||
|
"/admin/workspaces/" + id + "/edit",
|
||||||
|
}
|
||||||
|
for _, pfad := range gets {
|
||||||
|
if w := admin.get(h, pfad); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("GET %s: Status %d, erwartet 403", pfad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
posts := map[string]url.Values{
|
||||||
|
"/admin/workspaces/create": {"name": {"Eigener"}},
|
||||||
|
"/admin/workspaces/select": {"id": {id}},
|
||||||
|
"/admin/workspaces/delete": {"id": {id}, "confirm": {"Test"}},
|
||||||
|
"/admin/workspaces/" + id + "/edit": {"name": {"Umbenannt"}},
|
||||||
|
"/admin/workspaces/" + id + "/admins/create": {"name": {"Zweiter"}},
|
||||||
|
"/admin/workspaces/" + id + "/admins/revoke": {"id": {"egal"}},
|
||||||
|
"/admin/export": nil,
|
||||||
|
}
|
||||||
|
for pfad, formular := range posts {
|
||||||
|
if w := admin.post(h, pfad, formular); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("POST %s: Status %d, erwartet 403", pfad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein zweiter Workspace-Admin durch die Hintertür.
|
||||||
|
admins, err := srv.control.ListWorkspaceAdmins(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(admins) != 1 {
|
||||||
|
t.Errorf("%d Workspace-Admins, erwartet 1", len(admins))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdminCannotWriteEntries: Lesen ja, Schreiben nein — auch nicht
|
||||||
|
// über Import, Papierkorb oder Wiederherstellen.
|
||||||
|
func TestWorkspaceAdminCannotWriteEntries(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||||||
|
|
||||||
|
for _, pfad := range []string{"/p/testprojekt/new", "/p/testprojekt/import", "/p/testprojekt/trash"} {
|
||||||
|
if w := admin.get(h, pfad); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("GET %s: Status %d, erwartet 403", pfad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w := admin.post(h, "/p/testprojekt/save", url.Values{
|
||||||
|
"slug": {"neu"}, "title": {"Neu"}, "content": {"Text"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("Eintrag speichern: Status %d, erwartet 403", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Seitenleiste bietet weder Import noch Papierkorb an.
|
||||||
|
body := admin.get(h, "/p/testprojekt/").Body.String()
|
||||||
|
for _, link := range []string{`href="/p/testprojekt/import"`, `href="/p/testprojekt/trash"`, `href="/p/testprojekt/new"`} {
|
||||||
|
if strings.Contains(body, link) {
|
||||||
|
t.Errorf("die Seite verlinkt %s, obwohl der Workspace-Admin dort abgewiesen wird", link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdminCannotChangeWriteBudget: Das Feld wird nicht angeboten,
|
||||||
|
// und ein von Hand nachgebautes Formular ändert trotzdem nichts.
|
||||||
|
func TestWorkspaceAdminCannotChangeWriteBudget(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||||||
|
|
||||||
|
formularSeite := admin.get(h, "/admin/projects/testprojekt/edit")
|
||||||
|
if formularSeite.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Bearbeitungsseite: Status %d, erwartet 200", formularSeite.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(formularSeite.Body.String(), `name="write_limit_hourly"`) {
|
||||||
|
t.Error("die Bearbeitungsseite bietet dem Workspace-Admin das Schreibbudget an")
|
||||||
|
}
|
||||||
|
|
||||||
|
w := admin.post(h, "/admin/projects/testprojekt/edit", url.Values{
|
||||||
|
"name": {"Neuer Name"}, "description": {""}, "write_limit_hourly": {"99999"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Speichern: Status %d, erwartet 303 — %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
gelesen, err := db.ProjectByID(context.Background(), projekt.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gelesen.WriteLimitHourly != projekt.WriteLimitHourly {
|
||||||
|
t.Errorf("WriteLimitHourly = %d, erwartet unverändert %d", gelesen.WriteLimitHourly, projekt.WriteLimitHourly)
|
||||||
|
}
|
||||||
|
if gelesen.Name != "Neuer Name" {
|
||||||
|
t.Errorf("Name = %q — die übrigen Felder sollen trotzdem gespeichert werden", gelesen.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdminStaysInOwnWorkspace: Projekte eines anderen Workspace
|
||||||
|
// sind für ihn nicht vorhanden — weder unter /p/ noch in der Verwaltung.
|
||||||
|
func TestWorkspaceAdminStaysInOwnWorkspace(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
wsB, err := srv.control.CreateWorkspace(ctx, "Andere Abteilung")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
workspaceB, err := srv.workspaces.Get(wsB.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
projektB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||||||
|
|
||||||
|
for _, pfad := range []string{"/p/fremd/", "/p/fremd/keys", "/admin/projects/fremd/edit"} {
|
||||||
|
if w := admin.get(h, pfad); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET %s: Status %d, erwartet 404", pfad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := admin.post(h, "/admin/projects/delete", url.Values{"id": {projektB.ID}, "confirm": {"fremd"}}); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("fremdes Projekt löschen: Status %d, erwartet 404", w.Code)
|
||||||
|
}
|
||||||
|
if _, err := workspaceB.Store.ProjectByID(ctx, projektB.ID); err != nil {
|
||||||
|
t.Error("das Projekt aus Workspace B wurde gelöscht")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(admin.get(h, "/admin/projects").Body.String(), "Fremdes Projekt") {
|
||||||
|
t.Error("die Projektliste zeigt ein Projekt aus einem anderen Workspace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdminTokenRejectedByAPI: Das Token taugt nur für die
|
||||||
|
// Weboberfläche, nicht für MCP.
|
||||||
|
func TestWorkspaceAdminTokenRejectedByAPI(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
token, _ := legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Erika")
|
||||||
|
|
||||||
|
var erreicht bool
|
||||||
|
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true }))
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if erreicht {
|
||||||
|
t.Fatal("ein Workspace-Admin-Token kam an der MCP-Schnittstelle durch")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Status = %d, erwartet 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anlegen und Zurückziehen durch den Plattform-Admin ──────────────────────
|
||||||
|
|
||||||
|
func TestPlatformAdminIssuesAndRevokesWorkspaceAdmin(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
pfad := "/admin/workspaces/" + projekt.WorkspaceID + "/admins/"
|
||||||
|
|
||||||
|
w := adminPost(t, h, projekt.WorkspaceID, pfad+"create", url.Values{"name": {"Erika"}})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Anlegen: Status %d, erwartet 200 — %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Token steht genau einmal in der Antwort — herausfischen und damit anmelden.
|
||||||
|
body := w.Body.String()
|
||||||
|
start := strings.Index(body, auth.WorkspaceAdminPrefix)
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatal("die Antwort enthält kein Token")
|
||||||
|
}
|
||||||
|
token := body[start : start+len(auth.WorkspaceAdminPrefix)+64]
|
||||||
|
if !auth.LooksLikeWorkspaceAdminToken(token) {
|
||||||
|
t.Fatalf("%q sieht nicht wie ein Workspace-Admin-Token aus", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := anmelden(t, h, token)
|
||||||
|
|
||||||
|
admins, err := srv.control.ListWorkspaceAdmins(context.Background(), projekt.WorkspaceID)
|
||||||
|
if err != nil || len(admins) != 1 {
|
||||||
|
t.Fatalf("erwartet genau einen Workspace-Admin, gefunden %d (%v)", len(admins), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = adminPost(t, h, projekt.WorkspaceID, pfad+"revoke", url.Values{"id": {admins[0].ID}})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Zurückziehen: Status %d, erwartet 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die laufende Sitzung endet sofort.
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
|
||||||
|
r.AddCookie(cookie)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/login" {
|
||||||
|
t.Errorf("nach dem Zurückziehen: Status %d, Ziel %q — erwartet Weiterleitung zur Anmeldung",
|
||||||
|
w.Code, w.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Und eine neue Anmeldung klappt nicht mehr.
|
||||||
|
formular := url.Values{"key": {token}}
|
||||||
|
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("Anmeldung mit zurückgezogenem Token: Status %d, erwartet 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceListCountsActiveAdmins: Die Übersicht zählt nur aktive
|
||||||
|
// Workspace-Admins, und ein frisch angelegter gilt nicht als „ungenutzt“.
|
||||||
|
func TestWorkspaceListCountsActiveAdmins(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Erika")
|
||||||
|
legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Max")
|
||||||
|
_, weg := legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Ehemalig")
|
||||||
|
if err := srv.control.RevokeWorkspaceAdmin(ctx, projekt.WorkspaceID, weg.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/admin/workspaces", nil)
|
||||||
|
r.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Status = %d, erwartet 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !regexp.MustCompile(`<td class="zahl">\s*2\s*</td>`).MatchString(body) {
|
||||||
|
t.Error("die Übersicht zeigt nicht 2 aktive Workspace-Admins")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "ungenutzt</a>") {
|
||||||
|
t.Error("frisch angelegte Workspace-Admins sind als ungenutzt markiert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkspaceAdminRevokeRejectsForeignID:Über die Bearbeitungsseite von
|
||||||
|
// Workspace A lässt sich kein Zugang aus Workspace B zurückziehen.
|
||||||
|
func TestWorkspaceAdminRevokeRejectsForeignID(t *testing.T) {
|
||||||
|
srv, _, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
|
||||||
|
wsB, err := srv.control.CreateWorkspace(context.Background(), "Andere Abteilung")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, fremd := legeWorkspaceAdminAn(t, srv, wsB.ID, "Fremd")
|
||||||
|
|
||||||
|
w := adminPost(t, h, projekt.WorkspaceID,
|
||||||
|
"/admin/workspaces/"+projekt.WorkspaceID+"/admins/revoke", url.Values{"id": {fremd.ID}})
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Status = %d, erwartet 404", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
gelesen, err := srv.control.WorkspaceAdminByID(context.Background(), fremd.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gelesen.Revoked() {
|
||||||
|
t.Error("der Zugang aus Workspace B wurde zurückgezogen")
|
||||||
|
}
|
||||||
|
}
|
||||||
425
last_test.go
Normal file
425
last_test.go
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
//go:build last
|
||||||
|
|
||||||
|
// Lasttest: viele Agenten greifen gleichzeitig über echtes HTTP und MCP zu.
|
||||||
|
//
|
||||||
|
// Er läuft NICHT bei jedem "go test ./...", weil er Zeit braucht und die
|
||||||
|
// Maschine auslastet. Das Build-Tag oben schaltet ihn erst ein, wenn man
|
||||||
|
// ausdrücklich danach fragt:
|
||||||
|
//
|
||||||
|
// go test -tags last -run TestLast -v .
|
||||||
|
//
|
||||||
|
// Mit eigenen Werten (alle Schalter siehe die flag.-Zeilen unten):
|
||||||
|
//
|
||||||
|
// go test -tags last -run TestLast -v . -last.agenten=64 -last.dauer=30s
|
||||||
|
//
|
||||||
|
// Drei Aufstellungen mit derselben Zahl Agenten, damit die Zahlen vergleichbar
|
||||||
|
// sind:
|
||||||
|
//
|
||||||
|
// - ein Projekt: alle Agenten im selben Projekt
|
||||||
|
// - ein Workspace, mehrere Projekte: teilen sich eine Datei und damit die
|
||||||
|
// eine Schreibverbindung (siehe openPool in internal/store/store.go)
|
||||||
|
// - mehrere Workspaces: je eine eigene Datei, schreiben echt parallel
|
||||||
|
//
|
||||||
|
// Am Ende prüft jede Aufstellung, ob nichts verloren ging und ob der
|
||||||
|
// Suchindex zum Stand der Datenbank passt.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/search"
|
||||||
|
"munin/internal/store"
|
||||||
|
"munin/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
lastAgenten = flag.Int("last.agenten", 32, "Zahl der gleichzeitigen Agenten je Aufstellung")
|
||||||
|
lastDauer = flag.Duration("last.dauer", 10*time.Second, "Laufzeit je Aufstellung")
|
||||||
|
lastProjekte = flag.Int("last.projekte", 4, "Projekte bzw. Workspaces in den Aufstellungen mit mehreren")
|
||||||
|
lastEintraege = flag.Int("last.eintraege", 500, "Einträge, die jedes Projekt vorab bekommt")
|
||||||
|
lastSlugs = flag.Int("last.slugs", 20, "Bezeichner, auf die die Agenten je Projekt schreiben — klein heißt viel Gedränge auf denselben Einträgen")
|
||||||
|
lastSchreiben = flag.Int("last.schreibanteil", 30, "Anteil schreibender Aufrufe in Prozent, der Rest ist recall und get")
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLast(t *testing.T) {
|
||||||
|
// Jede Anfrage würde sonst eine Logzeile schreiben — bei zehntausenden
|
||||||
|
// Aufrufen geht die Auswertung darin unter.
|
||||||
|
log.SetOutput(io.Discard)
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|
||||||
|
n := *lastProjekte
|
||||||
|
aufstellungen := []struct {
|
||||||
|
name string
|
||||||
|
workspaces, projekte int // projekte = je Workspace
|
||||||
|
}{
|
||||||
|
{"ein Projekt", 1, 1},
|
||||||
|
{fmt.Sprintf("ein Workspace, %d Projekte", n), 1, n},
|
||||||
|
{fmt.Sprintf("%d Workspaces, je 1 Projekt", n), n, 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range aufstellungen {
|
||||||
|
t.Run(a.name, func(t *testing.T) {
|
||||||
|
lauf(t, a.workspaces, a.projekte)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ziel ist ein Projekt samt den Keys, mit denen Agenten es ansprechen.
|
||||||
|
type ziel struct {
|
||||||
|
ws *workspace.Workspace
|
||||||
|
projekt store.Project
|
||||||
|
keys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// lauf baut eine Aufstellung auf, lässt die Agenten laufen und wertet aus.
|
||||||
|
func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
control, err := store.OpenControl(filepath.Join(dir, "control.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { control.Close() })
|
||||||
|
workspaces := workspace.NewManager(dir)
|
||||||
|
t.Cleanup(func() { workspaces.Close() })
|
||||||
|
|
||||||
|
// Dieselbe Verdrahtung wie im Betrieb. Schreibbudget 0 = unbegrenzt — es
|
||||||
|
// soll eine Schleife stoppen und würde hier die Messung abwürgen.
|
||||||
|
handler, err := baueHandler(control, workspaces, nil, config{
|
||||||
|
sessionSecret: strings.Repeat("x", 32),
|
||||||
|
adminToken: strings.Repeat("y", 32),
|
||||||
|
maxAPIBody: defaultMaxAPIBodyBytes,
|
||||||
|
writeLimitHourly: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
// ── Daten anlegen ────────────────────────────────────────────────────────
|
||||||
|
agenten := *lastAgenten
|
||||||
|
anzahlZiele := anzahlWorkspaces * projekteJeWorkspace
|
||||||
|
var ziele []*ziel
|
||||||
|
|
||||||
|
for w := 0; w < anzahlWorkspaces; w++ {
|
||||||
|
wsEintrag, err := control.CreateWorkspace(ctx, fmt.Sprintf("Workspace %d", w))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ws, err := workspaces.Get(wsEintrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for p := 0; p < projekteJeWorkspace; p++ {
|
||||||
|
projekt, err := ws.Store.CreateProject(ctx, wsEintrag.ID, fmt.Sprintf("projekt-%d", p), "Projekt", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
z := &ziel{ws: ws, projekt: projekt}
|
||||||
|
ziele = append(ziele, z)
|
||||||
|
|
||||||
|
for i := 0; i < *lastEintraege; i++ {
|
||||||
|
if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{
|
||||||
|
ProjectID: projekt.ID, Slug: fmt.Sprintf("vorab-%04d", i), Title: fmt.Sprintf("Vorab %d", i),
|
||||||
|
Content: fuellText(i), Source: store.SourceHuman,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jeder Agent bekommt einen eigenen Key, wie ein echter Nutzer.
|
||||||
|
for a := 0; a < agenten; a++ {
|
||||||
|
z := ziele[a%anzahlZiele]
|
||||||
|
klartext, err := auth.GenerateKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash := auth.HashKey(klartext)
|
||||||
|
if _, err := z.ws.Store.CreateAPIKey(ctx, z.projekt.ID, fmt.Sprintf("agent-%d", a), hash, string(auth.RoleWrite)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := control.IndexKey(ctx, hash, z.ws.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
z.keys = append(z.keys, klartext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agenten laufen lassen ────────────────────────────────────────────────
|
||||||
|
// Ein gemeinsamer Transport, der je Agent eine Verbindung offen hält. Mit
|
||||||
|
// den Vorgaben von Go (2 offene Verbindungen je Ziel) öffnen die Agenten
|
||||||
|
// sonst laufend neue, bis dem Testrechner die lokalen Ports ausgehen
|
||||||
|
// ("can't assign requested address"). Echte Agenten sitzen auf
|
||||||
|
// verschiedenen Rechnern und kennen das Problem nicht — es wäre also ein
|
||||||
|
// Fehler der Messung, nicht des Servers.
|
||||||
|
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
httpTransport.MaxIdleConnsPerHost = agenten
|
||||||
|
t.Cleanup(httpTransport.CloseIdleConnections)
|
||||||
|
|
||||||
|
messung := newMessung()
|
||||||
|
ende := time.Now().Add(*lastDauer)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for a := 0; a < agenten; a++ {
|
||||||
|
z := ziele[a%anzahlZiele]
|
||||||
|
key := z.keys[a/anzahlZiele]
|
||||||
|
wg.Add(1)
|
||||||
|
go func(a int) {
|
||||||
|
defer wg.Done()
|
||||||
|
agent(ctx, httpTransport, server.URL, key, a, ende, messung)
|
||||||
|
}(a)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// ── Auswerten ────────────────────────────────────────────────────────────
|
||||||
|
messung.bericht(t, *lastDauer)
|
||||||
|
|
||||||
|
if anzahl, erste := messung.fehler(); anzahl > 0 {
|
||||||
|
t.Errorf("%d fehlgeschlagene Aufrufe, erste: %v", anzahl, erste)
|
||||||
|
}
|
||||||
|
|
||||||
|
pruefeBestand(t, ziele, messung.erfolgreicheSchreibvorgaenge())
|
||||||
|
}
|
||||||
|
|
||||||
|
// agent verhält sich wie ein Agent in einer Schleife: suchen, lesen,
|
||||||
|
// gelegentlich schreiben — bis die Zeit um ist.
|
||||||
|
func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string, nr int, ende time.Time, m *messung) {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &mitKey{key: key, next: httpTransport},
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
transport := &mcp.StreamableClientTransport{
|
||||||
|
Endpoint: url + "/mcp",
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
// Der zusätzliche Rückkanal für Nachrichten vom Server wird hier nicht
|
||||||
|
// gebraucht und würde nur eine weitere offene Verbindung je Agent kosten.
|
||||||
|
DisableStandaloneSSE: true,
|
||||||
|
}
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: fmt.Sprintf("agent-%d", nr), Version: "1"}, nil)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
sitzung, err := client.Connect(ctx, transport, nil)
|
||||||
|
m.erfasse("verbinden", time.Since(start), err)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sitzung.Close()
|
||||||
|
|
||||||
|
zufall := rand.New(rand.NewPCG(uint64(nr), 42))
|
||||||
|
for i := 0; time.Now().Before(ende); i++ {
|
||||||
|
var werkzeug string
|
||||||
|
var args map[string]any
|
||||||
|
|
||||||
|
switch w := zufall.IntN(100); {
|
||||||
|
case w < *lastSchreiben:
|
||||||
|
werkzeug = "remember"
|
||||||
|
// Ein eindeutiges Wort je Schreibvorgang — daran erkennt
|
||||||
|
// pruefeBestand, ob der Suchindex den letzten Stand kennt.
|
||||||
|
args = map[string]any{
|
||||||
|
"slug": fmt.Sprintf("gemeinsam-%02d", zufall.IntN(*lastSlugs)),
|
||||||
|
"title": "Gemeinsamer Eintrag",
|
||||||
|
"content": "Stand " + eindeutigesWort(nr, i) + "\n\n" + fuellText(i),
|
||||||
|
}
|
||||||
|
case w < *lastSchreiben+(100-*lastSchreiben)*2/3:
|
||||||
|
werkzeug = "recall"
|
||||||
|
args = map[string]any{"query": suchbegriffe[zufall.IntN(len(suchbegriffe))]}
|
||||||
|
default:
|
||||||
|
werkzeug = "get"
|
||||||
|
args = map[string]any{"slug": fmt.Sprintf("vorab-%04d", zufall.IntN(max(1, *lastEintraege)))}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
erg, err := sitzung.CallTool(ctx, &mcp.CallToolParams{Name: werkzeug, Arguments: args})
|
||||||
|
if err == nil && erg.IsError {
|
||||||
|
err = fmt.Errorf("%s: %s", werkzeug, ergebnisText(erg))
|
||||||
|
}
|
||||||
|
m.erfasse(werkzeug, time.Since(start), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruefeBestand vergleicht am Ende Datenbank und Suchindex.
|
||||||
|
//
|
||||||
|
// Zwei Dinge müssen stimmen:
|
||||||
|
// - Kein Schreibvorgang ging verloren: Jeder erfolgreiche remember-Aufruf
|
||||||
|
// hat eine Fassung erzeugt. Die Summe der Fassungen aller umkämpften
|
||||||
|
// Einträge muss also genau die Zahl der erfolgreichen Aufrufe sein.
|
||||||
|
// - Der Suchindex kennt den letzten Stand: Das eindeutige Wort der letzten
|
||||||
|
// Fassung muss den Eintrag finden.
|
||||||
|
func pruefeBestand(t *testing.T, ziele []*ziel, erfolgreich int) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
summeFassungen, veraltet := 0, 0
|
||||||
|
for _, z := range ziele {
|
||||||
|
idx, err := z.ws.Search.For(ctx, z.projekt.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for s := 0; s < *lastSlugs; s++ {
|
||||||
|
slug := fmt.Sprintf("gemeinsam-%02d", s)
|
||||||
|
e, err := z.ws.Store.EntryBySlug(ctx, z.projekt.ID, slug)
|
||||||
|
if err != nil {
|
||||||
|
continue // nie beschrieben
|
||||||
|
}
|
||||||
|
summeFassungen += e.Version
|
||||||
|
|
||||||
|
wort := strings.Fields(e.Content)[1]
|
||||||
|
gefunden := slices.ContainsFunc(idx.Search(wort, 5), func(r search.Result) bool { return r.DocID == slug })
|
||||||
|
if !gefunden {
|
||||||
|
veraltet++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if summeFassungen != erfolgreich {
|
||||||
|
t.Errorf("%d erfolgreiche remember-Aufrufe, aber %d Fassungen gespeichert", erfolgreich, summeFassungen)
|
||||||
|
}
|
||||||
|
if veraltet > 0 {
|
||||||
|
t.Errorf("%d Einträge: Der Suchindex kennt die letzte Fassung nicht", veraltet)
|
||||||
|
}
|
||||||
|
t.Logf("Bestand geprüft: %d Fassungen, Suchindex %s", summeFassungen,
|
||||||
|
map[bool]string{true: "aktuell", false: "VERALTET"}[veraltet == 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messung ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// messung sammelt Dauer und Fehler aller Aufrufe, getrennt nach Werkzeug.
|
||||||
|
type messung struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
dauern map[string][]time.Duration
|
||||||
|
fehl map[string]int
|
||||||
|
erste []error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessung() *messung {
|
||||||
|
return &messung{dauern: map[string][]time.Duration{}, fehl: map[string]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *messung) erfasse(werkzeug string, d time.Duration, err error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
m.fehl[werkzeug]++
|
||||||
|
if len(m.erste) < 10 {
|
||||||
|
m.erste = append(m.erste, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.dauern[werkzeug] = append(m.dauern[werkzeug], d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fehler gibt die Zahl aller fehlgeschlagenen Aufrufe und die ersten davon
|
||||||
|
// zurück.
|
||||||
|
func (m *messung) fehler() (anzahl int, erste []error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, n := range m.fehl {
|
||||||
|
anzahl += n
|
||||||
|
}
|
||||||
|
return anzahl, m.erste
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *messung) erfolgreicheSchreibvorgaenge() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return len(m.dauern["remember"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// bericht schreibt eine Tabelle: Aufrufe je Sekunde und wie lange die
|
||||||
|
// typische (p50), die langsamsten 5 % (p95) und 1 % (p99) Aufrufe dauerten.
|
||||||
|
func (m *messung) bericht(t *testing.T, dauer time.Duration) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "\n%-10s %8s %8s %9s %9s %9s %9s %7s\n", "Werkzeug", "Aufrufe", "je s", "p50", "p95", "p99", "max", "Fehler")
|
||||||
|
for _, w := range []string{"verbinden", "recall", "get", "remember"} {
|
||||||
|
d := m.dauern[w]
|
||||||
|
slices.Sort(d)
|
||||||
|
je := float64(len(d)) / dauer.Seconds()
|
||||||
|
if w == "verbinden" {
|
||||||
|
je = 0 // einmal je Agent, keine Rate
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%-10s %8d %8.0f %9s %9s %9s %9s %7d\n", w, len(d), je,
|
||||||
|
perzentil(d, 50), perzentil(d, 95), perzentil(d, 99), perzentil(d, 100), m.fehl[w])
|
||||||
|
}
|
||||||
|
t.Log(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func perzentil(sortiert []time.Duration, p int) string {
|
||||||
|
if len(sortiert) == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
i := (len(sortiert) - 1) * p / 100
|
||||||
|
return sortiert[i].Round(10 * time.Microsecond).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfen ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// mitKey hängt den Bearer-Key an jede Anfrage — so wie es ein MCP-Client mit
|
||||||
|
// "headers" in seiner Konfiguration tut.
|
||||||
|
type mitKey struct {
|
||||||
|
key string
|
||||||
|
next http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mitKey) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
r = r.Clone(r.Context())
|
||||||
|
r.Header.Set("Authorization", "Bearer "+m.key)
|
||||||
|
return m.next.RoundTrip(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ergebnisText(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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// eindeutigesWort bildet aus Agent und Durchlauf ein Wort nur aus Buchstaben,
|
||||||
|
// damit der Tokenizer es nicht an Ziffern zerlegt.
|
||||||
|
func eindeutigesWort(agent, durchlauf int) string {
|
||||||
|
s := []byte(fmt.Sprintf("%04d%07d", agent, durchlauf))
|
||||||
|
for i := range s {
|
||||||
|
s[i] = 'a' + (s[i] - '0')
|
||||||
|
}
|
||||||
|
return "marke" + string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
var suchbegriffe = []string{"deployment", "server", "datenbank", "zugang", "backup", "monitoring", "release", "fehler"}
|
||||||
|
|
||||||
|
// fuellText erzeugt einen Absatz mit wechselnden Fachbegriffen, damit der
|
||||||
|
// Suchindex realistisch viele Begriffe und Treffer hat.
|
||||||
|
func fuellText(i int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for j := 0; j < 40; j++ {
|
||||||
|
b.WriteString(suchbegriffe[(i+j*3)%len(suchbegriffe)])
|
||||||
|
b.WriteString(" wird hier beschrieben, mit Details zu Schritt ")
|
||||||
|
b.WriteString(fmt.Sprint(j))
|
||||||
|
b.WriteString(". ")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
111
main.go
111
main.go
|
|
@ -179,59 +179,11 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bestandteile ─────────────────────────────────────────────────────────
|
handler, err := baueHandler(control, workspaces, exporter, cfg)
|
||||||
signer := auth.NewSigner([]byte(cfg.sessionSecret))
|
|
||||||
cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE"))
|
|
||||||
limiter := auth.NewRateLimiter()
|
|
||||||
writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow)
|
|
||||||
|
|
||||||
webServer, err := web.NewServer(web.Config{
|
|
||||||
Workspaces: workspaces,
|
|
||||||
Control: control,
|
|
||||||
Signer: signer,
|
|
||||||
Cookies: cookies,
|
|
||||||
Limiter: limiter,
|
|
||||||
Templates: templatesFS,
|
|
||||||
AdminToken: cfg.adminToken,
|
|
||||||
Exporter: exporter,
|
|
||||||
WriteBudget: writeBudget,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Templates konnten nicht geladen werden: ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpHandler := mcpserver.New(writeBudget)
|
|
||||||
|
|
||||||
// ── Routen ───────────────────────────────────────────────────────────────
|
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
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", webServer.APIAuth(mcpHandler))
|
|
||||||
|
|
||||||
// Reihenfolge von außen nach innen:
|
|
||||||
// LogRequests erfasst jede Antwort, auch abgewiesene
|
|
||||||
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
|
|
||||||
// LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit
|
|
||||||
// einer eigenen Grenze für /mcp und den Import, siehe
|
|
||||||
// dort, warum das nicht je Route geschehen kann
|
|
||||||
handler := web.LogRequests(
|
|
||||||
web.SecurityHeaders(
|
|
||||||
web.LimitBodies(mux, web.BodyLimits{
|
|
||||||
Web: maxWebBodyBytes,
|
|
||||||
API: cfg.maxAPIBody,
|
|
||||||
Import: maxImportBodyBytes,
|
|
||||||
}),
|
|
||||||
cfg.enableHSTS,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Wiederkehrende Aufgaben ──────────────────────────────────────────────
|
// ── Wiederkehrende Aufgaben ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Beide laufen bei jedem Durchgang über ALLE Workspaces — auch über die,
|
// Beide laufen bei jedem Durchgang über ALLE Workspaces — auch über die,
|
||||||
|
|
@ -291,6 +243,67 @@ func main() {
|
||||||
log.Println("Server gestoppt.")
|
log.Println("Server gestoppt.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// baueHandler setzt Weboberfläche, MCP-Endpunkt und die Middleware davor zu
|
||||||
|
// einem HTTP-Handler zusammen.
|
||||||
|
//
|
||||||
|
// Eine eigene Funktion statt Teil von main, damit der Lasttest (last_test.go)
|
||||||
|
// über genau dieselbe Verdrahtung läuft wie der Betrieb — und nicht über einen
|
||||||
|
// nachgebauten Weg, der sich irgendwann unbemerkt unterscheidet.
|
||||||
|
func baueHandler(control *store.ControlStore, workspaces *workspace.Manager, exporter *export.Exporter, cfg config) (http.Handler, error) {
|
||||||
|
// ── Bestandteile ─────────────────────────────────────────────────────────
|
||||||
|
signer := auth.NewSigner([]byte(cfg.sessionSecret))
|
||||||
|
cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE"))
|
||||||
|
limiter := auth.NewRateLimiter()
|
||||||
|
writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow)
|
||||||
|
|
||||||
|
webServer, err := web.NewServer(web.Config{
|
||||||
|
Workspaces: workspaces,
|
||||||
|
Control: control,
|
||||||
|
Signer: signer,
|
||||||
|
Cookies: cookies,
|
||||||
|
Limiter: limiter,
|
||||||
|
Templates: templatesFS,
|
||||||
|
AdminToken: cfg.adminToken,
|
||||||
|
Exporter: exporter,
|
||||||
|
WriteBudget: writeBudget,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Templates konnten nicht geladen werden: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpHandler := mcpserver.New(writeBudget)
|
||||||
|
|
||||||
|
// ── Routen ───────────────────────────────────────────────────────────────
|
||||||
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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", webServer.APIAuth(mcpHandler))
|
||||||
|
|
||||||
|
// Reihenfolge von außen nach innen:
|
||||||
|
// LogRequests erfasst jede Antwort, auch abgewiesene
|
||||||
|
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
|
||||||
|
// LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit
|
||||||
|
// einer eigenen Grenze für /mcp und den Import, siehe
|
||||||
|
// dort, warum das nicht je Route geschehen kann
|
||||||
|
return web.LogRequests(
|
||||||
|
web.SecurityHeaders(
|
||||||
|
web.LimitBodies(mux, web.BodyLimits{
|
||||||
|
Web: maxWebBodyBytes,
|
||||||
|
API: cfg.maxAPIBody,
|
||||||
|
Import: maxImportBodyBytes,
|
||||||
|
}),
|
||||||
|
cfg.enableHSTS,
|
||||||
|
),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
// config sammelt alle Einstellungen aus der Umgebung.
|
// config sammelt alle Einstellungen aus der Umgebung.
|
||||||
type config struct {
|
type config struct {
|
||||||
// dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner
|
// dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@
|
||||||
# Platz unter DATA_DIR zurückkopieren, Server wieder starten. Für einen
|
# Platz unter DATA_DIR zurückkopieren, Server wieder starten. Für einen
|
||||||
# einzelnen Workspace reicht dessen Datei unter workspaces/ — die
|
# einzelnen Workspace reicht dessen Datei unter workspaces/ — die
|
||||||
# Kontroll-Datenbank bleibt davon unberührt.
|
# Kontroll-Datenbank bleibt davon unberührt.
|
||||||
|
#
|
||||||
|
# Vorher die -wal- und -shm-Dateien der alten Fassung entfernen, sonst
|
||||||
|
# übernimmt SQLite deren Inhalt in die zurückgespielte Datei. Die genauen
|
||||||
|
# Schritte stehen in BETRIEB.md, Abschnitt "Aus einer Sicherung wiederherstellen".
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
|
||||||
418
static/graph.js
418
static/graph.js
|
|
@ -9,11 +9,29 @@
|
||||||
// fortlaufend animiert: Bei der serverseitig gedeckelten Knotenzahl (siehe
|
// fortlaufend animiert: Bei der serverseitig gedeckelten Knotenzahl (siehe
|
||||||
// graphNodeLimit in internal/web/handlers_entries.go) ist das in Bruchteilen
|
// graphNodeLimit in internal/web/handlers_entries.go) ist das in Bruchteilen
|
||||||
// einer Sekunde fertig.
|
// einer Sekunde fertig.
|
||||||
|
//
|
||||||
|
// Damit der Graph auch bei vielen Knoten lesbar bleibt, gelten drei Regeln:
|
||||||
|
//
|
||||||
|
// 1. Zoomen zieht die Knoten auseinander, statt alles zu vergrößern.
|
||||||
|
// Punkte und Schrift behalten ihre Bildschirmgröße — wer hineinzoomt,
|
||||||
|
// schafft also tatsächlich Platz zwischen den Knoten.
|
||||||
|
// 2. Beschriftet wird nur, was Platz hat. Die am stärksten verknüpften
|
||||||
|
// Knoten kommen zuerst an die Reihe; eine Beschriftung, die eine schon
|
||||||
|
// gesetzte oder einen fremden Punkt überdecken würde, bleibt weg (siehe
|
||||||
|
// beschrifte()). Je weiter man hineinzoomt, desto mehr Namen erscheinen.
|
||||||
|
// 3. Zeigt die Maus auf einen Knoten, treten er und seine direkten Nachbarn
|
||||||
|
// hervor und werden bevorzugt beschriftet; alles andere wird blass.
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var SVG_NS = "http://www.w3.org/2000/svg";
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
// Ungefähre Höhe einer Beschriftungszeile in Bildschirmpixeln, samt
|
||||||
|
// etwas Luft — nur für die Überlappungsprüfung in beschrifte().
|
||||||
|
var ZEILENHOEHE = 16;
|
||||||
|
// Abstand zwischen Punkt und Beschriftung.
|
||||||
|
var TEXTABSTAND = 4;
|
||||||
|
|
||||||
document.querySelectorAll("svg[data-graph]").forEach(function (svg) {
|
document.querySelectorAll("svg[data-graph]").forEach(function (svg) {
|
||||||
ladeUndZeichne(svg);
|
ladeUndZeichne(svg);
|
||||||
});
|
});
|
||||||
|
|
@ -59,8 +77,11 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die viewBox entspricht der tatsächlichen Größe beim Laden — eine
|
||||||
|
// viewBox-Einheit ist damit ein Bildschirmpixel, und Schriftgrößen
|
||||||
|
// sowie Radien unten lassen sich direkt in Pixeln denken.
|
||||||
var breite = svg.clientWidth || 800;
|
var breite = svg.clientWidth || 800;
|
||||||
var hoehe = 560;
|
var hoehe = svg.clientHeight || 560;
|
||||||
svg.setAttribute("viewBox", "0 0 " + breite + " " + hoehe);
|
svg.setAttribute("viewBox", "0 0 " + breite + " " + hoehe);
|
||||||
|
|
||||||
var indexBySlug = {};
|
var indexBySlug = {};
|
||||||
|
|
@ -75,43 +96,70 @@
|
||||||
return p[0] !== undefined && p[1] !== undefined;
|
return p[0] !== undefined && p[1] !== undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nachbarn je Knoten — für die Hervorhebung beim Überfahren.
|
||||||
|
var nachbarn = knoten.map(function () {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
kantenIdx.forEach(function (paar) {
|
||||||
|
nachbarn[paar[0]].push(paar[1]);
|
||||||
|
nachbarn[paar[1]].push(paar[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
var radien = knoten.map(function (k) {
|
||||||
|
return 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// rangfolge: Knotenindizes, am stärksten verknüpfte zuerst. Diese
|
||||||
|
// Reihenfolge entscheidet, wer bei Platzmangel beschriftet wird.
|
||||||
|
// Vorhandene Einträge gehen fehlenden Verweiszielen vor, bei
|
||||||
|
// Gleichstand entscheidet der Titel — so bleibt die Auswahl bei
|
||||||
|
// jedem Laden dieselbe.
|
||||||
|
var rangfolge = knoten.map(function (_, i) {
|
||||||
|
return i;
|
||||||
|
});
|
||||||
|
rangfolge.sort(function (a, b) {
|
||||||
|
var ka = knoten[a];
|
||||||
|
var kb = knoten[b];
|
||||||
|
if ((kb.degree || 0) !== (ka.degree || 0)) {
|
||||||
|
return (kb.degree || 0) - (ka.degree || 0);
|
||||||
|
}
|
||||||
|
if (ka.exists !== kb.exists) {
|
||||||
|
return ka.exists ? -1 : 1;
|
||||||
|
}
|
||||||
|
return ka.title < kb.title ? -1 : ka.title > kb.title ? 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
var slugs = knoten.map(function (k) {
|
var slugs = knoten.map(function (k) {
|
||||||
return k.slug;
|
return k.slug;
|
||||||
});
|
});
|
||||||
var positionen = simuliere(knoten.length, kantenIdx, breite, hoehe, slugs);
|
var positionen = simuliere(knoten.length, kantenIdx, radien, slugs);
|
||||||
|
|
||||||
var viewport = svgEl("g", {});
|
|
||||||
svg.appendChild(viewport);
|
|
||||||
|
|
||||||
var kantenGruppe = svgEl("g", { class: "graph-kanten" });
|
var kantenGruppe = svgEl("g", { class: "graph-kanten" });
|
||||||
var knotenGruppe = svgEl("g", { class: "graph-knoten" });
|
var knotenGruppe = svgEl("g", { class: "graph-knoten" });
|
||||||
viewport.appendChild(kantenGruppe);
|
svg.appendChild(kantenGruppe);
|
||||||
viewport.appendChild(knotenGruppe);
|
svg.appendChild(knotenGruppe);
|
||||||
|
|
||||||
kantenIdx.forEach(function (paar) {
|
var kantenEls = kantenIdx.map(function (paar) {
|
||||||
var von = positionen[paar[0]];
|
var linie = svgEl("line", { class: "graph-kante" });
|
||||||
var nach = positionen[paar[1]];
|
kantenGruppe.appendChild(linie);
|
||||||
kantenGruppe.appendChild(
|
return linie;
|
||||||
svgEl("line", { x1: von.x, y1: von.y, x2: nach.x, y2: nach.y, class: "graph-kante" }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
knoten.forEach(function (k, i) {
|
var knotenEls = knoten.map(function (k, i) {
|
||||||
var pos = positionen[i];
|
|
||||||
var radius = 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3);
|
|
||||||
|
|
||||||
var gruppe = svgEl("g", {
|
var gruppe = svgEl("g", {
|
||||||
class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"),
|
class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"),
|
||||||
transform: "translate(" + pos.x.toFixed(1) + "," + pos.y.toFixed(1) + ")",
|
"data-index": i,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// <title> zeigt der Browser als Tooltip — so ist der volle Name
|
||||||
|
// auch dann lesbar, wenn die Beschriftung gerade ausgeblendet ist.
|
||||||
var titelEl = document.createElementNS(SVG_NS, "title");
|
var titelEl = document.createElementNS(SVG_NS, "title");
|
||||||
titelEl.textContent = k.title;
|
titelEl.textContent = k.title;
|
||||||
gruppe.appendChild(titelEl);
|
gruppe.appendChild(titelEl);
|
||||||
|
|
||||||
gruppe.appendChild(svgEl("circle", { r: radius, class: "graph-punkt" }));
|
gruppe.appendChild(svgEl("circle", { r: radien[i], class: "graph-punkt" }));
|
||||||
|
|
||||||
var text = svgEl("text", { x: radius + 4, y: 4, class: "graph-beschriftung" });
|
var text = svgEl("text", { x: radien[i] + TEXTABSTAND, y: 0, class: "graph-beschriftung" });
|
||||||
text.textContent = kuerze(k.title, 28);
|
text.textContent = kuerze(k.title, 28);
|
||||||
gruppe.appendChild(text);
|
gruppe.appendChild(text);
|
||||||
|
|
||||||
|
|
@ -121,9 +169,243 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
knotenGruppe.appendChild(gruppe);
|
knotenGruppe.appendChild(gruppe);
|
||||||
|
return { gruppe: gruppe, text: text };
|
||||||
});
|
});
|
||||||
|
|
||||||
aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl);
|
// Die Textbreite einmal messen, solange alles sichtbar ist (ein
|
||||||
|
// ausgeblendetes Element hat keine messbare Breite). Scheitert die
|
||||||
|
// Messung, hilft eine Schätzung über die Zeichenzahl.
|
||||||
|
var textBreiten = knotenEls.map(function (el) {
|
||||||
|
var gemessen = 0;
|
||||||
|
try {
|
||||||
|
gemessen = el.text.getComputedTextLength();
|
||||||
|
} catch (e) {
|
||||||
|
gemessen = 0;
|
||||||
|
}
|
||||||
|
return gemessen || el.text.textContent.length * 6.5;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ansicht beschreibt, wie Layout-Koordinaten (aus simuliere) auf den
|
||||||
|
// Bildschirm kommen: bildschirm = layout * massstab + verschiebung.
|
||||||
|
var ansicht = { massstab: 1, x: 0, y: 0 };
|
||||||
|
var fokus = -1;
|
||||||
|
var bildschirm = positionen.map(function () {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// aktualisiere setzt alle Elemente an ihre Bildschirmposition und
|
||||||
|
// wählt die Beschriftungen neu aus. Wird über requestAnimationFrame
|
||||||
|
// höchstens einmal je Bildschirmbild ausgeführt, auch wenn Mausrad
|
||||||
|
// oder Ziehen viel häufiger Ereignisse liefern.
|
||||||
|
var geplant = false;
|
||||||
|
function aktualisiereBald() {
|
||||||
|
if (geplant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
geplant = true;
|
||||||
|
window.requestAnimationFrame(function () {
|
||||||
|
geplant = false;
|
||||||
|
aktualisiere();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aktualisiere() {
|
||||||
|
positionen.forEach(function (pos, i) {
|
||||||
|
var bx = pos.x * ansicht.massstab + ansicht.x;
|
||||||
|
var by = pos.y * ansicht.massstab + ansicht.y;
|
||||||
|
bildschirm[i].x = bx;
|
||||||
|
bildschirm[i].y = by;
|
||||||
|
knotenEls[i].gruppe.setAttribute("transform", "translate(" + bx.toFixed(1) + "," + by.toFixed(1) + ")");
|
||||||
|
});
|
||||||
|
kantenIdx.forEach(function (paar, i) {
|
||||||
|
var von = bildschirm[paar[0]];
|
||||||
|
var nach = bildschirm[paar[1]];
|
||||||
|
kantenEls[i].setAttribute("x1", von.x.toFixed(1));
|
||||||
|
kantenEls[i].setAttribute("y1", von.y.toFixed(1));
|
||||||
|
kantenEls[i].setAttribute("x2", nach.x.toFixed(1));
|
||||||
|
kantenEls[i].setAttribute("y2", nach.y.toFixed(1));
|
||||||
|
});
|
||||||
|
beschrifte();
|
||||||
|
}
|
||||||
|
|
||||||
|
// beschrifte entscheidet, welche Knoten gerade einen Namen tragen.
|
||||||
|
//
|
||||||
|
// Gierig in Rangfolge: Jede Beschriftung wird zuerst rechts, dann
|
||||||
|
// links vom Punkt versucht. Überdeckt sie an beiden Stellen eine
|
||||||
|
// schon gesetzte Beschriftung oder einen fremden Punkt, bleibt sie
|
||||||
|
// weg. Das ist nicht die bestmögliche Verteilung, aber es garantiert
|
||||||
|
// das Wichtigste: Keine zwei Namen liegen übereinander.
|
||||||
|
//
|
||||||
|
// Aufwand: je Knoten ein Vergleich mit allen Punkten und allen schon
|
||||||
|
// gesetzten Namen — bei 150 Knoten einige zehntausend Rechtecktests,
|
||||||
|
// was auch beim flüssigen Zoomen nicht ins Gewicht fällt.
|
||||||
|
function beschrifte() {
|
||||||
|
var punktKaesten = bildschirm.map(function (p, i) {
|
||||||
|
var r = radien[i];
|
||||||
|
return { x1: p.x - r, y1: p.y - r, x2: p.x + r, y2: p.y + r };
|
||||||
|
});
|
||||||
|
var gesetzt = [];
|
||||||
|
|
||||||
|
var reihenfolge = rangfolge;
|
||||||
|
var erlaubt = null;
|
||||||
|
if (fokus >= 0) {
|
||||||
|
// Im Fokus: nur der Knoten selbst und seine Nachbarn, er
|
||||||
|
// selbst zuerst.
|
||||||
|
erlaubt = {};
|
||||||
|
erlaubt[fokus] = true;
|
||||||
|
nachbarn[fokus].forEach(function (n) {
|
||||||
|
erlaubt[n] = true;
|
||||||
|
});
|
||||||
|
reihenfolge = [fokus].concat(
|
||||||
|
rangfolge.filter(function (i) {
|
||||||
|
return i !== fokus && erlaubt[i];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeigen = {};
|
||||||
|
reihenfolge.forEach(function (i) {
|
||||||
|
var p = bildschirm[i];
|
||||||
|
// Knoten außerhalb des sichtbaren Bereichs brauchen keinen
|
||||||
|
// Namen und sollen auch keinen Platz belegen.
|
||||||
|
if (p.x < -50 || p.y < -50 || p.x > breite + 50 || p.y > hoehe + 50) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var r = radien[i];
|
||||||
|
var w = textBreiten[i];
|
||||||
|
var y1 = p.y - ZEILENHOEHE / 2;
|
||||||
|
var y2 = p.y + ZEILENHOEHE / 2;
|
||||||
|
var rechts = { x1: p.x + r + TEXTABSTAND, y1: y1, x2: p.x + r + TEXTABSTAND + w, y2: y2 };
|
||||||
|
var links = { x1: p.x - r - TEXTABSTAND - w, y1: y1, x2: p.x - r - TEXTABSTAND, y2: y2 };
|
||||||
|
|
||||||
|
// Der hervorgehobene Knoten bekommt seinen Namen immer.
|
||||||
|
var immer = i === fokus;
|
||||||
|
if (immer || istFrei(rechts, i, punktKaesten, gesetzt)) {
|
||||||
|
gesetzt.push(rechts);
|
||||||
|
zeigen[i] = "rechts";
|
||||||
|
} else if (istFrei(links, i, punktKaesten, gesetzt)) {
|
||||||
|
gesetzt.push(links);
|
||||||
|
zeigen[i] = "links";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
knotenEls.forEach(function (el, i) {
|
||||||
|
var seite = zeigen[i];
|
||||||
|
el.text.classList.toggle("graph-beschriftung-aus", !seite);
|
||||||
|
if (seite === "links") {
|
||||||
|
el.text.setAttribute("x", -(radien[i] + TEXTABSTAND));
|
||||||
|
el.text.setAttribute("text-anchor", "end");
|
||||||
|
} else if (seite === "rechts") {
|
||||||
|
el.text.setAttribute("x", radien[i] + TEXTABSTAND);
|
||||||
|
el.text.removeAttribute("text-anchor");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// setzeFokus hebt Knoten i und seine Nachbarn hervor; -1 hebt die
|
||||||
|
// Hervorhebung auf.
|
||||||
|
function setzeFokus(i) {
|
||||||
|
if (i === fokus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fokus = i;
|
||||||
|
svg.classList.toggle("graph-fokus", fokus >= 0);
|
||||||
|
var nah = {};
|
||||||
|
if (fokus >= 0) {
|
||||||
|
nah[fokus] = true;
|
||||||
|
nachbarn[fokus].forEach(function (n) {
|
||||||
|
nah[n] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
knotenEls.forEach(function (el, k) {
|
||||||
|
el.gruppe.classList.toggle("graph-nachbar", !!nah[k]);
|
||||||
|
});
|
||||||
|
kantenIdx.forEach(function (paar, k) {
|
||||||
|
kantenEls[k].classList.toggle("graph-kante-aktiv", paar[0] === fokus || paar[1] === fokus);
|
||||||
|
});
|
||||||
|
// Knoten im Fokus ans Ende ihrer Gruppe hängen: In SVG liegt
|
||||||
|
// oben, was zuletzt kommt — so verdeckt kein blasser Nachbar den
|
||||||
|
// hervorgehobenen Namen.
|
||||||
|
if (fokus >= 0) {
|
||||||
|
knotenGruppe.appendChild(knotenEls[fokus].gruppe);
|
||||||
|
}
|
||||||
|
beschrifte();
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.addEventListener("pointerover", function (event) {
|
||||||
|
// Während des Ziehens nicht umschalten — sonst flackert die
|
||||||
|
// Hervorhebung über alle Knoten, die unter der Maus durchlaufen.
|
||||||
|
if (svg.classList.contains("graph-zieht")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var gruppe = event.target.closest(".graph-knoten-gruppe");
|
||||||
|
setzeFokus(gruppe ? Number(gruppe.getAttribute("data-index")) : -1);
|
||||||
|
});
|
||||||
|
svg.addEventListener("pointerleave", function () {
|
||||||
|
setzeFokus(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Einpassen: den ganzen Graphen mittig in die Fläche zoomen.
|
||||||
|
var grenzen = { min: 0.1, max: 4 };
|
||||||
|
function einpassen() {
|
||||||
|
var minX = Infinity;
|
||||||
|
var minY = Infinity;
|
||||||
|
var maxX = -Infinity;
|
||||||
|
var maxY = -Infinity;
|
||||||
|
positionen.forEach(function (p) {
|
||||||
|
minX = Math.min(minX, p.x);
|
||||||
|
minY = Math.min(minY, p.y);
|
||||||
|
maxX = Math.max(maxX, p.x);
|
||||||
|
maxY = Math.max(maxY, p.y);
|
||||||
|
});
|
||||||
|
// Rand in Bildschirmpixeln: Platz für die Punkte selbst und für
|
||||||
|
// Beschriftungen am äußeren Rand.
|
||||||
|
var rand = 48;
|
||||||
|
var spanneX = Math.max(maxX - minX, 1);
|
||||||
|
var spanneY = Math.max(maxY - minY, 1);
|
||||||
|
var massstab = Math.min((breite - 2 * rand) / spanneX, (hoehe - 2 * rand) / spanneY);
|
||||||
|
// Kleine Graphen nicht künstlich aufblasen.
|
||||||
|
massstab = Math.min(massstab, 1.5);
|
||||||
|
|
||||||
|
ansicht.massstab = massstab;
|
||||||
|
ansicht.x = breite / 2 - ((minX + maxX) / 2) * massstab;
|
||||||
|
ansicht.y = hoehe / 2 - ((minY + maxY) / 2) * massstab;
|
||||||
|
|
||||||
|
// Heraus nur bis zur halben Einpass-Größe, hinein weit genug,
|
||||||
|
// dass auch dichte Knäuel auseinanderfallen.
|
||||||
|
grenzen.min = massstab * 0.5;
|
||||||
|
grenzen.max = Math.max(massstab * 12, 3);
|
||||||
|
aktualisiereBald();
|
||||||
|
}
|
||||||
|
|
||||||
|
einpassen();
|
||||||
|
aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, aktualisiereBald);
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-graph-einpassen]").forEach(function (knopf) {
|
||||||
|
knopf.addEventListener("click", einpassen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// istFrei prüft, ob das Rechteck kasten weder einen fremden Punkt (alle
|
||||||
|
// außer dem eigenen, Index eigener) noch eine schon gesetzte
|
||||||
|
// Beschriftung überdeckt.
|
||||||
|
function istFrei(kasten, eigener, punktKaesten, gesetzt) {
|
||||||
|
for (var i = 0; i < punktKaesten.length; i++) {
|
||||||
|
if (i !== eigener && ueberlappt(kasten, punktKaesten[i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var j = 0; j < gesetzt.length; j++) {
|
||||||
|
if (ueberlappt(kasten, gesetzt[j])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zwei Rechtecke überlappen, wenn sie sich auf beiden Achsen überlappen.
|
||||||
|
function ueberlappt(a, b) {
|
||||||
|
return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fnv1a bildet aus einer Zeichenkette einen 32-Bit-Hash — hier verwendet,
|
// fnv1a bildet aus einer Zeichenkette einen 32-Bit-Hash — hier verwendet,
|
||||||
|
|
@ -158,10 +440,16 @@
|
||||||
|
|
||||||
// Klassische Kräftesimulation: Abstoßung zwischen allen Knotenpaaren
|
// Klassische Kräftesimulation: Abstoßung zwischen allen Knotenpaaren
|
||||||
// (Coulomb-artig), Anziehung entlang der Kanten (Federgesetz) und eine
|
// (Coulomb-artig), Anziehung entlang der Kanten (Federgesetz) und eine
|
||||||
// schwache Kraft zur Bildmitte, damit der Graph nicht davonwandert.
|
// schwache Kraft zur Mitte, damit der Graph nicht davonwandert. Dazu
|
||||||
|
// eine Kollisionskraft, die Punkte, die sich überlappen würden, hart
|
||||||
|
// auseinanderschiebt.
|
||||||
|
//
|
||||||
|
// Das Ergebnis sind Layout-Koordinaten um den Nullpunkt, unabhängig von
|
||||||
|
// der Größe der Zeichenfläche — auf den Bildschirm bringt sie erst
|
||||||
|
// einpassen() in zeichne().
|
||||||
//
|
//
|
||||||
// O(Knoten²) je Durchlauf — bei der serverseitigen Obergrenze von 150
|
// O(Knoten²) je Durchlauf — bei der serverseitigen Obergrenze von 150
|
||||||
// Knoten sind das rund 22.000 Paare, mit 250 Durchläufen im
|
// Knoten sind das rund 11.000 Paare, mit 300 Durchläufen im
|
||||||
// einstelligen Millionenbereich an Rechenschritten. Das läuft im Browser
|
// einstelligen Millionenbereich an Rechenschritten. Das läuft im Browser
|
||||||
// in Bruchteilen einer Sekunde.
|
// in Bruchteilen einer Sekunde.
|
||||||
//
|
//
|
||||||
|
|
@ -170,25 +458,31 @@
|
||||||
// Startpositionen und damit, weil die Kräfte selbst keine Zufallszahlen
|
// Startpositionen und damit, weil die Kräfte selbst keine Zufallszahlen
|
||||||
// mehr ziehen, dasselbe Endlayout. Ändert sich die Knotenmenge, ändert
|
// mehr ziehen, dasselbe Endlayout. Ändert sich die Knotenmenge, ändert
|
||||||
// sich auch der Seed, und das Layout darf sich neu einpendeln.
|
// sich auch der Seed, und das Layout darf sich neu einpendeln.
|
||||||
function simuliere(n, kantenIdx, breite, hoehe, slugsFuerSeed) {
|
function simuliere(n, kantenIdx, radien, slugsFuerSeed) {
|
||||||
var zufall = mulberry32(fnv1a(slugsFuerSeed.join("\n")));
|
var zufall = mulberry32(fnv1a(slugsFuerSeed.join("\n")));
|
||||||
|
|
||||||
|
var ABSTOSSUNG = 3000;
|
||||||
|
var FEDER = 0.02;
|
||||||
|
var ZIELLAENGE = 110;
|
||||||
|
var DAEMPFUNG = 0.85;
|
||||||
|
var ITERATIONEN = 300;
|
||||||
|
// Mindestabstand zwischen zwei Punkten zusätzlich zu ihren Radien.
|
||||||
|
var LUFT = 24;
|
||||||
|
|
||||||
|
// Startfläche wächst mit der Wurzel der Knotenzahl — so beginnt ein
|
||||||
|
// großer Graph nicht zusammengequetscht auf derselben Fläche wie ein
|
||||||
|
// kleiner.
|
||||||
|
var spanne = ZIELLAENGE * Math.sqrt(n);
|
||||||
var positionen = [];
|
var positionen = [];
|
||||||
for (var i = 0; i < n; i++) {
|
for (var i = 0; i < n; i++) {
|
||||||
positionen.push({
|
positionen.push({
|
||||||
x: breite / 2 + (zufall() - 0.5) * breite * 0.6,
|
x: (zufall() - 0.5) * spanne,
|
||||||
y: hoehe / 2 + (zufall() - 0.5) * hoehe * 0.6,
|
y: (zufall() - 0.5) * spanne,
|
||||||
vx: 0,
|
vx: 0,
|
||||||
vy: 0,
|
vy: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var ABSTOSSUNG = 1800;
|
|
||||||
var FEDER = 0.02;
|
|
||||||
var ZIELLAENGE = 90;
|
|
||||||
var DAEMPFUNG = 0.85;
|
|
||||||
var ITERATIONEN = 250;
|
|
||||||
|
|
||||||
for (var iter = 0; iter < ITERATIONEN; iter++) {
|
for (var iter = 0; iter < ITERATIONEN; iter++) {
|
||||||
for (var a = 0; a < n; a++) {
|
for (var a = 0; a < n; a++) {
|
||||||
for (var b = a + 1; b < n; b++) {
|
for (var b = a + 1; b < n; b++) {
|
||||||
|
|
@ -197,6 +491,10 @@
|
||||||
var distQ = dx * dx + dy * dy || 0.01;
|
var distQ = dx * dx + dy * dy || 0.01;
|
||||||
var dist = Math.sqrt(distQ);
|
var dist = Math.sqrt(distQ);
|
||||||
var kraft = ABSTOSSUNG / distQ;
|
var kraft = ABSTOSSUNG / distQ;
|
||||||
|
var mindest = radien[a] + radien[b] + LUFT;
|
||||||
|
if (dist < mindest) {
|
||||||
|
kraft += (mindest - dist) * 0.5;
|
||||||
|
}
|
||||||
var fx = (dx / dist) * kraft;
|
var fx = (dx / dist) * kraft;
|
||||||
var fy = (dy / dist) * kraft;
|
var fy = (dy / dist) * kraft;
|
||||||
positionen[a].vx += fx;
|
positionen[a].vx += fx;
|
||||||
|
|
@ -222,8 +520,8 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
for (var k = 0; k < n; k++) {
|
for (var k = 0; k < n; k++) {
|
||||||
positionen[k].vx += (breite / 2 - positionen[k].x) * 0.002;
|
positionen[k].vx -= positionen[k].x * 0.002;
|
||||||
positionen[k].vy += (hoehe / 2 - positionen[k].y) * 0.002;
|
positionen[k].vy -= positionen[k].y * 0.002;
|
||||||
}
|
}
|
||||||
for (var m = 0; m < n; m++) {
|
for (var m = 0; m < n; m++) {
|
||||||
positionen[m].vx *= DAEMPFUNG;
|
positionen[m].vx *= DAEMPFUNG;
|
||||||
|
|
@ -236,22 +534,21 @@
|
||||||
return positionen;
|
return positionen;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zoom (Mausrad) und Pan (Ziehen) auf der Transform-Gruppe "viewport".
|
// Zoom (Mausrad) und Pan (Ziehen). Verändert nur das Objekt ansicht
|
||||||
|
// (massstab, x, y) und ruft danach beiAenderung auf — das Umsetzen auf
|
||||||
|
// die SVG-Elemente übernimmt zeichne().
|
||||||
//
|
//
|
||||||
// Der naheliegende Ansatz — Mausbewegung in Bildschirm-Pixeln direkt als
|
// Der naheliegende Ansatz — Mausbewegung in Bildschirm-Pixeln direkt als
|
||||||
// Verschiebung im viewBox-Koordinatensystem übernehmen — geht nur auf,
|
// Verschiebung im viewBox-Koordinatensystem übernehmen — geht nur auf,
|
||||||
// wenn die dargestellte Größe der SVG-Fläche exakt ihrer viewBox
|
// wenn die dargestellte Größe der SVG-Fläche exakt ihrer viewBox
|
||||||
// entspricht. Das stimmt zufällig beim ersten Zeichnen (viewBox-Breite
|
// entspricht. Das stimmt zufällig beim ersten Zeichnen (viewBox-Größe
|
||||||
// kommt von svg.clientWidth), aber nicht mehr nach einer
|
// kommt von svg.clientWidth/clientHeight), aber nicht mehr nach einer
|
||||||
// Fenstergröße-Änderung (die SVG-Breite ist "100%" und wächst/schrumpft
|
// Fenstergröße-Änderung (die SVG-Breite ist "100%" und wächst/schrumpft
|
||||||
// mit), und auch sonst nicht zuverlässig (abweichende rem-Basis,
|
// mit), und auch sonst nicht zuverlässig (abweichende rem-Basis,
|
||||||
// Browser-Zoom). zuViewBox() rechnet deshalb bei jeder Bewegung über
|
// Browser-Zoom). zuViewBox() rechnet deshalb bei jeder Bewegung über
|
||||||
// getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung
|
// getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung
|
||||||
// zu verlassen.
|
// zu verlassen.
|
||||||
function aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl) {
|
function aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, beiAenderung) {
|
||||||
var massstab = 1;
|
|
||||||
var x = 0;
|
|
||||||
var y = 0;
|
|
||||||
var ziehtGerade = false;
|
var ziehtGerade = false;
|
||||||
var startPunkt = { x: 0, y: 0 };
|
var startPunkt = { x: 0, y: 0 };
|
||||||
var startX = 0;
|
var startX = 0;
|
||||||
|
|
@ -262,10 +559,6 @@
|
||||||
// Ziehen statt als Klick.
|
// Ziehen statt als Klick.
|
||||||
var ZIEHSCHWELLE = 6;
|
var ZIEHSCHWELLE = 6;
|
||||||
|
|
||||||
function anwenden() {
|
|
||||||
viewport.setAttribute("transform", "translate(" + x + "," + y + ") scale(" + massstab + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
function zuViewBox(clientX, clientY) {
|
function zuViewBox(clientX, clientY) {
|
||||||
var rect = svg.getBoundingClientRect();
|
var rect = svg.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
|
|
@ -282,8 +575,8 @@
|
||||||
bewegungsstrecke = 0;
|
bewegungsstrecke = 0;
|
||||||
gedruecktesElement = event.target.closest(".graph-klickbar");
|
gedruecktesElement = event.target.closest(".graph-klickbar");
|
||||||
startPunkt = zuViewBox(event.clientX, event.clientY);
|
startPunkt = zuViewBox(event.clientX, event.clientY);
|
||||||
startX = x;
|
startX = ansicht.x;
|
||||||
startY = y;
|
startY = ansicht.y;
|
||||||
svg.classList.add("graph-zieht");
|
svg.classList.add("graph-zieht");
|
||||||
svg.setPointerCapture(event.pointerId);
|
svg.setPointerCapture(event.pointerId);
|
||||||
});
|
});
|
||||||
|
|
@ -293,11 +586,10 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var jetzt = zuViewBox(event.clientX, event.clientY);
|
var jetzt = zuViewBox(event.clientX, event.clientY);
|
||||||
// Die Verschiebung (translate) der Gruppe lebt im selben
|
// Die Verschiebung lebt im Bildschirm-Koordinatensystem der
|
||||||
// Koordinatensystem wie die viewBox der äußeren SVG — deshalb
|
// viewBox — deshalb hier ohne weitere Umrechnung durch massstab:
|
||||||
// hier ohne weitere Umrechnung durch massstab: Eine Bewegung um
|
// Eine Bewegung um 10 viewBox-Einheiten verschiebt den Inhalt um
|
||||||
// 10 viewBox-Einheiten verschiebt den Inhalt um genau 10
|
// genau 10 Einheiten, unabhängig vom aktuellen Zoomstand.
|
||||||
// Einheiten, unabhängig vom aktuellen Zoomstand.
|
|
||||||
//
|
//
|
||||||
// Der größte je erreichte Abstand zum Startpunkt zählt, nicht
|
// Der größte je erreichte Abstand zum Startpunkt zählt, nicht
|
||||||
// die Summe aller Zwischenschritte — sonst würde allein die
|
// die Summe aller Zwischenschritte — sonst würde allein die
|
||||||
|
|
@ -306,9 +598,9 @@
|
||||||
// einen stillstehenden Klick als Ziehen werten.
|
// einen stillstehenden Klick als Ziehen werten.
|
||||||
var abstand = Math.abs(jetzt.x - startPunkt.x) + Math.abs(jetzt.y - startPunkt.y);
|
var abstand = Math.abs(jetzt.x - startPunkt.x) + Math.abs(jetzt.y - startPunkt.y);
|
||||||
bewegungsstrecke = Math.max(bewegungsstrecke, abstand);
|
bewegungsstrecke = Math.max(bewegungsstrecke, abstand);
|
||||||
x = startX + (jetzt.x - startPunkt.x);
|
ansicht.x = startX + (jetzt.x - startPunkt.x);
|
||||||
y = startY + (jetzt.y - startPunkt.y);
|
ansicht.y = startY + (jetzt.y - startPunkt.y);
|
||||||
anwenden();
|
beiAenderung();
|
||||||
});
|
});
|
||||||
|
|
||||||
function beendeZiehen(event) {
|
function beendeZiehen(event) {
|
||||||
|
|
@ -344,20 +636,20 @@
|
||||||
"wheel",
|
"wheel",
|
||||||
function (event) {
|
function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
var faktor = event.deltaY < 0 ? 1.1 : 0.9;
|
var faktor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||||
var neuerMassstab = Math.min(4, Math.max(0.25, massstab * faktor));
|
var neuerMassstab = Math.min(grenzen.max, Math.max(grenzen.min, ansicht.massstab * faktor));
|
||||||
if (neuerMassstab === massstab) {
|
if (neuerMassstab === ansicht.massstab) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Zoomt zur Cursor-Position statt zur Bildecke: Der Punkt
|
// Zoomt zur Cursor-Position statt zur Bildecke: Der Punkt
|
||||||
// unter dem Mauszeiger bleibt an derselben Stelle stehen,
|
// unter dem Mauszeiger bleibt an derselben Stelle stehen,
|
||||||
// statt dass der Inhalt beim Zoomen "wegläuft".
|
// statt dass der Inhalt beim Zoomen "wegläuft".
|
||||||
var zeiger = zuViewBox(event.clientX, event.clientY);
|
var zeiger = zuViewBox(event.clientX, event.clientY);
|
||||||
var verhaeltnis = neuerMassstab / massstab;
|
var verhaeltnis = neuerMassstab / ansicht.massstab;
|
||||||
x = zeiger.x - (zeiger.x - x) * verhaeltnis;
|
ansicht.x = zeiger.x - (zeiger.x - ansicht.x) * verhaeltnis;
|
||||||
y = zeiger.y - (zeiger.y - y) * verhaeltnis;
|
ansicht.y = zeiger.y - (zeiger.y - ansicht.y) * verhaeltnis;
|
||||||
massstab = neuerMassstab;
|
ansicht.massstab = neuerMassstab;
|
||||||
anwenden();
|
beiAenderung();
|
||||||
},
|
},
|
||||||
{ passive: false },
|
{ passive: false },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
|
|
||||||
/* Breiten. Die Seite nutzt den Platz aus; nur der Fließtext begrenzt sich
|
/* Breiten. Die Seite nutzt den Platz aus; nur der Fließtext begrenzt sich
|
||||||
* selbst (siehe Regel zwei oben). */
|
* selbst (siehe Regel zwei oben). */
|
||||||
--breite-seite: 92rem; /* Seitenleiste plus Inhalt */
|
--breite-seite: 120rem; /* Seitenleiste plus Inhalt */
|
||||||
--breite-leiste: 15rem; /* Seitenleiste */
|
--breite-leiste: 15rem; /* Seitenleiste */
|
||||||
--breite-lesen: 44rem; /* Fließtext eines Eintrags */
|
--breite-lesen: 44rem; /* Fließtext eines Eintrags */
|
||||||
--breite-schmal: 25rem; /* Anmeldung */
|
--breite-schmal: 25rem; /* Anmeldung */
|
||||||
|
|
@ -198,7 +198,9 @@ pre code { background: none; border: none; padding: 0; }
|
||||||
font-size: var(--t-meta);
|
font-size: var(--t-meta);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.arbeitsbereich:hover {
|
/* Nur der Umschalter (Link) reagiert auf die Maus — beim Workspace-Admin
|
||||||
|
* steht der Name als reiner Text ohne Ziel. */
|
||||||
|
a.arbeitsbereich:hover {
|
||||||
background: var(--flaeche-leis);
|
background: var(--flaeche-leis);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -214,7 +216,7 @@ pre code { background: none; border: none; padding: 0; }
|
||||||
padding-inline: var(--s5);
|
padding-inline: var(--s5);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--breite-leiste) minmax(0, 1fr);
|
grid-template-columns: var(--breite-leiste) minmax(0, 1fr);
|
||||||
gap: var(--s7);
|
gap: var(--s6);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -635,13 +637,17 @@ table.tabelle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: var(--s5);
|
/* Unten mehr Abstand als oben: Das hebt den Block etwas über die
|
||||||
|
* mathematische Mitte. Genau mittig wirkt er optisch zu tief. */
|
||||||
|
padding: var(--s5) var(--s5) 12vh;
|
||||||
}
|
}
|
||||||
.anmeldeseite .karte { width: 100%; padding: var(--s6); }
|
.anmeldeseite .karte { width: 100%; padding: var(--s6); }
|
||||||
.anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); }
|
.anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); }
|
||||||
/* Logo zentriert über der Anmeldekarte. */
|
/* Logo zentriert über der Anmeldekarte. */
|
||||||
.anmelde-logo { display: flex; justify-content: center; margin-bottom: var(--s4); }
|
.anmelde-logo { display: flex; justify-content: center; margin-bottom: var(--s2); }
|
||||||
.anmelde-logo img { width: 160px; height: auto; }
|
/* 264px, damit auch die kleine Unterzeile im Logo ("MCP Server • Wiki /
|
||||||
|
* Knowledge Base") lesbar bleibt. max-width fängt schmale Handys ab. */
|
||||||
|
.anmelde-logo img { width: 264px; max-width: 100%; height: auto; }
|
||||||
|
|
||||||
/* Werkzeugleiste über der Eintragsliste: Suche und Filter. */
|
/* Werkzeugleiste über der Eintragsliste: Suche und Filter. */
|
||||||
.werkzeugleiste {
|
.werkzeugleiste {
|
||||||
|
|
@ -848,13 +854,16 @@ table.tabelle {
|
||||||
.graph-huelle svg {
|
.graph-huelle svg {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 35rem;
|
/* Möglichst viel Fläche, aber auf kleinen und sehr hohen Bildschirmen
|
||||||
|
* in vernünftigen Grenzen. */
|
||||||
|
height: clamp(30rem, 75vh, 60rem);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
.graph-huelle svg.graph-zieht { cursor: grabbing; }
|
.graph-huelle svg.graph-zieht { cursor: grabbing; }
|
||||||
|
|
||||||
.graph-kante { stroke: var(--rahmen-stark); stroke-width: 1.5; }
|
.graph-kante { stroke: var(--rahmen-stark); stroke-width: 1.5; transition: opacity 0.15s; }
|
||||||
|
.graph-knoten-gruppe { transition: opacity 0.15s; }
|
||||||
|
|
||||||
.graph-punkt {
|
.graph-punkt {
|
||||||
fill: var(--akzent-flach);
|
fill: var(--akzent-flach);
|
||||||
|
|
@ -873,12 +882,55 @@ table.tabelle {
|
||||||
/* Beschriftungen sollen das Ziehen des Graphen nicht durch
|
/* Beschriftungen sollen das Ziehen des Graphen nicht durch
|
||||||
* Textmarkierung stören. */
|
* Textmarkierung stören. */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
/* Ein Rand in Hintergrundfarbe um jeden Buchstaben: Kanten, die hinter
|
||||||
|
* dem Text verlaufen, werden so unterbrochen, statt die Schrift zu
|
||||||
|
* durchkreuzen. paint-order malt den Rand zuerst, die Füllung darüber. */
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: var(--flaeche);
|
||||||
|
stroke-width: 3px;
|
||||||
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
/* Welche Namen Platz haben, entscheidet static/graph.js (beschrifte()). */
|
||||||
|
.graph-beschriftung-aus { display: none; }
|
||||||
.graph-knoten-fehlend .graph-beschriftung { fill: var(--text-leise); }
|
.graph-knoten-fehlend .graph-beschriftung { fill: var(--text-leise); }
|
||||||
.graph-klickbar { cursor: pointer; }
|
.graph-klickbar { cursor: pointer; }
|
||||||
.graph-klickbar:hover .graph-punkt { stroke-width: 2.5; }
|
.graph-klickbar:hover .graph-punkt { stroke-width: 2.5; }
|
||||||
.graph-klickbar:hover .graph-beschriftung { fill: var(--akzent-text); }
|
.graph-klickbar:hover .graph-beschriftung { fill: var(--akzent-text); }
|
||||||
|
|
||||||
|
/* Hervorhebung beim Überfahren: der Knoten, seine Nachbarn und die
|
||||||
|
* verbindenden Kanten bleiben kräftig, der Rest tritt zurück. */
|
||||||
|
.graph-fokus .graph-knoten-gruppe:not(.graph-nachbar) { opacity: 0.2; }
|
||||||
|
.graph-fokus .graph-kante:not(.graph-kante-aktiv) { opacity: 0.1; }
|
||||||
|
.graph-kante-aktiv { stroke: var(--akzent); }
|
||||||
|
|
||||||
|
/* Seite "Alle Schlagworte": mehrspaltige Liste, die Spaltenzahl ergibt sich
|
||||||
|
* aus der verfügbaren Breite. */
|
||||||
|
.schlagwort-liste {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--s5);
|
||||||
|
padding: 0;
|
||||||
|
columns: 14rem;
|
||||||
|
column-gap: var(--s5);
|
||||||
|
}
|
||||||
|
.schlagwort-liste li { break-inside: avoid; }
|
||||||
|
.schlagwort-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);
|
||||||
|
}
|
||||||
|
.schlagwort-liste a:hover { background: var(--flaeche-leis); text-decoration: none; }
|
||||||
|
.schlagwort-gruppe h2 {
|
||||||
|
font-size: var(--t-gross);
|
||||||
|
color: var(--text-leise);
|
||||||
|
border-bottom: 1px solid var(--rahmen);
|
||||||
|
padding-bottom: var(--s1);
|
||||||
|
margin-bottom: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
/* Gefahrenzone: Löschvorgänge stehen abgesetzt am Seitenende, nicht zwischen
|
/* Gefahrenzone: Löschvorgänge stehen abgesetzt am Seitenende, nicht zwischen
|
||||||
* den harmlosen Aktionen. Vorher steckte das Löschen eines Projekts samt
|
* 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
|
* Bestätigungsfeld in einer Tabellenzelle — zu eng und viel zu beiläufig für
|
||||||
|
|
|
||||||
67
static/tagfilter.js
Normal file
67
static/tagfilter.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Filtert die Liste auf der Seite "Alle Schlagworte" (templates/tags.html)
|
||||||
|
// schon beim Tippen, ohne die Seite neu zu laden.
|
||||||
|
//
|
||||||
|
// Gesucht wird nach Teilwörtern ("deploy" findet auch "k8s-deployment"),
|
||||||
|
// Groß-/Kleinschreibung und Umlaute sind egal. Buchstabengruppen, in denen
|
||||||
|
// nichts mehr übrig bleibt, verschwinden mit.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var feld = document.querySelector("[data-tag-filter]");
|
||||||
|
if (!feld) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var eintraege = document.querySelectorAll("[data-tag-liste] [data-tag]");
|
||||||
|
var gruppen = document.querySelectorAll("[data-tag-gruppe]");
|
||||||
|
var leer = document.querySelector("[data-tag-leer]");
|
||||||
|
|
||||||
|
// vereinfache bringt Suchbegriff und Schlagwort in dieselbe Form:
|
||||||
|
// klein geschrieben und ohne Umlaute, damit "uber" auch "übersicht" findet.
|
||||||
|
function vereinfache(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "a")
|
||||||
|
.replace(/ö/g, "o")
|
||||||
|
.replace(/ü/g, "u")
|
||||||
|
.replace(/ß/g, "ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtere() {
|
||||||
|
var begriff = vereinfache(feld.value.trim());
|
||||||
|
var sichtbar = 0;
|
||||||
|
|
||||||
|
eintraege.forEach(function (li) {
|
||||||
|
var passt = vereinfache(li.getAttribute("data-tag")).indexOf(begriff) !== -1;
|
||||||
|
li.hidden = !passt;
|
||||||
|
if (passt) {
|
||||||
|
sichtbar++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gruppen.forEach(function (gruppe) {
|
||||||
|
gruppe.hidden = !gruppe.querySelector("[data-tag]:not([hidden])");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (leer) {
|
||||||
|
leer.hidden = sichtbar > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feld.addEventListener("input", filtere);
|
||||||
|
|
||||||
|
// Enter bei genau einem Treffer öffnet ihn direkt — spart den Griff zur Maus.
|
||||||
|
feld.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key !== "Enter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var treffer = document.querySelectorAll("[data-tag-liste] [data-tag]:not([hidden]) a");
|
||||||
|
if (treffer.length === 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = treffer[0].href;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Browser stellt beim Zurück-Navigieren den Feldinhalt wieder her —
|
||||||
|
// die Liste muss dann dazu passen.
|
||||||
|
filtere();
|
||||||
|
})();
|
||||||
|
|
@ -7,9 +7,17 @@
|
||||||
{{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}}
|
{{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{if or .Session.CanWrite .Session.CanDownloadProject}}
|
||||||
|
<div class="seitenkopf-aktionen reihe">
|
||||||
|
{{/* Derselbe Platz wie "Alle als ZIP herunterladen" unter Projekte.
|
||||||
|
download: Der Browser speichert das ZIP, statt die Seite zu wechseln.
|
||||||
|
Wer das darf, steht in Session.CanDownloadProject. */}}
|
||||||
|
{{if .Session.CanDownloadProject}}
|
||||||
|
<a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/export.zip" download>Als ZIP herunterladen</a>
|
||||||
|
{{end}}
|
||||||
{{if .Session.CanWrite}}
|
{{if .Session.CanWrite}}
|
||||||
<div class="seitenkopf-aktionen">
|
|
||||||
<a class="knopf" href="/p/{{.Project.Slug}}/new">Neuer Eintrag</a>
|
<a class="knopf" href="/p/{{.Project.Slug}}/new">Neuer Eintrag</a>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
<span class="hilfe">Verweise zwischen den Einträgen dieses Projekts. Gestrichelte Knoten sind
|
<span class="hilfe">Verweise zwischen den Einträgen dieses Projekts. Gestrichelte Knoten sind
|
||||||
Ziele eines <code>[[…]]</code>-Verweises, zu denen es noch keinen Eintrag gibt.</span>
|
Ziele eines <code>[[…]]</code>-Verweises, zu denen es noch keinen Eintrag gibt.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="seitenkopf-aktionen">
|
||||||
|
<button type="button" class="zweitrangig" data-graph-einpassen>Ganzen Graphen zeigen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="hilfe" data-graph-hinweis hidden></p>
|
<p class="hilfe" data-graph-hinweis hidden></p>
|
||||||
|
|
@ -15,6 +18,7 @@
|
||||||
role="img" aria-label="Verweisgraph"></svg>
|
role="img" aria-label="Verweisgraph"></svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="hilfe">Ziehen zum Verschieben, Mausrad zum Zoomen, Klick auf einen Knoten öffnet den Eintrag.</p>
|
<p class="hilfe">Ziehen zum Verschieben, Mausrad zum Zoomen — beim Hineinzoomen erscheinen weitere Namen.
|
||||||
|
Zeigt die Maus auf einen Knoten, treten er und seine Nachbarn hervor. Ein Klick öffnet den Eintrag.</p>
|
||||||
|
|
||||||
{{template "fuss" .}}
|
{{template "fuss" .}}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<picture> wählt anhand der Systemeinstellung die passende aus. */}}
|
<picture> wählt anhand der Systemeinstellung die passende aus. */}}
|
||||||
<picture class="anmelde-logo">
|
<picture class="anmelde-logo">
|
||||||
<source srcset="/static/img/munin_logo_dark.svg" media="(prefers-color-scheme: dark)">
|
<source srcset="/static/img/munin_logo_dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
<img src="/static/img/munin_logo_transparent.svg" alt="Munin" width="160" height="160">
|
<img src="/static/img/munin_logo_transparent.svg" alt="Munin" width="264" height="264">
|
||||||
</picture>
|
</picture>
|
||||||
<div class="karte">
|
<div class="karte">
|
||||||
<span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin
|
<span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
<script src="/static/preview.js"></script>
|
<script src="/static/preview.js"></script>
|
||||||
<script src="/static/autocomplete.js"></script>
|
<script src="/static/autocomplete.js"></script>
|
||||||
<script src="/static/graph.js"></script>
|
<script src="/static/graph.js"></script>
|
||||||
|
<script src="/static/tagfilter.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -44,12 +45,19 @@
|
||||||
<div class="kopfleiste-innen">
|
<div class="kopfleiste-innen">
|
||||||
<div class="reihe">
|
<div class="reihe">
|
||||||
<a class="marke-wort" href="/">Munin</a>
|
<a class="marke-wort" href="/">Munin</a>
|
||||||
{{if .Session.WorkspaceName}}
|
{{if .Session.IsPlatformAdmin}}{{if .Session.WorkspaceName}}
|
||||||
<a class="arbeitsbereich" href="/admin/workspaces">
|
<a class="arbeitsbereich" href="/admin/workspaces">
|
||||||
<span class="arbeitsbereich-trenner" aria-hidden="true">/</span>
|
<span class="arbeitsbereich-trenner" aria-hidden="true">/</span>
|
||||||
<span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span>
|
<span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span>
|
||||||
<span class="arbeitsbereich-pfeil" aria-hidden="true">⌄</span>
|
<span class="arbeitsbereich-pfeil" aria-hidden="true">⌄</span>
|
||||||
</a>
|
</a>
|
||||||
|
{{end}}{{else if .Session.IsWorkspaceAdmin}}
|
||||||
|
{{/* Der Workspace-Admin hat genau einen Workspace — der Name steht
|
||||||
|
zur Orientierung da, ohne Umschalter. */}}
|
||||||
|
<span class="arbeitsbereich">
|
||||||
|
<span class="arbeitsbereich-trenner" aria-hidden="true">/</span>
|
||||||
|
<span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span>
|
||||||
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/logout" class="reihe">
|
<form method="POST" action="/logout" class="reihe">
|
||||||
|
|
@ -66,7 +74,7 @@
|
||||||
über der Liste — sie sind Navigation, und die Liste bekommt so mehr Platz. */}}
|
über der Liste — sie sind Navigation, und die Liste bekommt so mehr Platz. */}}
|
||||||
<aside class="seitenleiste">
|
<aside class="seitenleiste">
|
||||||
|
|
||||||
{{if .Session.IsPlatformAdmin}}
|
{{if .Session.CanManageProjects}}
|
||||||
<nav class="nav-gruppe">
|
<nav class="nav-gruppe">
|
||||||
<h2 class="nav-titel">Projekte</h2>
|
<h2 class="nav-titel">Projekte</h2>
|
||||||
<ul class="nav-liste">
|
<ul class="nav-liste">
|
||||||
|
|
@ -92,6 +100,8 @@
|
||||||
<li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li>
|
<li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li>
|
||||||
{{if .Session.CanManageKeys}}
|
{{if .Session.CanManageKeys}}
|
||||||
<li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li>
|
<li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li>
|
||||||
|
{{end}}
|
||||||
|
{{if .Session.CanImport}}
|
||||||
<li><a href="/p/{{.Project.Slug}}/import" {{if eq .Bereich "import"}}class="aktiv"{{end}}>Import</a></li>
|
<li><a href="/p/{{.Project.Slug}}/import" {{if eq .Bereich "import"}}class="aktiv"{{end}}>Import</a></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
<li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li>
|
<li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li>
|
||||||
|
|
@ -116,6 +126,17 @@
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
{{/* Die Seitenleiste zeigt nur die häufigsten Schlagworte (navTagLimit
|
||||||
|
in internal/web/server.go). Gibt es mehr, führt dieser Link zur
|
||||||
|
vollständigen, filterbaren Liste. */}}
|
||||||
|
{{if gt .Nav.TagsGesamt (len .Nav.Tags)}}
|
||||||
|
<ul class="nav-liste leise">
|
||||||
|
<li><a href="/p/{{.Project.Slug}}/tags" {{if eq .Bereich "schlagworte"}}class="aktiv"{{end}}>
|
||||||
|
<span class="nav-text">Alle Schlagworte…</span>
|
||||||
|
<span class="nav-zahl">{{.Nav.TagsGesamt}}</span>
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -128,6 +149,13 @@
|
||||||
<li><a href="/admin/workspaces" {{if eq .Bereich "workspaces"}}class="aktiv"{{end}}>Workspaces</a></li>
|
<li><a href="/admin/workspaces" {{if eq .Bereich "workspaces"}}class="aktiv"{{end}}>Workspaces</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
{{else if .Session.IsWorkspaceAdmin}}
|
||||||
|
<nav class="nav-gruppe">
|
||||||
|
<h2 class="nav-titel">Workspace</h2>
|
||||||
|
<ul class="nav-liste">
|
||||||
|
<li><a href="/admin/audit">Protokoll (alle)</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* Die Hilfe steht für alle ganz unten — gleich, mit welcher Rolle
|
{{/* Die Hilfe steht für alle ganz unten — gleich, mit welcher Rolle
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@
|
||||||
<input type="text" id="slug" value="{{.Data.Item.Project.Slug}}" disabled>
|
<input type="text" id="slug" value="{{.Data.Item.Project.Slug}}" disabled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* Das Schreibbudget ändert nur der Plattform-Admin. Der Server
|
||||||
|
ignoriert das Feld für alle anderen ohnehin (handleProjectEditSave)
|
||||||
|
— hier wird es nur deshalb nicht angeboten. */}}
|
||||||
|
{{if .Session.CanEditWriteBudget}}
|
||||||
<div class="feld kurz">
|
<div class="feld kurz">
|
||||||
<label for="write_limit_hourly">Schreibbudget je Stunde</label>
|
<label for="write_limit_hourly">Schreibbudget je Stunde</label>
|
||||||
<span class="hilfe">
|
<span class="hilfe">
|
||||||
|
|
@ -41,6 +45,15 @@
|
||||||
<input type="number" id="write_limit_hourly" name="write_limit_hourly"
|
<input type="number" id="write_limit_hourly" name="write_limit_hourly"
|
||||||
min="0" step="1" value="{{.Data.Item.EigenesLimit}}">
|
min="0" step="1" value="{{.Data.Item.EigenesLimit}}">
|
||||||
</div>
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="feld kurz">
|
||||||
|
<label>Schreibbudget je Stunde</label>
|
||||||
|
<span class="hilfe">
|
||||||
|
{{if .Data.Item.Limit}}{{.Data.Item.Limit}}, im laufenden Fenster noch frei: {{.Data.Item.Frei}}.{{else}}unbegrenzt.{{end}}
|
||||||
|
Ändern kann es nur der Plattform-Admin.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="formular-aktionen">
|
<div class="formular-aktionen">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,26 @@
|
||||||
|
|
||||||
<div class="seitenkopf">
|
<div class="seitenkopf">
|
||||||
<div class="seitenkopf-titel">
|
<div class="seitenkopf-titel">
|
||||||
<h1>Projekte {{template "hilfelink" "plattform-admin#projekte"}}</h1>
|
<h1>Projekte {{if .Session.IsPlatformAdmin}}{{template "hilfelink" "plattform-admin#projekte"}}{{else}}{{template "hilfelink" "zugaenge#workspace-admin"}}{{end}}</h1>
|
||||||
<span class="hilfe">Jedes Projekt hat eigene Einträge, eigene Zugänge und einen
|
<span class="hilfe">Jedes Projekt hat eigene Einträge, eigene Zugänge und einen
|
||||||
eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span>
|
eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .ExportEnabled}}
|
<div class="seitenkopf-aktionen reihe">
|
||||||
<div class="seitenkopf-aktionen">
|
{{/* Der Download geht an jeden, der diese Seite sieht (Plattform- und
|
||||||
{{/* Der Export läuft ohnehin täglich. Der Knopf ist für den Fall, dass
|
Workspace-Admin), und braucht kein EXPORT_DIR. */}}
|
||||||
man unmittelbar vor einer Sicherung einen aktuellen Stand braucht. */}}
|
<a href="/admin/export.zip" class="knopf zweitrangig" download>Alle als ZIP herunterladen</a>
|
||||||
|
|
||||||
|
{{/* Der Export auf die Platte des Servers ist Sache des
|
||||||
|
Plattform-Admins, nicht des Workspace-Admins. Er läuft ohnehin
|
||||||
|
täglich. Der Knopf ist für den Fall, dass man unmittelbar vor einer
|
||||||
|
Sicherung einen aktuellen Stand braucht. */}}
|
||||||
|
{{if and .ExportEnabled .Session.IsPlatformAdmin}}
|
||||||
<form method="POST" action="/admin/export" class="reihe">
|
<form method="POST" action="/admin/export" class="reihe">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="zweitrangig">Jetzt exportieren</button>
|
<button type="submit" class="zweitrangig">Jetzt exportieren</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Data.Projects}}
|
{{if .Data.Projects}}
|
||||||
|
|
|
||||||
60
templates/tags.html
Normal file
60
templates/tags.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{{template "kopf" .}}
|
||||||
|
|
||||||
|
{{$slug := .Project.Slug}}
|
||||||
|
|
||||||
|
<div class="seitenkopf">
|
||||||
|
<div class="seitenkopf-titel">
|
||||||
|
<h1>Schlagworte {{template "hilfelink" "weboberflaeche#finden"}}</h1>
|
||||||
|
<span class="hilfe">Alle Schlagworte dieses Projekts. Ein Klick filtert die Eintragsliste.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Data.Tags}}
|
||||||
|
{{/* Das Filterfeld filtert ohne Neuladen (static/tagfilter.js). Ohne
|
||||||
|
JavaScript bleibt es wirkungslos, die vollständige Liste steht dann
|
||||||
|
trotzdem darunter. */}}
|
||||||
|
<div class="werkzeugleiste">
|
||||||
|
<input type="search" placeholder="Schlagworte filtern…" autocomplete="off"
|
||||||
|
aria-label="Schlagworte filtern" data-tag-filter>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hilfe">
|
||||||
|
{{len .Data.Tags}} Schlagworte · Sortiert nach
|
||||||
|
{{if eq .Data.Sortierung "name"}}
|
||||||
|
<a href="/p/{{$slug}}/tags">Häufigkeit</a> · <strong>Name</strong>
|
||||||
|
{{else}}
|
||||||
|
<strong>Häufigkeit</strong> · <a href="/p/{{$slug}}/tags?sort=name">Name</a>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div data-tag-liste>
|
||||||
|
{{if eq .Data.Sortierung "name"}}
|
||||||
|
{{range .Data.Gruppen}}
|
||||||
|
<section class="schlagwort-gruppe" data-tag-gruppe>
|
||||||
|
<h2>{{.Buchstabe}}</h2>
|
||||||
|
<ul class="schlagwort-liste">
|
||||||
|
{{range .Tags}}
|
||||||
|
<li data-tag="{{.Tag}}"><a href="/p/{{$slug}}/?tag={{.Tag}}">
|
||||||
|
<span class="nav-text">{{.Tag}}</span><span class="nav-zahl">{{.Count}}</span>
|
||||||
|
</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<ul class="schlagwort-liste">
|
||||||
|
{{range .Data.Tags}}
|
||||||
|
<li data-tag="{{.Tag}}"><a href="/p/{{$slug}}/?tag={{.Tag}}">
|
||||||
|
<span class="nav-text">{{.Tag}}</span><span class="nav-zahl">{{.Count}}</span>
|
||||||
|
</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="leer" data-tag-leer hidden><p>Kein Schlagwort passt zum Filter.</p></div>
|
||||||
|
{{else}}
|
||||||
|
<div class="leer"><p>Noch keine Schlagworte in diesem Projekt.</p></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "fuss" .}}
|
||||||
|
|
@ -26,10 +26,95 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* Workspace-Admins (AUSBAUSTUFEN.md 4.8): verwalten in diesem Workspace
|
||||||
|
Projekte und Zugänge, nur über die Weboberfläche. Anlegen und zurückziehen
|
||||||
|
kann sie nur der Plattform-Admin — deshalb stehen sie hier und nicht auf
|
||||||
|
einer Seite, die der Workspace-Admin selbst erreicht. */}}
|
||||||
|
<section class="abschnitt">
|
||||||
|
<h2>Workspace-Admins {{template "hilfelink" "plattform-admin#workspace-admins"}}</h2>
|
||||||
|
<span class="hilfe">Ein Workspace-Admin legt in diesem Workspace Projekte an, bearbeitet und
|
||||||
|
löscht sie und verwaltet deren Zugänge. Er meldet sich mit seinem Token in der
|
||||||
|
Weboberfläche an — für MCP taugt das Token nicht. Andere Workspaces, das
|
||||||
|
Schreibbudget und der Export bleiben beim Plattform-Admin.</span>
|
||||||
|
|
||||||
|
{{if .Data.NewToken}}
|
||||||
|
<div class="karte key-anzeige">
|
||||||
|
<strong>Token für „{{.Data.NewTokenName}}“</strong>
|
||||||
|
<code class="key-wert">{{.Data.NewToken}}</code>
|
||||||
|
<span class="hilfe">Wird nur jetzt angezeigt — gespeichert ist nur eine Prüfsumme.
|
||||||
|
Geht das Token verloren, muss ein neues erzeugt werden.</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Ungenutzt}}
|
||||||
|
<div class="meldung fehler">
|
||||||
|
{{if eq .Data.Ungenutzt 1}}Ein aktiver Workspace-Admin wurde{{else}}{{.Data.Ungenutzt}} aktive Workspace-Admins wurden{{end}}
|
||||||
|
seit über 90 Tagen nicht benutzt. Jeder davon ist ein offener Zugang zum ganzen
|
||||||
|
Workspace. Ist die Person nicht mehr zuständig, am besten zurückziehen.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Admins}}
|
||||||
|
<div class="tabelle-huelle">
|
||||||
|
<table class="tabelle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Angelegt</th>
|
||||||
|
<th>Zuletzt benutzt</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="schmal rechts"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{$csrf := .CSRFToken}}{{$id := .Data.Workspace.ID}}{{$jetzt := .Data.Jetzt}}
|
||||||
|
{{range .Data.Admins}}
|
||||||
|
<tr {{if .Revoked}}class="gedaempft"{{end}}>
|
||||||
|
<td>{{.Name}}</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}}
|
||||||
|
{{if .Unused $jetzt}}<span class="marke warnung" title="Seit über 90 Tagen nicht benutzt">ungenutzt</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="schmal rechts">
|
||||||
|
{{if not .Revoked}}
|
||||||
|
<form method="POST" action="/admin/workspaces/{{$id}}/admins/revoke" class="reihe"
|
||||||
|
data-confirm="Zugang „{{.Name}}“ zurückziehen? Eine laufende Sitzung endet sofort.">
|
||||||
|
<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>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="karte">
|
||||||
|
<form method="POST" action="/admin/workspaces/{{.Data.Workspace.ID}}/admins/create" class="formular">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
|
<div class="feld mittel">
|
||||||
|
<label for="admin-name">Name</label>
|
||||||
|
<span class="hilfe">Wer den Zugang bekommt — so steht es später im Protokoll.</span>
|
||||||
|
<input type="text" id="admin-name" name="name" required placeholder="z.B. Erika Muster">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formular-aktionen">
|
||||||
|
<button type="submit">Workspace-Admin anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="abschnitt">
|
<section class="abschnitt">
|
||||||
<h2>Gefahrenzone</h2>
|
<h2>Gefahrenzone</h2>
|
||||||
<span class="hilfe">Das Löschen entfernt alle {{.Data.Projects}} Projekte dieses Workspace samt
|
<span class="hilfe">Das Löschen entfernt alle {{.Data.Projects}} Projekte dieses Workspace samt
|
||||||
ihrer Einträge und Zugänge unwiederbringlich — die Workspace-Datei wird von der Platte
|
ihrer Einträge, Zugänge und Workspace-Admins unwiederbringlich — die Workspace-Datei wird von der Platte
|
||||||
entfernt. Der Markdown-Export bleibt erhalten, sofern einer eingerichtet ist.</span>
|
entfernt. Der Markdown-Export bleibt erhalten, sofern einer eingerichtet ist.</span>
|
||||||
|
|
||||||
<div class="karte gefahrenzone">
|
<div class="karte gefahrenzone">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>Workspace</th>
|
<th>Workspace</th>
|
||||||
<th class="zahl">Projekte</th>
|
<th class="zahl">Projekte</th>
|
||||||
|
<th class="zahl">Workspace-Admins</th>
|
||||||
<th>Angelegt</th>
|
<th>Angelegt</th>
|
||||||
<th class="schmal rechts">Auswahl</th>
|
<th class="schmal rechts">Auswahl</th>
|
||||||
<th class="schmal rechts">Verwaltung</th>
|
<th class="schmal rechts">Verwaltung</th>
|
||||||
|
|
@ -29,6 +30,16 @@
|
||||||
{{if eq .ID $aktuell}}<span class="marke betont">Aktuell</span>{{end}}
|
{{if eq .ID $aktuell}}<span class="marke betont">Aktuell</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="zahl">{{.Projects}}</td>
|
<td class="zahl">{{.Projects}}</td>
|
||||||
|
{{/* Aktive Workspace-Admins. "ungenutzt" ist das Signal, auf das
|
||||||
|
es hier ankommt: ein offener Zugang zum ganzen Workspace,
|
||||||
|
den seit über 90 Tagen niemand benutzt. */}}
|
||||||
|
<td class="zahl">
|
||||||
|
{{if .Admins}}{{.Admins}}{{else}}<span class="hilfe">keine</span>{{end}}
|
||||||
|
{{if .AdminsUngenutzt}}
|
||||||
|
<div><a href="/admin/workspaces/{{.ID}}/edit" class="marke warnung"
|
||||||
|
title="Seit über 90 Tagen nicht benutzt">{{.AdminsUngenutzt}} ungenutzt</a></div>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
<td class="hilfe">{{.CreatedAt}}</td>
|
<td class="hilfe">{{.CreatedAt}}</td>
|
||||||
<td class="schmal rechts">
|
<td class="schmal rechts">
|
||||||
{{if eq .ID $aktuell}}
|
{{if eq .ID $aktuell}}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue