initial commit
This commit is contained in:
commit
d86235564b
34 changed files with 5173 additions and 0 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Was Docker beim Build NICHT in den Build-Kontext kopieren soll.
|
||||
# Weniger Kontext = schnellerer Build.
|
||||
|
||||
# Docs kommen per Volume-Mount, nicht ins Image
|
||||
data/
|
||||
|
||||
# Das Binary falls es lokal gebaut wurde
|
||||
wiki
|
||||
|
||||
# Git-Metadaten braucht Docker nicht
|
||||
.git
|
||||
|
||||
# Die README ist kein Laufzeit-Artefakt
|
||||
README.md
|
||||
34
.env.example
Normal file
34
.env.example
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Wiki Konfiguration — Vorlage
|
||||
# Kopiere diese Datei nach .env und trage deine Werte ein:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Wichtig: .env enthält Secrets und ist in .gitignore ausgeschlossen.
|
||||
# Niemals .env in Git committen!
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Wurzelverzeichnis für alle Teams. Jedes Team bekommt automatisch einen Unterordner
|
||||
# <DATA_ROOT>/<team-id>/content für seine Markdown-Dateien (siehe teams.yaml.example).
|
||||
DATA_ROOT=data
|
||||
|
||||
# Pfad zur Team-Konfiguration (siehe teams.yaml.example)
|
||||
TEAMS_CONFIG=teams.yaml
|
||||
|
||||
# Port auf dem der Server lauscht
|
||||
PORT=8080
|
||||
|
||||
# ── Tokens & Secrets ──────────────────────────────────────────────────────────
|
||||
|
||||
# Globaler Admin-Zugang: sieht/verwaltet ALLE Teams (Web über Team-Umschaltung,
|
||||
# REST-API und MCP mit explizitem "team"-Parameter/-Header).
|
||||
ADMIN_TOKEN=hier-sicheren-token-eintragen
|
||||
|
||||
# Signiert die Web-Session-Cookies nach dem Team-Login (HMAC) — Pflichtfeld,
|
||||
# ohne SESSION_SECRET startet der Server nicht. Mindestens 32 zufällige Zeichen,
|
||||
# z.B. erzeugt mit: openssl rand -hex 32
|
||||
SESSION_SECRET=hier-zufaelligen-string-eintragen
|
||||
|
||||
# Secret für den Git-Webhook (/api/webhook)
|
||||
# Muss identisch sein mit dem Secret das du bei GitHub/GitLab/Bitbucket einträgst.
|
||||
# Leer lassen oder weglassen um den Webhook zu deaktivieren.
|
||||
WEBHOOK_SECRET=hier-sicheres-secret-eintragen
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Kompiliertes Binary (wird von "go build" erzeugt)
|
||||
wiki
|
||||
|
||||
# Lokale Docs — gehören nicht ins Repository, werden per deploy.sh hochgeladen
|
||||
data/
|
||||
|
||||
# Umgebungsvariablen und Secrets — NIEMALS committen
|
||||
.env
|
||||
teams.yaml
|
||||
|
||||
# macOS-Systemdateien
|
||||
.DS_Store
|
||||
|
||||
# custom
|
||||
.claude
|
||||
.opencode
|
||||
.cline
|
||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# ── Stage 1: Builder ──────────────────────────────────────────────────────────
|
||||
# Wir starten mit dem offiziellen Go-Image — es hat den Compiler und alle Tools.
|
||||
# Dieses Image ist groß (~800MB), aber es landet NICHT im finalen Container.
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
# Arbeitsverzeichnis im Container setzen (wie "cd /app")
|
||||
WORKDIR /app
|
||||
|
||||
# Zuerst nur die Dependency-Dateien kopieren und installieren.
|
||||
# Warum zuerst nur diese? Docker cached jeden Schritt. Wenn sich nur der Code
|
||||
# ändert aber nicht go.mod/go.sum, muss Docker die Dependencies nicht neu laden.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Jetzt den restlichen Code kopieren
|
||||
COPY . .
|
||||
|
||||
# Das Binary kompilieren.
|
||||
# CGO_ENABLED=0 → kein C-Code, reines Go (nötig für Alpine Linux)
|
||||
# GOOS=linux → für Linux kompilieren (auch wenn wir auf Mac entwickeln)
|
||||
# -o wiki → Name des Output-Binaries
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o wiki .
|
||||
|
||||
|
||||
# ── Stage 2: Runner ───────────────────────────────────────────────────────────
|
||||
# Alpine ist ein minimales Linux — nur ~5MB. Kein Go-Compiler, keine Tools,
|
||||
# nur das nötigste Betriebssystem.
|
||||
FROM alpine:3.21
|
||||
|
||||
# git wird zur Laufzeit gebraucht — der Webhook-Handler führt "git pull" aus.
|
||||
# apk ist der Paketmanager von Alpine (wie apt bei Ubuntu).
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Das fertige Binary aus dem Builder-Stage rüberkopieren.
|
||||
# Sonst nichts — kein Go, keine Source-Files, keine Dependencies.
|
||||
COPY --from=builder /app/wiki .
|
||||
|
||||
# templates/ und static/ sind ins Binary eingebettet (//go:embed in main.go)
|
||||
# und müssen deshalb NICHT separat kopiert werden — das Binary ist selbst-contained.
|
||||
|
||||
# Der docs-Ordner wird NICHT ins Image gepackt — er kommt als Volume-Mount.
|
||||
# Das bedeutet: neue Wiki-Seiten deployen = Datei hochladen, kein Rebuild nötig.
|
||||
|
||||
# Port dokumentieren (macht den Port nicht automatisch erreichbar, ist nur Metadaten)
|
||||
EXPOSE 8080
|
||||
|
||||
# Umgebungsvariablen mit Standardwerten.
|
||||
# Diese können beim "docker run" mit -e überschrieben werden.
|
||||
ENV DOCS_ROOT=/data/docs
|
||||
ENV PORT=8080
|
||||
# ADMIN_TOKEN wird beim Start gesetzt und hat keinen Standardwert — Pflichtfeld!
|
||||
|
||||
# Den Server starten wenn der Container läuft
|
||||
CMD ["./wiki"]
|
||||
530
README.md
Normal file
530
README.md
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
# Wiki
|
||||
|
||||
Ein simples, datei-zentriertes Wiki. Markdown-Dateien sind die Datenbank — kein CMS, keine Datenbank, kein Build-Schritt.
|
||||
|
||||
---
|
||||
|
||||
## Lokal starten
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
Der Server läuft dann auf [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
Vor dem ersten Start brauchst du eine `teams.yaml` (siehe [Teams](#teams--zugriffskeys) unten) und ein `SESSION_SECRET`:
|
||||
|
||||
```bash
|
||||
cp teams.yaml.example teams.yaml
|
||||
# teams.yaml mit deinen Teams befüllen
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
Standardwerte lokal:
|
||||
- **Daten-Ordner:** `data/` (jedes Team unter `data/<team-id>/content/`)
|
||||
- **Admin-Token:** `dev-token`
|
||||
- **Port:** `8080`
|
||||
|
||||
---
|
||||
|
||||
## Teams & Zugriffskeys
|
||||
|
||||
Das Wiki ist Multi-Tenant: jedes Team hat einen eigenen Zugriffskey und sieht nur
|
||||
seine eigenen Markdown-Dateien — im Web, über die REST-API/`deploy.sh` und über MCP.
|
||||
Ein globaler Admin-Token sieht alle Teams.
|
||||
|
||||
### Teams anlegen
|
||||
|
||||
Teams werden in `teams.yaml` konfiguriert (Vorlage: `teams.yaml.example`, niemals
|
||||
committen — steht in `.gitignore`):
|
||||
|
||||
```yaml
|
||||
teams:
|
||||
- id: alpha
|
||||
name: "Team Alpha"
|
||||
key: "hier-sicheren-key-eintragen"
|
||||
- id: beta
|
||||
name: "Team Beta"
|
||||
key: "hier-sicheren-key-eintragen"
|
||||
```
|
||||
|
||||
`id` darf nur aus `a-z`, `0-9`, `-` und `_` bestehen (wird 1:1 als Ordnername
|
||||
verwendet) und ist reserviert für `admin`. Für jedes Team wird beim Serverstart
|
||||
automatisch `data/<id>/content/` angelegt — dort landen die Markdown-Dateien
|
||||
dieses Teams.
|
||||
|
||||
Änderungen an `teams.yaml` (neuer Key, neues Team, umbenanntes Team, entferntes
|
||||
Team) werden alle paar Sekunden automatisch übernommen — **kein Neustart nötig**.
|
||||
Ein entferntes Team verschwindet nur aus der Registry, seine Dateien auf der
|
||||
Platte bleiben unangetastet.
|
||||
|
||||
### Web-Zugriff
|
||||
|
||||
Beim Aufruf des Wikis im Browser wird zuerst der Zugriffskey abgefragt (`/login`).
|
||||
Nach erfolgreichem Login sieht der Besucher nur die Seiten seines Teams — Navigation,
|
||||
Suche und alles andere sind vollständig getrennt von anderen Teams. Der globale
|
||||
`ADMIN_TOKEN` funktioniert ebenfalls als Login, zeigt danach aber zunächst eine
|
||||
Team-Übersicht (`/switch-team`) zum Umschalten.
|
||||
|
||||
### REST-API / `deploy.sh`
|
||||
|
||||
Der Bearer-Key im `Authorization`-Header bestimmt automatisch das Ziel-Team — ein
|
||||
Team-Key kann nur in seinem eigenen Ordner schreiben/löschen. Mit dem `ADMIN_TOKEN`
|
||||
muss zusätzlich das Zielteam über den Parameter `team` angegeben werden (Formfeld
|
||||
bei Upload, Query-Parameter bei Delete). Details siehe Abschnitt
|
||||
[Deployment](#3-seite-auf-den-server-deployen).
|
||||
|
||||
### MCP
|
||||
|
||||
Genau wie bei der REST-API bestimmt der mitgeschickte Key das Team. Beim
|
||||
`ADMIN_TOKEN` ist zusätzlich der Header `X-Wiki-Team` erforderlich.
|
||||
|
||||
### Audit-Log
|
||||
|
||||
Upload, Delete, Login (Erfolg/Fehlschlag) und Webhook-Sync werden als JSON Lines
|
||||
in `<DATA_ROOT>/audit.log` protokolliert — eine Zeile pro Ereignis, mit Team,
|
||||
Aktion, Pfad, Quelle (`web`/`api`/`mcp`/`webhook`) und Zeitstempel. Direkt mit
|
||||
`tail -f`/`jq` auswertbar, oder im Browser unter `/admin/audit` (nur mit
|
||||
`ADMIN_TOKEN` erreichbar).
|
||||
|
||||
Da Zugriffskeys pro Team und nicht pro Person vergeben werden, zeigt das Log
|
||||
"Team Alpha hat X gelöscht", aber nicht welche Person im Team das war.
|
||||
|
||||
---
|
||||
|
||||
## Binary bauen (ohne Docker)
|
||||
|
||||
Go kann ein einzelnes, vollständig selbst-enthaltenes Binary für jedes Betriebssystem bauen — von jedem Rechner aus, ohne dass das Zielsystem Go installiert haben muss.
|
||||
|
||||
Templates (`templates/`) und CSS (`static/`) sind per `//go:embed` direkt ins Binary eingebettet. Das Binary ist komplett eigenständig — es braucht keine externen Dateien.
|
||||
|
||||
### Für das aktuelle System (schnellster Weg)
|
||||
|
||||
```bash
|
||||
go build -o wiki .
|
||||
./wiki
|
||||
```
|
||||
|
||||
### Für andere Systeme (Cross-Compilation)
|
||||
|
||||
Go verwendet zwei Umgebungsvariablen um das Zielsystem festzulegen:
|
||||
- `GOOS` — Betriebssystem (`linux`, `darwin`, `windows`)
|
||||
- `GOARCH` — Prozessorarchitektur (`amd64`, `arm64`)
|
||||
|
||||
```bash
|
||||
# Linux — 64-bit Intel/AMD (z.B. eigener Server, VPS)
|
||||
GOOS=linux GOARCH=amd64 go build -o wiki-linux .
|
||||
|
||||
# Linux — ARM (z.B. Raspberry Pi)
|
||||
GOOS=linux GOARCH=arm64 go build -o wiki-linux-arm64 .
|
||||
|
||||
# Windows — 64-bit
|
||||
GOOS=windows GOARCH=amd64 go build -o wiki-windows.exe .
|
||||
|
||||
# macOS — Apple Silicon (M1/M2/M3)
|
||||
GOOS=darwin GOARCH=arm64 go build -o wiki-macos-arm64 .
|
||||
|
||||
# macOS — Intel
|
||||
GOOS=darwin GOARCH=amd64 go build -o wiki-macos-amd64 .
|
||||
```
|
||||
|
||||
### Binary betreiben
|
||||
|
||||
Das Binary braucht keine externen Dateien außer `teams.yaml`. Der Daten-Ordner
|
||||
wird beim ersten Start automatisch angelegt falls er nicht existiert.
|
||||
|
||||
```
|
||||
irgendwo/
|
||||
wiki ← das Binary — mehr braucht es nicht
|
||||
teams.yaml ← Team-Konfiguration (siehe teams.yaml.example)
|
||||
.env ← optional
|
||||
```
|
||||
|
||||
Starten:
|
||||
|
||||
```bash
|
||||
# Mit .env-Datei
|
||||
./wiki
|
||||
|
||||
# Oder mit Umgebungsvariablen direkt
|
||||
ADMIN_TOKEN=mein-token SESSION_SECRET=$(openssl rand -hex 32) ./wiki
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Neue Seite anlegen
|
||||
|
||||
Markdown-Datei mit Frontmatter erstellen:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Meine Seite
|
||||
tags: [beispiel]
|
||||
---
|
||||
|
||||
# Überschrift
|
||||
|
||||
Inhalt hier...
|
||||
```
|
||||
|
||||
**Dateiname bestimmt die URL:**
|
||||
|
||||
| Datei | URL (innerhalb des jeweiligen Teams) |
|
||||
|---|---|
|
||||
| `data/<team>/content/index.md` | `/` |
|
||||
| `data/<team>/content/setup.md` | `/setup` |
|
||||
| `data/<team>/content/projekte/notizen.md` | `/projekte/notizen` |
|
||||
|
||||
Unterordner einfach anlegen — die Navigation baut sich automatisch als aufklappbare Baumstruktur daraus auf.
|
||||
|
||||
### Alle Frontmatter-Felder
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
|---|---|---|
|
||||
| `title` | String | Anzeigename in Navigation und Browser-Tab |
|
||||
| `tags` | Liste | Schlagwörter, z.B. `[projekt, intern]` |
|
||||
| `password` | String | Seite mit Passwort schützen |
|
||||
| `hidden` | Boolean | Seite aus Navigation ausblenden (URL bleibt erreichbar) |
|
||||
|
||||
### Seite mit Passwortschutz
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Interne Notizen
|
||||
password: mein-passwort
|
||||
---
|
||||
|
||||
Nur für mich.
|
||||
```
|
||||
|
||||
Besucher sehen ein Passwort-Formular. Nach erfolgreicher Eingabe wird ein Cookie gesetzt — beim nächsten Besuch direkt zugänglich.
|
||||
|
||||
### Seite aus Navigation ausblenden
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Versteckte Seite
|
||||
hidden: true
|
||||
---
|
||||
|
||||
Nicht in der Sidebar, aber über die URL erreichbar.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Docker)
|
||||
|
||||
### 1. Image bauen
|
||||
|
||||
```bash
|
||||
docker build -t wiki .
|
||||
```
|
||||
|
||||
### 2. Container starten
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name wiki \
|
||||
-p 80:8080 \
|
||||
-e ADMIN_TOKEN=dein-geheimer-token \
|
||||
-e SESSION_SECRET=dein-signier-secret \
|
||||
-v /data:/data \
|
||||
-v /pfad/zu/teams.yaml:/teams.yaml \
|
||||
wiki
|
||||
```
|
||||
|
||||
| Option | Bedeutung |
|
||||
|---|---|
|
||||
| `-p 80:8080` | Port 80 auf dem Host → Port 8080 im Container |
|
||||
| `-e ADMIN_TOKEN=...` | Globaler Admin-Zugang (sieht alle Teams) |
|
||||
| `-e SESSION_SECRET=...` | Signiert die Web-Session-Cookies — Pflichtfeld |
|
||||
| `-v /data:/data` | Daten-Ordner (ein Unterordner pro Team) auf dem Host einbinden |
|
||||
| `-v .../teams.yaml:/teams.yaml` | Team-Konfiguration einbinden |
|
||||
|
||||
Der Ordner `/data` muss auf dem Server existieren:
|
||||
|
||||
```bash
|
||||
mkdir -p /data
|
||||
```
|
||||
|
||||
### 3. Seite auf den Server deployen
|
||||
|
||||
```bash
|
||||
# Team-Key (oder Admin-Token, siehe unten) und Server-URL setzen
|
||||
export WIKI_TOKEN=team-zugriffskey
|
||||
export WIKI_SERVER=https://wiki.example.com
|
||||
|
||||
# Datei hochladen (Pfad ist relativ zum Team-Ordner)
|
||||
./deploy.sh lokale-datei.md /ziel/pfad.md
|
||||
```
|
||||
|
||||
Beispiele:
|
||||
|
||||
```bash
|
||||
./deploy.sh index.md /index.md
|
||||
./deploy.sh notizen.md /projekte/notizen.md
|
||||
```
|
||||
|
||||
Mit dem globalen Admin-Token statt einem Team-Key muss zusätzlich `WIKI_TEAM`
|
||||
gesetzt sein:
|
||||
|
||||
```bash
|
||||
WIKI_TOKEN=dein-admin-token WIKI_TEAM=alpha ./deploy.sh notizen.md /projekte/notizen.md
|
||||
```
|
||||
|
||||
Die Seite ist sofort erreichbar — kein Neustart nötig.
|
||||
|
||||
### Seite löschen
|
||||
|
||||
```bash
|
||||
# Mit Team-Key:
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer team-zugriffskey" \
|
||||
"https://wiki.example.com/api/delete?path=/projekte/notizen.md"
|
||||
|
||||
# Mit Admin-Token (Team explizit angeben):
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer dein-admin-token" \
|
||||
"https://wiki.example.com/api/delete?path=/projekte/notizen.md&team=alpha"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MCP-Integration (KI-Tools)
|
||||
|
||||
Das Wiki stellt einen [MCP](https://modelcontextprotocol.io)-Endpoint bereit. KI-Tools wie **Cline**, **Opencode** oder **Claude Desktop** können darüber direkt auf das Wiki zugreifen — Seiten lesen, suchen, erstellen und löschen.
|
||||
|
||||
### Verbindung einrichten
|
||||
|
||||
Lokal: `http://localhost:8080/mcp` — auf dem Server: `https://wiki.example.com/mcp`
|
||||
|
||||
Der `headers`-Block sorgt dafür dass der Key bei jeder Verbindung automatisch mitgeschickt wird — kein manuelles Eingeben nötig. Der Key bestimmt automatisch das Team — normalerweise reicht `Authorization` allein:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wiki": {
|
||||
"url": "http://localhost:8080/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer team-zugriffskey"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Genau dieses JSON funktioniert unverändert für **Cline** (`.vscode/mcp.json`),
|
||||
**Opencode** (`~/.config/opencode/config.json`, dort unter `"mcp"` statt
|
||||
`"mcpServers"` und mit zusätzlichem `"type": "remote"`) und **Claude Desktop**
|
||||
(`~/Library/Application Support/Claude/claude_desktop_config.json`).
|
||||
|
||||
Mit dem globalen `ADMIN_TOKEN` statt einem Team-Key muss zusätzlich der Header
|
||||
`X-Wiki-Team` gesetzt werden:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wiki": {
|
||||
"url": "http://localhost:8080/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer dein-admin-token",
|
||||
"X-Wiki-Team": "alpha"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Im Produktionsbetrieb `http://localhost:8080` durch `https://wiki.example.com` ersetzen.
|
||||
|
||||
### Resources (Daten lesen)
|
||||
|
||||
MCP-Resources sind Dokumente die das KI-Tool direkt in seinen Kontext laden kann.
|
||||
|
||||
| Resource URI | Beschreibung |
|
||||
|---|---|
|
||||
| `wiki://pages` | Alle Seiten als JSON (Titel, URL, Tags) |
|
||||
| `wiki://page/{+pfad}` | Eine Seite als Markdown, z.B. `wiki://page/projekte/notizen` |
|
||||
|
||||
### Tools (Aktionen ausführen)
|
||||
|
||||
| Tool | Beschreibung |
|
||||
|---|---|
|
||||
| `search_pages` | Volltextsuche mit BM25-Ranking — Titel-Treffer zuerst, dann Body |
|
||||
| `upload_page` | Neue Seite erstellen oder bestehende überschreiben — aktualisiert `_index.md` automatisch |
|
||||
| `delete_page` | Eine Seite löschen |
|
||||
| `rebuild_index` | `_index.md` manuell neu generieren — nach Bulk-Uploads sinnvoll |
|
||||
|
||||
### Wiki-Index für KI-Navigation
|
||||
|
||||
Nach dem ersten `upload_page`-Aufruf wird automatisch eine `_index.md` erzeugt — eine nach Tags gegliederte Übersicht aller Seiten. Das KI-Tool kann diese Resource lesen um sich schnell zu orientieren, bevor es gezielt mit `search_pages` sucht:
|
||||
|
||||
```
|
||||
"Lies wiki://page/_index um einen Überblick zu bekommen, dann such nach 'nginx'"
|
||||
```
|
||||
|
||||
### Beispiel-Prompts
|
||||
|
||||
```
|
||||
"Was steht in meinem Wiki über Projekt X?"
|
||||
"Suche nach allen Seiten die das Wort 'Docker' enthalten."
|
||||
"Erstelle eine neue Wiki-Seite /projekte/ideen.md mit einer Liste meiner Ideen."
|
||||
"Fasse alle Seiten im Ordner /projekte zusammen."
|
||||
"Aktualisiere die Seite /setup mit den neuen Docker-Befehlen."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git-Synchronisation (Webhook)
|
||||
|
||||
Mehrere Nutzer können Wiki-Seiten über ein gemeinsames Git-Repository bearbeiten. Bei jedem Push wird der Server automatisch per Webhook benachrichtigt und zieht die Änderungen rein.
|
||||
|
||||
### 1. Daten-Ordner als Git-Repository einrichten
|
||||
|
||||
Alle Teams liegen in einem gemeinsamen Repository (ein Ordner pro Team, siehe
|
||||
[Teams & Zugriffskeys](#teams--zugriffskeys)). Einmalig auf dem Server:
|
||||
|
||||
```bash
|
||||
cd /data
|
||||
git init
|
||||
git remote add origin https://github.com/dein-user/wiki-docs.git
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
### 2. WEBHOOK_SECRET setzen
|
||||
|
||||
Das Secret muss identisch sein mit dem was du beim Git-Anbieter einträgst:
|
||||
|
||||
```bash
|
||||
# In .env (lokal) oder als Docker-Umgebungsvariable
|
||||
WEBHOOK_SECRET=dein-geheimes-webhook-secret
|
||||
```
|
||||
|
||||
### 3. Webhook beim Git-Anbieter einrichten
|
||||
|
||||
**GitHub** — Repository → Settings → Webhooks → Add webhook:
|
||||
|
||||
```
|
||||
Payload URL: https://wiki.example.com/api/webhook
|
||||
Content type: application/json
|
||||
Secret: dein-geheimes-webhook-secret
|
||||
Events: Just the push event
|
||||
```
|
||||
|
||||
**GitLab** — Repository → Settings → Webhooks:
|
||||
|
||||
```
|
||||
URL: https://wiki.example.com/api/webhook
|
||||
Secret token: dein-geheimes-webhook-secret
|
||||
Trigger: Push events
|
||||
```
|
||||
|
||||
**Bitbucket** — Repository → Repository settings → Webhooks → Add webhook:
|
||||
|
||||
```
|
||||
URL: https://wiki.example.com/api/webhook
|
||||
Secret: dein-geheimes-webhook-secret
|
||||
Triggers: Repository push
|
||||
```
|
||||
|
||||
### Ablauf nach einem Push
|
||||
|
||||
```
|
||||
git push → GitHub/GitLab/Bitbucket → POST /api/webhook
|
||||
Signatur prüfen
|
||||
git pull (im Daten-Wurzelverzeichnis)
|
||||
Cache jedes Teams neu aufbauen
|
||||
Seiten sofort live
|
||||
```
|
||||
|
||||
Der Webhook erkennt den Anbieter automatisch anhand des Request-Headers — kein separater Endpoint pro Anbieter nötig.
|
||||
|
||||
---
|
||||
|
||||
## Lokal mit .env arbeiten
|
||||
|
||||
Statt Umgebungsvariablen einzeln zu setzen kannst du eine `.env`-Datei verwenden:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# .env mit deinen Werten befüllen
|
||||
```
|
||||
|
||||
```bash
|
||||
# .env
|
||||
ADMIN_TOKEN=mein-token
|
||||
SESSION_SECRET=ein-langer-zufaelliger-string
|
||||
WEBHOOK_SECRET=mein-webhook-secret
|
||||
```
|
||||
|
||||
Der Server lädt `.env` automatisch beim Start. Echte Umgebungsvariablen (z.B. aus `docker run -e`) haben immer Vorrang über `.env`.
|
||||
|
||||
> **Wichtig:** `.env` enthält Secrets und ist in `.gitignore` ausgeschlossen — niemals committen.
|
||||
|
||||
---
|
||||
|
||||
## Konfiguration (Umgebungsvariablen)
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `ADMIN_TOKEN` | `dev-token` | Globaler Admin-Zugang — sieht/verwaltet alle Teams |
|
||||
| `SESSION_SECRET` | — | Pflichtfeld. Signiert die Web-Session-Cookies (HMAC) |
|
||||
| `TEAMS_CONFIG` | `teams.yaml` | Pfad zur Team-Konfiguration |
|
||||
| `DATA_ROOT` | `data` | Wurzelverzeichnis, unter dem jedes Team seinen `<id>/content`-Ordner bekommt |
|
||||
| `WEBHOOK_SECRET` | — | Optional. Secret für den Git-Webhook — fehlt er, ist `/api/webhook` deaktiviert |
|
||||
| `PORT` | `8080` | Port auf dem der Server lauscht |
|
||||
|
||||
---
|
||||
|
||||
## Suche
|
||||
|
||||
Das Wiki hat eine eingebaute Volltextsuche auf Basis von **BM25** (der Algorithmus hinter Elasticsearch & Co.).
|
||||
|
||||
### Web-Suche
|
||||
|
||||
Das Suchfeld befindet sich in der Sidebar — jede Seite. Suche über `/search?q=suchbegriff`.
|
||||
|
||||
Ergebnisse sind nach Relevanz sortiert: Treffer im **Titel** werden höher gewichtet als Treffer im Body, Treffer in **Tags** stärker als normaler Fließtext.
|
||||
|
||||
### Wie der Index aufgebaut ist
|
||||
|
||||
Der Suchindex wird beim Serverstart einmalig aus allen `.md`-Dateien aufgebaut und komplett im Arbeitsspeicher gehalten. Bei jedem `upload_page` (MCP oder REST-API) wird der Index sofort aktualisiert — kein Neustart nötig.
|
||||
|
||||
---
|
||||
|
||||
## Skalierbarkeit
|
||||
|
||||
Der BM25-Index liegt vollständig im RAM. Die folgende Tabelle zeigt grobe Richtwerte für typische Wiki-Seiten (~500 Wörter, ~300 unique Tokens):
|
||||
|
||||
| Dokumente | RAM-Verbrauch | Startzeit |
|
||||
|---|---|---|
|
||||
| 100 | ~1–2 MB | <0,1 s |
|
||||
| 500 | ~5–10 MB | <0,5 s |
|
||||
| 1.000 | ~20–40 MB | ~1 s |
|
||||
| 5.000 | ~150–250 MB | ~5 s |
|
||||
| 10.000+ | >500 MB | >10 s |
|
||||
|
||||
**Faustregel:** Bis ~2.000 Seiten ohne Einschränkungen, bis ~5.000 noch vertretbar. Für größere Datenmengen wäre ein persistenter Index auf Disk (z.B. [Bleve](https://github.com/blevesearch/bleve)) oder ein dedizierter Suchdienst (z.B. [Meilisearch](https://www.meilisearch.com/)) der nächste Schritt.
|
||||
|
||||
**Bekannte Grenzen der aktuellen Implementierung:**
|
||||
- Kein Fuzzy-Matching — Tippfehler werden nicht korrigiert
|
||||
- Kein Disk-Persistence — Index wird bei jedem Neustart neu aufgebaut
|
||||
- Keine Pagination — maximal 20 Suchergebnisse
|
||||
|
||||
---
|
||||
|
||||
## Übersicht der HTTP-Endpoints
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---|---|---|
|
||||
| `GET` | `/:pfad` | Wiki-Seite anzeigen (Team-Session erforderlich) |
|
||||
| `GET` | `/search` | Volltextsuche (`?q=suchbegriff`, Team-Session erforderlich) |
|
||||
| `GET` | `/static/*` | CSS und statische Dateien |
|
||||
| `GET`/`POST` | `/login` | Team-Key eingeben, Session-Cookie setzen |
|
||||
| `GET`/`POST` | `/logout` | Session-Cookie löschen |
|
||||
| `GET`/`POST` | `/switch-team` | Team-Umschaltung für den Admin-Zugang |
|
||||
| `POST` | `/auth/:pfad` | Seiten-Passwort absenden, Cookie setzen |
|
||||
| `POST` | `/api/upload` | Datei hochladen (Team-Key oder Admin-Token erforderlich) |
|
||||
| `DELETE` | `/api/delete` | Datei löschen (Team-Key oder Admin-Token erforderlich) |
|
||||
| `POST` | `/mcp` | MCP Nachrichten empfangen (Team-Key oder Admin-Token erforderlich) |
|
||||
| `POST` | `/api/webhook` | Git-Webhook empfangen — GitHub, GitLab, Bitbucket (Signatur erforderlich) |
|
||||
295
REPOMAP.md
Normal file
295
REPOMAP.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Repomap
|
||||
|
||||
Übersicht aller Dateien und Ordner im Projekt.
|
||||
|
||||
```
|
||||
go-wiki/
|
||||
├── main.go ← Einstiegspunkt. Lädt .env, prüft Pflicht-Secrets
|
||||
│ (SESSION_SECRET, ADMIN_TOKEN), lädt teams.yaml und
|
||||
│ baut die TeamRegistry auf, richtet alle Routen ein
|
||||
│ und verwaltet den Graceful Shutdown (wartet auf
|
||||
│ laufende Requests bevor der Server sich bei
|
||||
│ CTRL+C / docker stop beendet).
|
||||
│
|
||||
├── go.mod ← Go-Moduldefinition (Name, Go-Version, direkte Dependencies).
|
||||
│ Entspricht in etwa einer package.json in Node.
|
||||
│
|
||||
├── go.sum ← Automatisch generierte Checksummen aller Dependencies.
|
||||
│ Nicht manuell bearbeiten.
|
||||
│
|
||||
├── Dockerfile ← Multi-Stage Build: Stage 1 kompiliert das Binary mit dem
|
||||
│ Go-Compiler, Stage 2 packt nur das fertige Binary in ein
|
||||
│ minimales Alpine-Linux-Image (mit git, für den Webhook).
|
||||
│
|
||||
├── deploy.sh ← Shell-Skript zum Hochladen einer lokalen Markdown-Datei
|
||||
│ auf den Wiki-Server via REST-API (curl + Team-Key oder
|
||||
│ Admin-Token).
|
||||
│
|
||||
├── .gitignore ← Schließt Binary, data/-Ordner, .env, teams.yaml und
|
||||
│ .DS_Store aus Git aus.
|
||||
│
|
||||
├── .dockerignore ← Schließt data/, Binary und .git aus dem Docker-Build-
|
||||
│ Kontext aus — macht den Build schneller.
|
||||
│
|
||||
├── .env.example ← Vorlage für die .env-Datei. Zeigt alle verfügbaren
|
||||
│ Umgebungsvariablen mit Erklärungen (u.a. SESSION_SECRET
|
||||
│ und ADMIN_TOKEN — beides Pflichtfelder ohne unsicheren
|
||||
│ Default). Kopieren nach .env und mit echten Werten befüllen.
|
||||
│
|
||||
├── teams.yaml.example ← Vorlage für die Team-Konfiguration. Jedes Team bekommt
|
||||
│ eine ID (a-z, 0-9, -, _), einen Anzeigenamen und einen
|
||||
│ Zugriffskey. Kopieren nach teams.yaml.
|
||||
│
|
||||
├── golang-wiki-spec.md ← Ursprüngliche Projektspezifikation (Planungsdokument).
|
||||
│
|
||||
├── README.md ← Dokumentation: Server starten, Teams anlegen, Seiten
|
||||
│ anlegen, Deployment, MCP-Integration, API-Übersicht.
|
||||
│
|
||||
├── REPOMAP.md ← Diese Datei.
|
||||
│
|
||||
├── handler/
|
||||
│ ├── registry.go ← TeamRegistry: zentrale Stelle die weiß welche Teams es
|
||||
│ │ gibt, wo ihre Markdown-Dateien liegen (data/<id>/content)
|
||||
│ │ und welcher Zugriffskey zu welchem Team gehört. Löst Keys
|
||||
│ │ timing-sicher auf (subtle.ConstantTimeCompare) und
|
||||
│ │ unterscheidet Team-Key vom globalen Admin-Token.
|
||||
│ │ Unterstützt Hot-Reload (Reload()) für teams.yaml-Änderungen
|
||||
│ │ ohne Serverneustart. Stellt außerdem die Request-Context-
|
||||
│ │ Helfer bereit (WithTeam/TeamFromContext/IsAdminFromContext).
|
||||
│ │
|
||||
│ ├── teams_config.go ← Lädt und validiert teams.yaml (LoadTeamsConfig) und
|
||||
│ │ überwacht die Datei per mtime-Polling auf Änderungen
|
||||
│ │ (StartTeamsConfigWatcher) — lädt neue/geänderte/entfernte
|
||||
│ │ Teams automatisch in die Registry.
|
||||
│ │
|
||||
│ ├── cache.go ← In-Memory-Cache für Wiki-Seiten, ein PageCache pro Team.
|
||||
│ │ Beim Serverstart werden alle .md-Dateien eines Teams
|
||||
│ │ einmal geladen und im Arbeitsspeicher gehalten — Metadaten
|
||||
│ │ (Titel, Tags, Hidden) sind danach sofort verfügbar ohne
|
||||
│ │ Dateisystem-Zugriff. Thread-sicher via sync.RWMutex.
|
||||
│ │ Hält außerdem den BM25-Suchindex des Teams aktuell.
|
||||
│ │ Wird nach jedem Upload/Delete sofort aktualisiert.
|
||||
│ │
|
||||
│ ├── search.go ← In-Memory-Volltextindex mit BM25-Ranking (invertierter
|
||||
│ │ Index: Begriff → Dokumente). Titel-Treffer werden 3x,
|
||||
│ │ Tag-Treffer 2x höher gewichtet als Body-Treffer. Genutzt
|
||||
│ │ von /search (Web) und dem search_pages-MCP-Tool.
|
||||
│ │
|
||||
│ ├── session.go ← Signierte Web-Session fürs Team-Login. SignSession/
|
||||
│ │ VerifySession erzeugen bzw. prüfen einen HMAC-SHA256-
|
||||
│ │ signierten, ablaufenden Cookie-Wert ("team:<id>", "admin"
|
||||
│ │ oder "admin:<id>") — der Server signiert nur eine
|
||||
│ │ Behauptung, nie den Zugriffskey selbst.
|
||||
│ │
|
||||
│ ├── auth.go ← Team-Login fürs Web: Formular (GET/POST /login), Logout
|
||||
│ │ (/logout) und Team-Umschaltung für Admins (/switch-team).
|
||||
│ │ Nutzt den LoginRateLimiter (ratelimit.go) gegen Brute-Force
|
||||
│ │ und schreibt Login-Versuche ins Audit-Log. clientIP()
|
||||
│ │ liest bewusst nur r.RemoteAddr, keinen X-Forwarded-For-
|
||||
│ │ Header (sonst wäre das Rate-Limiting ohne bekannte,
|
||||
│ │ vertrauenswürdige Proxy-Kette leicht auszuhebeln).
|
||||
│ │
|
||||
│ ├── ratelimit.go ← LoginRateLimiter: Fixed-Window-Zähler pro Client-IP
|
||||
│ │ (5 Fehlversuche/Minute). Wird sowohl vom Team-Login
|
||||
│ │ (auth.go) als auch vom Seiten-Passwort (wiki.go,
|
||||
│ │ HandlePasswordSubmit) genutzt, damit keiner der beiden
|
||||
│ │ Auth-Wege unbegrenzt durchprobiert werden kann.
|
||||
│ │
|
||||
│ ├── wiki.go ← Liefert Wiki-Seiten des aktiven Teams aus (GET /:pfad,
|
||||
│ │ Team kommt aus dem Request-Context). Liest Markdown-
|
||||
│ │ Datei, prüft Seiten-Passwortschutz via Cookie
|
||||
│ │ (konstantzeitiger Vergleich + Rate-Limiting), baut die
|
||||
│ │ Sidebar-Navigation rekursiv aus der Ordnerstruktur und
|
||||
│ │ rendert das HTML-Template. Verarbeitet auch
|
||||
│ │ POST /auth/:pfad (Passwort-Formular) und GET /search
|
||||
│ │ (BM25-Trefferliste im normalen Wiki-Layout).
|
||||
│ │
|
||||
│ ├── api.go ← REST-API für das Deployment. POST /api/upload lädt eine
|
||||
│ │ Markdown-Datei hoch, DELETE /api/delete löscht eine Datei.
|
||||
│ │ Team-Key wirkt nur aufs eigene Team; der globale Admin-Key
|
||||
│ │ muss das Zielteam explizit per Parameter "team" angeben.
|
||||
│ │ Prüft Pfade gegen Path-Traversal ("..") und aktualisiert
|
||||
│ │ nach jeder Änderung den PageCache sowie das Audit-Log.
|
||||
│ │
|
||||
│ ├── webhook.go ← Git-Webhook-Handler (POST /api/webhook, nur aktiv wenn
|
||||
│ │ WEBHOOK_SECRET gesetzt ist). Erkennt den Anbieter anhand
|
||||
│ │ der Request-Headers und prüft die Signatur: GitHub
|
||||
│ │ (X-Hub-Signature-256, HMAC-SHA256), GitLab
|
||||
│ │ (X-Gitlab-Token, direkter Vergleich), Bitbucket
|
||||
│ │ (X-Hub-Signature, HMAC-SHA256). Nach erfolgreicher Prüfung:
|
||||
│ │ git pull im gemeinsamen data-Wurzelverzeichnis, danach
|
||||
│ │ Cache aller Teams komplett neu aufbauen (RebuildAll).
|
||||
│ │
|
||||
│ ├── mcp.go ← MCP-Server (Model Context Protocol) über Streamable HTTP
|
||||
│ │ (POST /mcp, ein MCP-Server pro Team, lazy gebaut). Ermöglicht
|
||||
│ │ KI-Tools (Claude Desktop, Cline, Opencode) direkten Zugriff
|
||||
│ │ auf das Wiki des aufgelösten Teams.
|
||||
│ │ Resources (Daten):
|
||||
│ │ wiki://pages — alle Seiten als JSON
|
||||
│ │ wiki://page/{+path} — eine Seite als rohes Markdown
|
||||
│ │ Tools (Aktionen):
|
||||
│ │ search_pages — BM25-Volltextsuche mit Snippet
|
||||
│ │ upload_page — Seite erstellen/überschreiben
|
||||
│ │ delete_page — Seite löschen
|
||||
│ │ rebuild_index — generiert _index.md (Karpathy-Wiki-Index)
|
||||
│ │ Admin-Zugriff braucht den Header "X-Wiki-Team". Alle
|
||||
│ │ lesenden Operationen nutzen den PageCache.
|
||||
│ │
|
||||
│ ├── audit.go ← AuditLogger: schreibt sicherheits-/nachvollziehbarkeits-
|
||||
│ │ relevante Ereignisse (Upload, Delete, Login-Versuche,
|
||||
│ │ Webhook-Sync) als JSON Lines in data/audit.log — eine
|
||||
│ │ gemeinsame Datei für alle Teams. Tail(n) liest die letzten
|
||||
│ │ n Ereignisse für die Admin-Ansicht, neueste zuerst.
|
||||
│ │
|
||||
│ └── audit_page.go ← Admin-Ansicht des Audit-Logs (GET /admin/audit). Nur mit
|
||||
│ dem globalen Admin-Zugang erreichbar, zeigt die letzten
|
||||
│ 200 Ereignisse als Tabelle.
|
||||
│
|
||||
├── middleware/
|
||||
│ ├── session.go ← RequireTeamSession: schützt die Web-Ansicht ("/",
|
||||
│ │ "/search", "/auth/", "/switch-team", "/admin/audit").
|
||||
│ │ Prüft den Session-Cookie (handler.VerifySession) und legt
|
||||
│ │ das aufgelöste Team bzw. den Admin-Status im Request-
|
||||
│ │ Context ab. Ohne gültige Session: Redirect zu /login.
|
||||
│ │
|
||||
│ └── auth.go ← RequireTeamOrAdminKey: schützt /api/upload, /api/delete
|
||||
│ und /mcp. Liest den Bearer-Token aus dem Authorization-
|
||||
│ Header und löst ihn timing-sicher gegen die TeamRegistry
|
||||
│ auf (handler.TeamRegistry.Resolve) — legt das Team bzw.
|
||||
│ den Admin-Status im Request-Context ab.
|
||||
│
|
||||
├── render/
|
||||
│ └── markdown.go ← Kernlogik: Liest eine .md-Datei, trennt den YAML-
|
||||
│ Frontmatter-Block (title, password, tags, hidden) vom
|
||||
│ Markdown-Body und wandelt den Body mit goldmark (Default-
|
||||
│ Konfiguration, kein html.WithUnsafe — eingebettetes
|
||||
│ Roh-HTML wird sicher escaped) in HTML um. Gibt ein
|
||||
│ Page-Struct zurück, inkl. RawBody für die Volltextsuche.
|
||||
│
|
||||
├── templates/
|
||||
│ ├── page.html ← HTML-Template für eine Wiki-Seite. Zweispaltiges Layout
|
||||
│ │ mit Sidebar (rekursive Navigation, Team-Name, "Team
|
||||
│ │ wechseln"-Link für Admins) und Hauptinhalt bzw.
|
||||
│ │ BM25-Suchergebnissen. Kein CSS inline — lädt
|
||||
│ │ /static/style.css und /static/sidebar.js.
|
||||
│ │
|
||||
│ ├── password.html ← HTML-Template für das Seiten-Passwort-Formular. Wird
|
||||
│ │ angezeigt wenn eine Seite passwortgeschützt ist und kein
|
||||
│ │ gültiger Auth-Cookie vorhanden ist.
|
||||
│ │
|
||||
│ ├── login.html ← HTML-Template für den Team-Login (GET/POST /login) —
|
||||
│ │ fragt den Zugriffskey des Teams oder den Admin-Token ab.
|
||||
│ │
|
||||
│ ├── switch-team.html ← HTML-Template für die Team-Auswahl des Admin-Zugangs
|
||||
│ │ (GET/POST /switch-team) — listet alle Teams aus der
|
||||
│ │ Registry als Buttons.
|
||||
│ │
|
||||
│ └── audit.html ← HTML-Template für die Audit-Log-Ansicht
|
||||
│ (GET /admin/audit) — Tabelle mit Zeit, Team, Akteur,
|
||||
│ Aktion, Pfad, Quelle und Details je Ereignis.
|
||||
│
|
||||
├── static/
|
||||
│ ├── style.css ← Gesamtes CSS des Wikis an einem Ort: Layout, Sidebar,
|
||||
│ │ Navigation, Markdown-Styling, Login-/Passwort-Formulare,
|
||||
│ │ Audit-Tabelle. Wird unter /static/ ausgeliefert.
|
||||
│ │
|
||||
│ └── sidebar.js ← Steuert das Ein-/Ausklappen der Sidebar, getrennt für
|
||||
│ Desktop (Zustand in localStorage gemerkt) und Mobile
|
||||
│ (Overlay, startet nach jedem Seitenaufruf zu).
|
||||
│
|
||||
└── data/ ← Wurzelverzeichnis aller Teams (DATA_ROOT). Nicht im
|
||||
├── audit.log Git-Repository — wird als Docker-Volume eingehängt.
|
||||
├── <team-id>/
|
||||
│ └── content/ ← Markdown-Dateien dieses Teams, z.B. data/alpha/content/.
|
||||
│ └── index.md Wird beim Serverstart automatisch angelegt, pro Team
|
||||
│ per deploy.sh, REST-API, MCP oder Git-Webhook befüllt.
|
||||
└── audit.log ← JSON-Lines-Protokoll aller Uploads/Deletes/Logins/
|
||||
Webhook-Syncs (teamübergreifend, eine Datei für alle).
|
||||
```
|
||||
|
||||
## Datenfluss: Browser ruft eine Seite auf
|
||||
|
||||
```
|
||||
Browser GET /projekte/notizen
|
||||
→ middleware/session.go (RequireTeamSession) Session-Cookie prüfen, Team auflösen
|
||||
→ handler/wiki.go (ServeHTTP)
|
||||
→ render/markdown.go (LoadPage) Datei lesen, Frontmatter + HTML
|
||||
→ Seiten-Passwortprüfung via Cookie (konstantzeitig, rate-limited)
|
||||
→ buildNavForDir() (rekursiv) Ordnerstruktur → Baumnavigation
|
||||
→ templates/page.html Template rendern
|
||||
→ HTML an Browser
|
||||
```
|
||||
|
||||
## Datenfluss: Team-Login und Team-Umschaltung
|
||||
|
||||
```
|
||||
Browser POST /login { key }
|
||||
→ handler/auth.go (HandleLogin)
|
||||
→ handler/ratelimit.go (LoginRateLimiter) IP-Sperre nach 5 Fehlversuchen/Minute
|
||||
→ handler/registry.go (TeamRegistry.Resolve) Key → Team oder Admin, timing-sicher
|
||||
→ handler/audit.go (Log) login_success / login_failure
|
||||
→ handler/session.go (SignSession) signierten Cookie setzen
|
||||
→ Redirect zu "/" (Team) oder "/switch-team" (Admin)
|
||||
|
||||
Admin POST /switch-team { team }
|
||||
→ handler/auth.go (HandleSwitchTeam) nur mit Admin-Session erreichbar
|
||||
→ handler/session.go (SignSession) Cookie "admin:<team-id>" setzen
|
||||
→ Redirect zu "/"
|
||||
```
|
||||
|
||||
## Datenfluss: KI-Tool liest/schreibt Seiten via MCP
|
||||
|
||||
```
|
||||
Claude Desktop/Cline/Opencode → POST /mcp (Streamable HTTP, JSON-RPC pro Request)
|
||||
→ middleware/auth.go (RequireTeamOrAdminKey)
|
||||
Bearer-Token → Team (oder Admin + Header X-Wiki-Team)
|
||||
→ handler/mcp.go (Team-Server, lazy gebaut)
|
||||
|
||||
Resource: wiki://pages
|
||||
→ handler/cache.go (All()) aus dem Cache, kein Disk-Zugriff
|
||||
→ JSON-Liste aller Seiten des Teams zurück
|
||||
|
||||
Resource: wiki://page/projekte/notizen
|
||||
→ handler/cache.go (URLPathToFilePath)
|
||||
→ os.ReadFile() Markdown direkt von Disk
|
||||
→ Frontmatter entfernt, Titel als H1 vorangestellt
|
||||
|
||||
Tool: search_pages { query: "Docker" }
|
||||
→ handler/search.go (BM25 Search) invertierter Index, kein Disk-Zugriff
|
||||
→ Treffer mit Snippet zurück
|
||||
|
||||
Tool: upload_page / delete_page
|
||||
→ Datei auf Disk schreiben/löschen
|
||||
→ handler/cache.go (Set/Delete) Cache + Suchindex aktualisieren
|
||||
→ buildWikiIndex() _index.md automatisch neu generieren
|
||||
→ handler/audit.go (Log) Audit-Eintrag schreiben
|
||||
```
|
||||
|
||||
## Datenfluss: Git-Webhook (automatische Synchronisation)
|
||||
|
||||
```
|
||||
Nutzer git push
|
||||
→ GitHub / GitLab / Bitbucket
|
||||
→ POST /api/webhook
|
||||
→ handler/webhook.go
|
||||
→ Anbieter erkennen (Header-Analyse)
|
||||
→ Signatur prüfen (HMAC-SHA256 oder Token-Vergleich, konstantzeitig)
|
||||
→ git pull im data-Wurzelverzeichnis (alle Teams)
|
||||
→ handler/registry.go (RebuildAll) Cache + Suchindex jedes Teams neu aufbauen
|
||||
→ handler/audit.go (Log) webhook_sync protokollieren
|
||||
→ Seiten aller Teams sofort live ohne Neustart
|
||||
```
|
||||
|
||||
## Datenfluss: Datei deployen (REST oder MCP)
|
||||
|
||||
```
|
||||
deploy.sh / MCP upload_page
|
||||
→ POST /api/upload oder Tool: upload_page
|
||||
→ middleware/auth.go (Token prüfen, Team auflösen)
|
||||
→ Pfad gegen Path-Traversal prüfen ("..")
|
||||
→ Datei auf Disk schreiben
|
||||
→ render/markdown.go (LoadPage) neue Seite laden
|
||||
→ handler/cache.go (Set()) Cache + Suchindex sofort aktualisieren
|
||||
→ handler/audit.go (Log) Audit-Eintrag schreiben
|
||||
→ ab sofort in Navigation, Suche und MCP sichtbar
|
||||
```
|
||||
65
deploy.sh
Executable file
65
deploy.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/bin/bash
|
||||
|
||||
# deploy.sh — lädt eine lokale Markdown-Datei auf den Wiki-Server hoch.
|
||||
#
|
||||
# Verwendung:
|
||||
# ./deploy.sh lokale-datei.md /ziel/pfad.md
|
||||
#
|
||||
# Beispiele:
|
||||
# ./deploy.sh notes.md /projekte/notes.md
|
||||
# ./deploy.sh index.md /index.md
|
||||
#
|
||||
# WIKI_TOKEN ist normalerweise der Zugriffskey deines Teams (aus teams.yaml) —
|
||||
# der Server leitet daraus automatisch das Zielteam ab, ein Team-Parameter ist
|
||||
# dann nicht nötig.
|
||||
#
|
||||
# Nutzt du stattdessen den globalen ADMIN_TOKEN, muss zusätzlich WIKI_TEAM
|
||||
# gesetzt sein (welches Team gemeint ist), z.B.:
|
||||
# WIKI_TOKEN=<admin-token> WIKI_TEAM=alpha ./deploy.sh notes.md /projekte/notes.md
|
||||
|
||||
# Bei jedem Fehler sofort abbrechen (statt weiterzumachen mit kaputtem Zustand)
|
||||
set -e
|
||||
|
||||
# ── Konfiguration ─────────────────────────────────────────────────────────────
|
||||
# Diese Werte vor dem ersten Einsatz anpassen!
|
||||
TOKEN="${WIKI_TOKEN:-mein-geheimer-token}" # Team-Key (oder Admin-Token, siehe oben)
|
||||
TEAM="${WIKI_TEAM:-}" # Nur nötig wenn TOKEN der Admin-Token ist
|
||||
SERVER="${WIKI_SERVER:-https://wiki.example.com}"
|
||||
|
||||
# ── Argumente prüfen ──────────────────────────────────────────────────────────
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "Fehler: Zwei Argumente erwartet."
|
||||
echo ""
|
||||
echo "Verwendung: $0 <lokale-datei.md> </ziel/pfad.md>"
|
||||
echo "Beispiel: $0 notes.md /projekte/notes.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOCAL_FILE="$1" # z.B. notes.md
|
||||
REMOTE_PATH="$2" # z.B. /projekte/notes.md
|
||||
|
||||
# Prüfen ob die lokale Datei existiert
|
||||
if [ ! -f "$LOCAL_FILE" ]; then
|
||||
echo "Fehler: Datei nicht gefunden: $LOCAL_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Upload ────────────────────────────────────────────────────────────────────
|
||||
echo "Uploading $LOCAL_FILE → $REMOTE_PATH"
|
||||
|
||||
# curl sendet die Datei als Multipart-Formular an den Server.
|
||||
# -f = Fehler bei HTTP-Fehlercodes (ohne -f würde curl auch bei 401 "OK" melden)
|
||||
# -H = Header setzen (Team-Key oder Admin-Token)
|
||||
# -F = Formularfeld (Multipart)
|
||||
# file=@$LOCAL_FILE → @ bedeutet: Dateiinhalt, nicht den String
|
||||
# path=$REMOTE_PATH → Zielpfad auf dem Server (relativ zum Team-Ordner)
|
||||
# team=$TEAM → nur beim Admin-Token nötig, sonst leer und wird ignoriert
|
||||
curl -f \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@$LOCAL_FILE" \
|
||||
-F "path=$REMOTE_PATH" \
|
||||
-F "team=$TEAM" \
|
||||
"$SERVER/api/upload"
|
||||
|
||||
echo ""
|
||||
echo "Fertig!"
|
||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module wiki
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
21
go.sum
Normal file
21
go.sum
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
203
handler/api.go
Normal file
203
handler/api.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// Dieses File enthält die API-Endpoints für das Deployment:
|
||||
// POST /api/upload — Markdown-Datei hochladen
|
||||
// DELETE /api/delete — Markdown-Datei löschen
|
||||
//
|
||||
// Nach jedem Upload oder Delete wird der PageCache aktualisiert damit
|
||||
// MCP und Navigation sofort den neuen Stand sehen — ohne Serverneustart.
|
||||
//
|
||||
// Team-Zugehörigkeit: middleware.RequireTeamOrAdminKey löst den Bearer-Key vorher
|
||||
// gegen die TeamRegistry auf und legt das Team im Request-Context ab (siehe
|
||||
// registry.go). Team-Keys wirken immer nur auf ihr eigenes Team — der globale
|
||||
// Admin-Key muss das Zielteam explizit über den Parameter "team" angeben.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"wiki/render"
|
||||
)
|
||||
|
||||
// APIHandler verwaltet die Admin-API-Endpoints.
|
||||
type APIHandler struct {
|
||||
registry *TeamRegistry
|
||||
audit *AuditLogger
|
||||
}
|
||||
|
||||
// NewAPIHandler erstellt einen neuen APIHandler.
|
||||
func NewAPIHandler(registry *TeamRegistry, audit *AuditLogger) *APIHandler {
|
||||
return &APIHandler{registry: registry, audit: audit}
|
||||
}
|
||||
|
||||
// actorLabel gibt "admin" oder "team" zurück, je nachdem womit der Request
|
||||
// authentifiziert wurde — fürs Audit-Log.
|
||||
func actorLabel(r *http.Request) string {
|
||||
if IsAdminFromContext(r.Context()) {
|
||||
return "admin"
|
||||
}
|
||||
return "team"
|
||||
}
|
||||
|
||||
// resolveTeam ermittelt für den aktuellen Request das Ziel-Team.
|
||||
// - Team-Key: das Team aus dem Request-Context (kann nicht überschrieben werden).
|
||||
// - Admin-Key: das Team muss explizit über teamParam (Formfeld oder Query-Param) angegeben werden.
|
||||
func (h *APIHandler) resolveTeam(r *http.Request, teamParam string) (*Team, error) {
|
||||
if team, ok := TeamFromContext(r.Context()); ok {
|
||||
return team, nil
|
||||
}
|
||||
|
||||
if !IsAdminFromContext(r.Context()) {
|
||||
return nil, fmt.Errorf("kein Team im Request-Context")
|
||||
}
|
||||
|
||||
if teamParam == "" {
|
||||
return nil, fmt.Errorf("Parameter 'team' erforderlich für Admin-Zugriff")
|
||||
}
|
||||
|
||||
team, ok := h.registry.ByID(teamParam)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unbekanntes Team: %s", teamParam)
|
||||
}
|
||||
return team, nil
|
||||
}
|
||||
|
||||
// HandleUpload verarbeitet POST /api/upload.
|
||||
// Erwartet ein Multipart-Formular mit den Feldern:
|
||||
// - "file": die Markdown-Datei
|
||||
// - "path": Zielpfad, z.B. "/projekte/neue-seite.md" (immer relativ zum Team-Ordner)
|
||||
// - "team": nur für den Admin-Key erforderlich — welches Team ist gemeint
|
||||
func (h *APIHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
team, err := h.resolveTeam(r, r.FormValue("team"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := r.FormValue("path")
|
||||
if targetPath == "" {
|
||||
http.Error(w, "Feld 'path' fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Sicherheitscheck: Pfad darf nicht außerhalb des Team-Ordners zeigen.
|
||||
// "../../../etc/passwd" wäre z.B. ein Angriff (Path Traversal).
|
||||
if strings.Contains(targetPath, "..") {
|
||||
http.Error(w, "Ungültiger Pfad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Die hochgeladene Datei aus dem Multipart-Formular holen.
|
||||
uploadedFile, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "Feld 'file' fehlt oder ungültig", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer uploadedFile.Close()
|
||||
|
||||
fullPath := filepath.Join(team.DocsRoot, targetPath)
|
||||
|
||||
// Alle nötigen Unterordner anlegen falls sie noch nicht existieren (wie mkdir -p).
|
||||
err = os.MkdirAll(filepath.Dir(fullPath), 0755)
|
||||
if err != nil {
|
||||
http.Error(w, "Ordner konnte nicht erstellt werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
destFile, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Datei konnte nicht erstellt werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, uploadedFile)
|
||||
if err != nil {
|
||||
http.Error(w, "Datei konnte nicht geschrieben werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Cache aktualisieren: die neue/geänderte Seite laden und eintragen.
|
||||
// So sehen MCP und Navigation den neuen Inhalt sofort ohne Serverneustart.
|
||||
// destFile schließen bevor wir lesen — defer läuft erst am Funktionsende,
|
||||
// deshalb explizit schließen.
|
||||
destFile.Close()
|
||||
if page, err := render.LoadPage(fullPath); err == nil {
|
||||
urlPath := team.Cache.FilePathToURLPath(fullPath)
|
||||
team.Cache.Set(urlPath, page)
|
||||
}
|
||||
|
||||
h.audit.Log(AuditEvent{
|
||||
Team: team.ID,
|
||||
Actor: actorLabel(r),
|
||||
Action: "upload",
|
||||
Path: targetPath,
|
||||
Source: "api",
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "Datei erfolgreich hochgeladen in Team %s: %s\n", team.ID, targetPath)
|
||||
}
|
||||
|
||||
// HandleDelete verarbeitet DELETE /api/delete.
|
||||
// Erwartet Query-Parameter "path" (z.B. /api/delete?path=/projekte/alte-seite.md)
|
||||
// und für den Admin-Key zusätzlich "team".
|
||||
func (h *APIHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Nur DELETE erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
team, err := h.resolveTeam(r, r.URL.Query().Get("team"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := r.URL.Query().Get("path")
|
||||
if targetPath == "" {
|
||||
http.Error(w, "Query-Parameter 'path' fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(targetPath, "..") {
|
||||
http.Error(w, "Ungültiger Pfad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(team.DocsRoot, targetPath)
|
||||
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
http.Error(w, "Datei nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.Remove(fullPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Datei konnte nicht gelöscht werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Cache-Eintrag entfernen damit die Seite sofort aus Navigation und MCP verschwindet.
|
||||
urlPath := team.Cache.FilePathToURLPath(fullPath)
|
||||
team.Cache.Delete(urlPath)
|
||||
|
||||
h.audit.Log(AuditEvent{
|
||||
Team: team.ID,
|
||||
Actor: actorLabel(r),
|
||||
Action: "delete",
|
||||
Path: targetPath,
|
||||
Source: "api",
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "Datei erfolgreich gelöscht aus Team %s: %s\n", team.ID, targetPath)
|
||||
}
|
||||
127
handler/audit.go
Normal file
127
handler/audit.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Dieses File implementiert das Audit-Log: ein Nachvollzieh-Protokoll für
|
||||
// sicherheits-/nachvollziehbarkeitsrelevante Schreib-Aktionen (Upload, Delete,
|
||||
// Login-Versuche, Webhook-Sync).
|
||||
//
|
||||
// Format: JSON Lines (eine JSON-Zeile pro Ereignis, append-only) — passt zum
|
||||
// datei-zentrierten Charakter des restlichen Projekts, lässt sich ohne eigenes
|
||||
// Tooling mit "tail -f" und "jq" auswerten, und braucht keine Datenbank.
|
||||
//
|
||||
// Eine einzige Datei für alle Teams (nicht pro Team) hält Schreiben/Locking
|
||||
// einfach — beim Anzeigen (siehe audit_page.go) wird bei Bedarf nach Team gefiltert.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditEvent ist ein einzelner Eintrag im Audit-Log.
|
||||
type AuditEvent struct {
|
||||
Time time.Time `json:"time"`
|
||||
Team string `json:"team"` // Team-ID; leer bei teamübergreifenden Events (z.B. webhook_sync) oder fehlgeschlagenem Login
|
||||
Actor string `json:"actor"` // "team" oder "admin" — wer hat gehandelt
|
||||
Action string `json:"action"` // z.B. "upload", "delete", "login_success", "login_failure", "webhook_sync"
|
||||
Path string `json:"path,omitempty"` // betroffener Seiten-Pfad, falls zutreffend
|
||||
Source string `json:"source"` // "web", "api", "mcp", "webhook"
|
||||
IP string `json:"ip,omitempty"` // Client-IP, falls bekannt (z.B. bei Login)
|
||||
Detail string `json:"detail,omitempty"` // zusätzlicher Kontext, z.B. eine Fehlermeldung
|
||||
}
|
||||
|
||||
// AuditLogger schreibt AuditEvents als JSON Lines in eine Datei und kann die
|
||||
// zuletzt geschriebenen Einträge wieder auslesen (für die Admin-Ansicht).
|
||||
type AuditLogger struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
}
|
||||
|
||||
// NewAuditLogger erstellt einen AuditLogger der in die angegebene Datei schreibt.
|
||||
// Die Datei wird beim ersten Log-Aufruf automatisch angelegt falls sie nicht existiert.
|
||||
func NewAuditLogger(path string) *AuditLogger {
|
||||
return &AuditLogger{path: path}
|
||||
}
|
||||
|
||||
// Log hängt ein Ereignis ans Ende der Audit-Log-Datei an.
|
||||
//
|
||||
// Ein Fehler beim Schreiben des Audit-Logs lässt die eigentliche Aktion (Upload,
|
||||
// Login, ...) nicht fehlschlagen — er wird nur geloggt. Das Audit-Log ist eine
|
||||
// Nachvollziehbarkeits-Hilfe, kein Teil des kritischen Pfads.
|
||||
func (a *AuditLogger) Log(event AuditEvent) {
|
||||
event.Time = time.Now()
|
||||
|
||||
line, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
log.Printf("Audit-Log: Ereignis konnte nicht serialisiert werden: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
f, err := os.OpenFile(a.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Printf("Audit-Log: Datei konnte nicht geöffnet werden: %v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
log.Printf("Audit-Log: Ereignis konnte nicht geschrieben werden: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Tail liest die letzten n Ereignisse aus der Audit-Log-Datei, neueste zuerst.
|
||||
// Existiert die Datei noch nicht (noch kein Ereignis protokolliert), wird eine
|
||||
// leere Liste ohne Fehler zurückgegeben.
|
||||
func (a *AuditLogger) Tail(n int) ([]AuditEvent, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
f, err := os.Open(a.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
// Der Standard-Puffer von bufio.Scanner ist auf 64 KB pro Zeile begrenzt —
|
||||
// für unsere kurzen JSON-Zeilen reichlich, aber sicherheitshalber explizit
|
||||
// auf 1 MB angehoben falls ein Detail-Feld mal ungewöhnlich lang wird.
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
|
||||
events := make([]AuditEvent, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
var event AuditEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
continue // defekte Zeile überspringen statt die ganze Anzeige abbrechen zu lassen
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
|
||||
// Neueste zuerst anzeigen.
|
||||
for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 {
|
||||
events[i], events[j] = events[j], events[i]
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
49
handler/audit_page.go
Normal file
49
handler/audit_page.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Dieses File implementiert die Admin-Ansicht des Audit-Logs unter /admin/audit.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// auditPageTemplateData bündelt die Daten für das audit.html-Template.
|
||||
type auditPageTemplateData struct {
|
||||
Events []AuditEvent
|
||||
}
|
||||
|
||||
// AuditPageHandler zeigt die letzten Audit-Log-Einträge als Tabelle an.
|
||||
type AuditPageHandler struct {
|
||||
logger *AuditLogger
|
||||
template *template.Template
|
||||
}
|
||||
|
||||
// NewAuditPageHandler lädt das Template und erstellt einen neuen AuditPageHandler.
|
||||
func NewAuditPageHandler(templatesFS fs.FS, logger *AuditLogger) (*AuditPageHandler, error) {
|
||||
tmpl, err := template.ParseFS(templatesFS, "templates/audit.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuditPageHandler{logger: logger, template: tmpl}, nil
|
||||
}
|
||||
|
||||
// maxAuditEventsShown begrenzt wie viele Einträge auf der Seite angezeigt werden.
|
||||
const maxAuditEventsShown = 200
|
||||
|
||||
// HandleAuditPage verarbeitet GET /admin/audit. Nur mit dem globalen Admin-Zugang
|
||||
// erreichbar — Teams sehen ihr eigenes Audit-Log (noch) nicht im Web.
|
||||
func (h *AuditPageHandler) HandleAuditPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !IsAdminFromContext(r.Context()) {
|
||||
http.Error(w, "Nur für den Admin-Zugang verfügbar", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
events, err := h.logger.Tail(maxAuditEventsShown)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht gelesen werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.template.Execute(w, auditPageTemplateData{Events: events})
|
||||
}
|
||||
181
handler/auth.go
Normal file
181
handler/auth.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Dieses File implementiert den Team-Login fürs Web: Formular, Session-Cookie
|
||||
// setzen/löschen, und die Team-Umschaltung für den Admin-Zugang.
|
||||
//
|
||||
// Zu unterscheiden vom bestehenden Seiten-Passwort-Feature (wiki.go): das hier
|
||||
// ist der Zugang zum Wiki insgesamt (welches Team sehe ich?), das Seiten-Passwort
|
||||
// schützt zusätzlich einzelne Seiten innerhalb eines Teams.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// loginTemplateData bündelt die Daten für das login.html-Template.
|
||||
type loginTemplateData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// switchTeamTemplateData bündelt die Daten für das switch-team.html-Template.
|
||||
type switchTeamTemplateData struct {
|
||||
Teams []*Team
|
||||
}
|
||||
|
||||
// AuthHandler verwaltet Login, Logout und die Team-Umschaltung für Admins.
|
||||
type AuthHandler struct {
|
||||
registry *TeamRegistry
|
||||
sessionSecret []byte
|
||||
loginTemplate *template.Template
|
||||
switchTeamTemplate *template.Template
|
||||
rateLimiter *LoginRateLimiter
|
||||
audit *AuditLogger
|
||||
}
|
||||
|
||||
// NewAuthHandler lädt die Templates und erstellt einen neuen AuthHandler.
|
||||
func NewAuthHandler(templatesFS fs.FS, registry *TeamRegistry, sessionSecret []byte, audit *AuditLogger) (*AuthHandler, error) {
|
||||
loginTmpl, err := template.ParseFS(templatesFS, "templates/login.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switchTmpl, err := template.ParseFS(templatesFS, "templates/switch-team.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthHandler{
|
||||
registry: registry,
|
||||
sessionSecret: sessionSecret,
|
||||
loginTemplate: loginTmpl,
|
||||
switchTeamTemplate: switchTmpl,
|
||||
rateLimiter: NewLoginRateLimiter(),
|
||||
audit: audit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleLogin verarbeitet GET /login (Formular anzeigen) und POST /login (Key prüfen).
|
||||
func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
h.loginTemplate.Execute(w, loginTemplateData{})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ip := clientIP(r)
|
||||
|
||||
// Rate-Limiting: verhindert dass ein Angreifer beliebig oft Keys durchprobiert.
|
||||
if allowed, retryAfter := h.rateLimiter.Allow(ip); !allowed {
|
||||
seconds := int(retryAfter.Seconds()) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(seconds))
|
||||
http.Error(w, fmt.Sprintf("Zu viele Login-Versuche. Bitte in %d Sekunden erneut versuchen.", seconds), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
key := r.FormValue("key")
|
||||
|
||||
team, isAdmin, ok := h.registry.Resolve(key)
|
||||
if !ok {
|
||||
h.rateLimiter.RecordFailure(ip)
|
||||
h.audit.Log(AuditEvent{Actor: "unbekannt", Action: "login_failure", Source: "web", IP: ip})
|
||||
h.loginTemplate.Execute(w, loginTemplateData{Error: "Ungültiger Key."})
|
||||
return
|
||||
}
|
||||
|
||||
// Erfolgreicher Login → Zähler dieser IP zurücksetzen.
|
||||
h.rateLimiter.Reset(ip)
|
||||
|
||||
var subject string
|
||||
var redirectTo string
|
||||
var teamID string
|
||||
actor := "team"
|
||||
if isAdmin {
|
||||
subject = "admin"
|
||||
redirectTo = "/switch-team"
|
||||
actor = "admin"
|
||||
} else {
|
||||
subject = "team:" + team.ID
|
||||
redirectTo = "/"
|
||||
teamID = team.ID
|
||||
}
|
||||
|
||||
h.audit.Log(AuditEvent{Team: teamID, Actor: actor, Action: "login_success", Source: "web", IP: ip})
|
||||
|
||||
h.setSessionCookie(w, subject)
|
||||
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// clientIP extrahiert die Client-IP aus r.RemoteAddr (Format "ip:port").
|
||||
//
|
||||
// Hinweis: Läuft der Server hinter einem Reverse-Proxy (nginx, Cloudflare, ...),
|
||||
// ist r.RemoteAddr die IP des Proxys, nicht die des eigentlichen Clients — dann
|
||||
// müsste stattdessen ein vom Proxy gesetzter Header wie X-Forwarded-For ausgewertet
|
||||
// werden. Das ist hier bewusst weggelassen: dieser Header ist ohne eine bekannte,
|
||||
// vertrauenswürdige Proxy-Kette leicht zu fälschen und würde das Rate-Limiting aushebeln.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// HandleLogout löscht den Session-Cookie und schickt zurück zum Login-Formular.
|
||||
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleSwitchTeam zeigt dem Admin eine Team-Auswahl (GET) und setzt das aktive
|
||||
// Team in der Session (POST). Nur erreichbar mit einer Admin-Session.
|
||||
func (h *AuthHandler) HandleSwitchTeam(w http.ResponseWriter, r *http.Request) {
|
||||
if !IsAdminFromContext(r.Context()) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
h.switchTeamTemplate.Execute(w, switchTeamTemplateData{Teams: h.registry.All()})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
teamID := r.FormValue("team")
|
||||
if _, ok := h.registry.ByID(teamID); !ok {
|
||||
http.Error(w, "Unbekanntes Team", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.setSessionCookie(w, "admin:"+teamID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// setSessionCookie signiert das subject und setzt es als Session-Cookie.
|
||||
func (h *AuthHandler) setSessionCookie(w http.ResponseWriter, subject string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: SignSession(subject, h.sessionSecret),
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(SessionTTL),
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
166
handler/cache.go
Normal file
166
handler/cache.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// Dieses File implementiert einen In-Memory-Cache für Wiki-Seiten.
|
||||
//
|
||||
// # Warum ein Cache?
|
||||
//
|
||||
// Ohne Cache liest der Server bei jedem Request jede .md-Datei vom Dateisystem
|
||||
// und parst das Frontmatter neu. Bei list_pages bedeutet das: alle Dateien öffnen,
|
||||
// YAML parsen, schließen — für jede Anfrage. Mit vielen Seiten wird das spürbar.
|
||||
//
|
||||
// Der Cache löst das: beim Serverstart werden alle Seiten einmal geladen und im
|
||||
// Arbeitsspeicher gehalten. Danach sind Metadaten (Titel, Tags, Hidden) sofort
|
||||
// verfügbar ohne Dateisystem-Zugriff.
|
||||
//
|
||||
// # Nebenläufigkeit (Concurrency)
|
||||
//
|
||||
// Go-Server bearbeiten mehrere HTTP-Requests gleichzeitig in sogenannten Goroutinen
|
||||
// (leichtgewichtige Threads). Das bedeutet: zwei Requests könnten gleichzeitig den
|
||||
// Cache lesen — das ist kein Problem. Aber wenn gleichzeitig einer liest und einer
|
||||
// schreibt, kann es zu Datenverlust oder Abstürzen kommen (Race Condition).
|
||||
//
|
||||
// sync.RWMutex (Read-Write-Mutex) löst das:
|
||||
// - Lesen (RLock): Mehrere Goroutinen dürfen gleichzeitig lesen
|
||||
// - Schreiben (Lock): Nur eine Goroutine darf schreiben, alle anderen warten
|
||||
package handler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"wiki/render"
|
||||
)
|
||||
|
||||
// CachedEntry ist ein einzelner Eintrag im Cache — eine geladene Seite mit ihrem URL-Pfad.
|
||||
type CachedEntry struct {
|
||||
URLPath string // z.B. "/projekte/notizen"
|
||||
Page *render.Page // die geladene Seite mit Frontmatter
|
||||
}
|
||||
|
||||
// PageCache hält alle Wiki-Seiten im Arbeitsspeicher.
|
||||
type PageCache struct {
|
||||
mu sync.RWMutex // schützt entries vor gleichzeitigem Lesen+Schreiben
|
||||
entries map[string]*render.Page // key: URL-Pfad wie "/projekte/notizen"
|
||||
docsRoot string // Pfad zum docs-Ordner, wird für Pfadumrechnung gebraucht
|
||||
SearchIndex *SearchIndex // BM25-Volltextindex — wird parallel zum Cache aktuell gehalten
|
||||
}
|
||||
|
||||
// NewPageCache erstellt einen neuen Cache und befüllt ihn sofort mit allen
|
||||
// vorhandenen .md-Dateien im docsRoot-Ordner.
|
||||
// Gleichzeitig wird der BM25-Suchindex initial aufgebaut.
|
||||
func NewPageCache(docsRoot string) (*PageCache, error) {
|
||||
c := &PageCache{
|
||||
entries: make(map[string]*render.Page),
|
||||
docsRoot: docsRoot,
|
||||
SearchIndex: NewSearchIndex(),
|
||||
}
|
||||
|
||||
// Beim Start einmal alle Seiten laden — befüllt Cache und Suchindex gemeinsam.
|
||||
if err := c.build(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// build geht rekursiv durch den docs-Ordner und lädt alle .md-Dateien in den Cache.
|
||||
// Gleichzeitig werden alle Seiten in den BM25-Suchindex aufgenommen.
|
||||
// Wird nur einmal beim Start aufgerufen — danach werden Einträge einzeln aktualisiert.
|
||||
func (c *PageCache) build() error {
|
||||
return filepath.Walk(c.docsRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
|
||||
page, err := render.LoadPage(path)
|
||||
if err != nil {
|
||||
return nil // fehlerhafte Datei überspringen, nicht abbrechen
|
||||
}
|
||||
|
||||
urlPath := c.FilePathToURLPath(path)
|
||||
// Kein Lock nötig hier, weil build() nur einmal vor dem ersten Request läuft
|
||||
c.entries[urlPath] = page
|
||||
|
||||
// Seite in den Suchindex aufnehmen — Hidden-Seiten werden mitindexiert,
|
||||
// aber search_pages filtert sie beim Anzeigen heraus.
|
||||
c.SearchIndex.Index(urlPath, page.Title, page.RawBody, page.Tags)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Get liest eine Seite aus dem Cache anhand ihres URL-Pfads.
|
||||
// Gibt (nil, false) zurück wenn die Seite nicht im Cache ist.
|
||||
func (c *PageCache) Get(urlPath string) (*render.Page, bool) {
|
||||
// RLock = Lesesperre: andere Goroutinen dürfen gleichzeitig lesen
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock() // Sperre am Funktionsende freigeben — defer läuft immer, auch bei return
|
||||
|
||||
page, ok := c.entries[urlPath]
|
||||
return page, ok
|
||||
}
|
||||
|
||||
// Set fügt eine Seite in den Cache ein oder überschreibt einen bestehenden Eintrag.
|
||||
// Aktualisiert gleichzeitig den BM25-Suchindex.
|
||||
// Wird nach einem erfolgreichen Upload aufgerufen.
|
||||
func (c *PageCache) Set(urlPath string, page *render.Page) {
|
||||
// Lock = exklusive Schreibsperre: alle anderen Goroutinen warten bis wir fertig sind
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.entries[urlPath] = page
|
||||
|
||||
// Suchindex synchron aktualisieren — Index() ersetzt automatisch einen bestehenden Eintrag
|
||||
c.SearchIndex.Index(urlPath, page.Title, page.RawBody, page.Tags)
|
||||
}
|
||||
|
||||
// Delete entfernt eine Seite aus dem Cache und aus dem BM25-Suchindex.
|
||||
// Wird nach einem erfolgreichen Delete aufgerufen.
|
||||
func (c *PageCache) Delete(urlPath string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
delete(c.entries, urlPath)
|
||||
c.SearchIndex.Remove(urlPath)
|
||||
}
|
||||
|
||||
// All gibt eine Momentaufnahme aller Cache-Einträge zurück.
|
||||
// Wir erstellen eine Kopie der Liste damit der Aufrufer sie ohne Sperre lesen kann.
|
||||
// (Würde der Aufrufer direkt auf c.entries zugreifen, müsste er die Sperre halten.)
|
||||
func (c *PageCache) All() []CachedEntry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
// make mit Kapazität = aktuelle Anzahl Einträge — vermeidet Speicher-Neuallokierungen
|
||||
result := make([]CachedEntry, 0, len(c.entries))
|
||||
for urlPath, page := range c.entries {
|
||||
result = append(result, CachedEntry{URLPath: urlPath, Page: page})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilePathToURLPath wandelt einen Dateisystem-Pfad in einen URL-Pfad um.
|
||||
//
|
||||
// data/docs/projekte/notizen.md → /projekte/notizen
|
||||
// data/docs/index.md → /
|
||||
func (c *PageCache) FilePathToURLPath(filePath string) string {
|
||||
rel := strings.TrimPrefix(filePath, c.docsRoot)
|
||||
rel = strings.TrimSuffix(rel, ".md")
|
||||
if rel == "/index" {
|
||||
return "/"
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// URLPathToFilePath wandelt einen URL-Pfad in einen Dateisystem-Pfad um.
|
||||
//
|
||||
// /projekte/notizen → data/docs/projekte/notizen.md
|
||||
// / → data/docs/index.md
|
||||
func (c *PageCache) URLPathToFilePath(urlPath string) string {
|
||||
if urlPath == "/" {
|
||||
return filepath.Join(c.docsRoot, "index.md")
|
||||
}
|
||||
clean := strings.TrimPrefix(urlPath, "/")
|
||||
// Falls der Pfad schon auf .md endet (z.B. vom MCP-Client), nicht doppelt anhängen.
|
||||
clean = strings.TrimSuffix(clean, ".md")
|
||||
return filepath.Join(c.docsRoot, clean+".md")
|
||||
}
|
||||
681
handler/mcp.go
Normal file
681
handler/mcp.go
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
// Dieses File implementiert einen MCP-Server (Model Context Protocol) für das Wiki.
|
||||
//
|
||||
// # Was ist MCP?
|
||||
//
|
||||
// MCP (Model Context Protocol) ist ein offener Standard der festlegt wie KI-Assistenten
|
||||
// (Claude, Cline, Opencode, ...) mit externen Systemen kommunizieren. Statt dass der
|
||||
// Nutzer copy-paste zwischen Wiki und Chat-Fenster macht, kann das KI-Tool direkt
|
||||
// Seiten lesen, schreiben und löschen.
|
||||
//
|
||||
// # Wie funktioniert Streamable HTTP als Transport?
|
||||
//
|
||||
// MCP kann über verschiedene Transportwege laufen. Wir nutzen Streamable HTTP
|
||||
// (MCP-Spezifikation 2025-11-25) — den modernen Standard.
|
||||
//
|
||||
// Der Ablauf ist einfach: jede MCP-Anfrage ist ein normaler HTTP POST-Request.
|
||||
//
|
||||
// 1. Das KI-Tool schickt POST /mcp mit einem JSON-RPC-Body:
|
||||
// { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "search_pages", ... } }
|
||||
//
|
||||
// 2. Der Server antwortet direkt auf diesen POST — entweder als normales JSON
|
||||
// oder als kurzer SSE-Stream wenn die Antwort gestreamt werden soll.
|
||||
//
|
||||
// Warum Streamable HTTP statt dem älteren SSE-Transport?
|
||||
//
|
||||
// Der ältere SSE-Transport hielt eine dauerhafte GET-Verbindung offen.
|
||||
// Proxies und HTTP-Clients trennen solche Verbindungen nach einiger Zeit Inaktivität
|
||||
// (Timeout) — die MCP-Verbindung bricht dann ab bis man das Tool neu startet.
|
||||
// Streamable HTTP hat dieses Problem nicht: keine persistente Verbindung,
|
||||
// kein Timeout, funktioniert zuverlässig auch nach längerer Pause.
|
||||
//
|
||||
// # Resources vs. Tools — der wichtige Unterschied
|
||||
//
|
||||
// MCP unterscheidet zwei Konzepte:
|
||||
//
|
||||
// - Resources: Dokumente und Daten die das KI-Tool lesen kann.
|
||||
// Sie haben eine URI (wie eine Web-Adresse), einen MIME-Type und einen Inhalt.
|
||||
// Das KI-Tool kann Resources direkt in seinen Kontext laden.
|
||||
// Beispiel: wiki://pages (Seitenliste), wiki://page/projekte/notizen (eine Seite)
|
||||
//
|
||||
// - Tools: Aktionen die das KI-Tool ausführen kann — wie Funktionsaufrufe.
|
||||
// Sie haben Parameter und geben ein Ergebnis zurück.
|
||||
// Beispiel: upload_page, delete_page, search_pages
|
||||
//
|
||||
// Wiki-Seiten sind semantisch Resources (Daten zum Lesen), keine Tools (Aktionen).
|
||||
// Deshalb: lesen/auflisten → Resources, schreiben/löschen/suchen → Tools.
|
||||
//
|
||||
// # Cache
|
||||
//
|
||||
// Alle lesenden Operationen nutzen den PageCache aus cache.go statt direkt
|
||||
// vom Dateisystem zu lesen. So sind list und read schnell auch bei vielen Seiten.
|
||||
// Schreibende Operationen (upload, delete) aktualisieren den Cache sofort.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"wiki/render"
|
||||
)
|
||||
|
||||
// mcpActorLabel gibt "admin" oder "team" zurück, je nachdem womit der
|
||||
// MCP-Request authentifiziert wurde — fürs Audit-Log.
|
||||
func mcpActorLabel(ctx context.Context) string {
|
||||
if IsAdminFromContext(ctx) {
|
||||
return "admin"
|
||||
}
|
||||
return "team"
|
||||
}
|
||||
|
||||
// ── Tool-Typen ────────────────────────────────────────────────────────────────
|
||||
// Nur noch für Tools (Aktionen) — Resources brauchen keine eigenen Input/Output-Structs.
|
||||
|
||||
// uploadPageInput sind die Parameter für das upload_page-Tool.
|
||||
type uploadPageInput struct {
|
||||
Path string `json:"path" jsonschema:"Zielpfad inkl. .md, z.B. /projekte/notizen.md"`
|
||||
Content string `json:"content" jsonschema:"Vollständiger Markdown-Inhalt inkl. Frontmatter"`
|
||||
}
|
||||
|
||||
// uploadPageOutput bestätigt den erfolgreichen Upload.
|
||||
type uploadPageOutput struct {
|
||||
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
|
||||
}
|
||||
|
||||
// deletePageInput ist der Parameter für das delete_page-Tool.
|
||||
type deletePageInput struct {
|
||||
Path string `json:"path" jsonschema:"Pfad zur Datei, z.B. /projekte/notizen.md"`
|
||||
}
|
||||
|
||||
// deletePageOutput bestätigt das erfolgreiche Löschen.
|
||||
type deletePageOutput struct {
|
||||
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
|
||||
}
|
||||
|
||||
// searchInput ist der Parameter für das search_pages-Tool.
|
||||
type searchInput struct {
|
||||
Query string `json:"query" jsonschema:"Suchbegriff — wird mit BM25-Ranking in Titel, Inhalt und Tags aller Seiten gesucht"`
|
||||
}
|
||||
|
||||
// searchMatch ist ein einzelnes Suchergebnis.
|
||||
type searchMatch struct {
|
||||
Title string `json:"title" jsonschema:"Titel der Seite"`
|
||||
URL string `json:"url" jsonschema:"URL der Seite"`
|
||||
Snippet string `json:"snippet" jsonschema:"Textstelle um den Treffer herum"`
|
||||
}
|
||||
|
||||
// searchOutput enthält alle Suchergebnisse, absteigend nach BM25-Relevanz sortiert.
|
||||
type searchOutput struct {
|
||||
Results []searchMatch `json:"results" jsonschema:"Gefundene Seiten, sortiert nach Relevanz (bester Treffer zuerst)"`
|
||||
Total int `json:"total" jsonschema:"Anzahl Treffer"`
|
||||
}
|
||||
|
||||
// rebuildIndexOutput ist die Bestätigung nach dem Aufbau des Wiki-Index.
|
||||
type rebuildIndexOutput struct {
|
||||
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
|
||||
PageCount int `json:"page_count" jsonschema:"Anzahl der indizierten Seiten"`
|
||||
}
|
||||
|
||||
// ── pageListEntry wird für die JSON-Ausgabe der wiki://pages Resource verwendet ─
|
||||
|
||||
type pageListEntry struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Tags []string `json:"tags"`
|
||||
Protected bool `json:"protected"` // true wenn Passwortschutz aktiv
|
||||
}
|
||||
|
||||
// ── MCPHandler ────────────────────────────────────────────────────────────────
|
||||
|
||||
// MCPHandler hält pro Team einen eigenen MCP-Server (jeweils mit Tool-Closures die
|
||||
// nur auf den Cache/Docs-Ordner dieses Teams zugreifen) und stellt sie als
|
||||
// gemeinsamen HTTP-Handler bereit.
|
||||
//
|
||||
// Team-Isolation: middleware.RequireTeamOrAdminKey legt das aufgelöste Team (oder
|
||||
// den Admin-Status) bereits im Request-Context ab, bevor ServeHTTP hier läuft.
|
||||
// Ein Team-Key kann also strukturell gar nicht an den Server eines anderen Teams
|
||||
// geraten. Der globale Admin-Key muss das Zielteam explizit über den Header
|
||||
// "X-Wiki-Team" angeben.
|
||||
type MCPHandler struct {
|
||||
registry *TeamRegistry
|
||||
audit *AuditLogger
|
||||
|
||||
mu sync.Mutex
|
||||
servers map[string]*mcp.Server // key: Team-ID — lazy befüllt, siehe getOrBuildServer
|
||||
streamableHandler http.Handler
|
||||
}
|
||||
|
||||
// NewMCPHandler erstellt den MCP-Handler. Die Team-Server selbst werden lazy
|
||||
// gebaut (siehe getOrBuildServer) statt alle beim Start — so bekommen auch
|
||||
// Teams die erst später per Hot-Reload von teams.yaml dazukommen (siehe
|
||||
// TeamRegistry.Reload) automatisch einen funktionierenden MCP-Server, ohne
|
||||
// dass der Prozess neu gestartet werden muss.
|
||||
func NewMCPHandler(registry *TeamRegistry, audit *AuditLogger) *MCPHandler {
|
||||
h := &MCPHandler{registry: registry, audit: audit, servers: make(map[string]*mcp.Server)}
|
||||
|
||||
// Streamable HTTP Transport statt SSE.
|
||||
//
|
||||
// SSE (Server-Sent Events) hält eine dauerhafte HTTP-Verbindung offen —
|
||||
// Proxies und Clients trennen diese nach einiger Zeit Inaktivität (Timeout).
|
||||
// Das führt dazu dass MCP-Verbindungen nach längerem Nichtbenutzen abbrechen.
|
||||
//
|
||||
// Streamable HTTP löst das: jede MCP-Anfrage ist ein normaler POST-Request,
|
||||
// keine persistente Verbindung. Kein Timeout-Problem, moderner Standard (Spec 2025-11-25).
|
||||
// Cline, Opencode und Claude Desktop unterstützen alle Streamable HTTP.
|
||||
h.streamableHandler = mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
team, _ := TeamFromContext(r.Context())
|
||||
if team == nil {
|
||||
return nil
|
||||
}
|
||||
return h.getOrBuildServer(team)
|
||||
}, nil)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// getOrBuildServer gibt den MCP-Server eines Teams zurück und baut ihn beim
|
||||
// ersten Zugriff auf. Bereits existierende Teams behalten ihren einmal gebauten
|
||||
// Server (der auf team.Cache zeigt, das bei einem Reload für bestehende Teams
|
||||
// wiederverwendet wird — siehe TeamRegistry.Reload), neue Teams bekommen einen frischen.
|
||||
func (h *MCPHandler) getOrBuildServer(team *Team) *mcp.Server {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if s, ok := h.servers[team.ID]; ok {
|
||||
return s
|
||||
}
|
||||
|
||||
s := buildTeamServer(team.ID, team.Cache, h.audit)
|
||||
h.servers[team.ID] = s
|
||||
return s
|
||||
}
|
||||
|
||||
// ServeHTTP löst zunächst das Ziel-Team auf (aus dem Request-Context, den
|
||||
// middleware.RequireTeamOrAdminKey vorher befüllt hat) und reicht den Request dann
|
||||
// an den passenden Team-Server weiter.
|
||||
func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
team, ok := TeamFromContext(r.Context())
|
||||
isAdmin := IsAdminFromContext(r.Context())
|
||||
|
||||
if !ok {
|
||||
if !isAdmin {
|
||||
http.Error(w, "Ungültiger Key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Admin-Key: das Zielteam muss explizit über den Header angegeben werden —
|
||||
// anders als bei der Web-Ansicht gibt es hier keine Team-Umschaltung.
|
||||
teamID := r.Header.Get("X-Wiki-Team")
|
||||
t, found := h.registry.ByID(teamID)
|
||||
if !found {
|
||||
http.Error(w, "Header 'X-Wiki-Team' erforderlich (unbekanntes oder fehlendes Team) für Admin-Zugriff", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
team = t
|
||||
}
|
||||
|
||||
// isAdmin bewusst durchreichen (nicht hart auf false setzen) — die Tool-Handler
|
||||
// nutzen es fürs Audit-Log (mcpActorLabel), um "admin" von "team" zu unterscheiden.
|
||||
h.streamableHandler.ServeHTTP(w, r.WithContext(WithTeam(r.Context(), team, isAdmin)))
|
||||
}
|
||||
|
||||
// buildTeamServer erstellt einen MCP-Server mit allen Resources und Tools, dessen
|
||||
// Closures ausschließlich auf den übergebenen (team-eigenen) Cache zugreifen.
|
||||
func buildTeamServer(teamID string, cache *PageCache, audit *AuditLogger) *mcp.Server {
|
||||
server := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "wiki",
|
||||
Version: "1.0.0",
|
||||
}, nil)
|
||||
|
||||
// ── Resource: wiki://pages ────────────────────────────────────────────────
|
||||
// Listet alle Wiki-Seiten als JSON auf.
|
||||
// Ein KI-Tool kann diese Resource laden um einen Überblick über das Wiki zu bekommen.
|
||||
server.AddResource(
|
||||
&mcp.Resource{
|
||||
URI: "wiki://pages",
|
||||
Name: "Wiki-Seitenverzeichnis",
|
||||
Description: "Liste aller Wiki-Seiten mit Titel, URL und Tags als JSON.",
|
||||
MIMEType: "application/json",
|
||||
},
|
||||
func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
||||
// Alle Einträge aus dem Cache holen — kein Dateisystem-Zugriff nötig
|
||||
entries := cache.All()
|
||||
|
||||
pages := make([]pageListEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
// Versteckte Seiten werden nicht aufgelistet
|
||||
if entry.Page.Hidden {
|
||||
continue
|
||||
}
|
||||
pages = append(pages, pageListEntry{
|
||||
Title: entry.Page.Title,
|
||||
URL: entry.URLPath,
|
||||
Tags: entry.Page.Tags,
|
||||
Protected: entry.Page.Password != "",
|
||||
})
|
||||
}
|
||||
|
||||
// Zur JSON-Darstellung umwandeln.
|
||||
// MarshalIndent formatiert das JSON lesbar mit Einrückung.
|
||||
data, err := json.MarshalIndent(pages, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Seitenliste konnte nicht serialisiert werden: %w", err)
|
||||
}
|
||||
|
||||
return &mcp.ReadResourceResult{
|
||||
Contents: []*mcp.ResourceContents{{
|
||||
URI: req.Params.URI,
|
||||
MIMEType: "application/json",
|
||||
Text: string(data),
|
||||
}},
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// ── ResourceTemplate: wiki://page/{+path} ─────────────────────────────────
|
||||
// Stellt eine einzelne Wiki-Seite als Markdown-Resource bereit.
|
||||
//
|
||||
// URI-Template-Syntax: {+path} ist RFC 6570 "reserved expansion" —
|
||||
// das + bedeutet dass Slashes im Pfad erlaubt sind (ohne + würden sie enkodiert).
|
||||
// So matcht wiki://page/projekte/notizen mit path = "projekte/notizen".
|
||||
//
|
||||
// Beispiele:
|
||||
// wiki://page/index → Startseite
|
||||
// wiki://page/setup → /setup
|
||||
// wiki://page/projekte/a → /projekte/a
|
||||
server.AddResourceTemplate(
|
||||
&mcp.ResourceTemplate{
|
||||
URITemplate: "wiki://page/{+path}",
|
||||
Name: "Wiki-Seite",
|
||||
Description: "Liest eine Wiki-Seite als rohen Markdown-Inhalt. Pfad ohne führenden Slash, z.B. projekte/notizen",
|
||||
MIMEType: "text/markdown",
|
||||
},
|
||||
func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
||||
// Den Pfad aus der URI extrahieren.
|
||||
// req.Params.URI ist z.B. "wiki://page/projekte/notizen"
|
||||
path := strings.TrimPrefix(req.Params.URI, "wiki://page/")
|
||||
|
||||
// "index" als Sonderfall für die Startseite behandeln
|
||||
urlPath := "/" + path
|
||||
if path == "index" {
|
||||
urlPath = "/"
|
||||
}
|
||||
|
||||
filePath := cache.URLPathToFilePath(urlPath)
|
||||
|
||||
// Rohen Dateiinhalt lesen — wir wollen Markdown, nicht gerendertes HTML
|
||||
rawBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Seite nicht gefunden: %s", urlPath)
|
||||
}
|
||||
|
||||
// Frontmatter entfernen damit das KI-Tool nur den Markdown-Body bekommt
|
||||
markdownBody := stripFrontmatter(string(rawBytes))
|
||||
|
||||
// Titel aus dem Cache holen (schneller als nochmal Frontmatter parsen)
|
||||
title := path
|
||||
if page, ok := cache.Get(urlPath); ok {
|
||||
title = page.Title
|
||||
}
|
||||
|
||||
// Titel als H1-Überschrift voranstellen falls nicht schon im Body
|
||||
content := fmt.Sprintf("# %s\n\n%s", title, markdownBody)
|
||||
|
||||
return &mcp.ReadResourceResult{
|
||||
Contents: []*mcp.ResourceContents{{
|
||||
URI: req.Params.URI,
|
||||
MIMEType: "text/markdown",
|
||||
Text: content,
|
||||
}},
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// ── Tool: search_pages ────────────────────────────────────────────────────
|
||||
// Durchsucht alle Wiki-Seiten mit BM25-Ranking.
|
||||
//
|
||||
// Gegenüber der früheren String-Suche (strings.Contains über alle Dateien) hat
|
||||
// BM25 zwei Vorteile:
|
||||
// 1. Geschwindigkeit: Der invertierte Index im Speicher wird genutzt — kein
|
||||
// Dateisystem-Zugriff, keine lineare Schleife über alle Seiten.
|
||||
// 2. Ranking: Ergebnisse sind nach Relevanz sortiert. Titel-Treffer erscheinen
|
||||
// vor Body-Treffern; seltene, spezifische Begriffe wiegen mehr als häufige.
|
||||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "search_pages",
|
||||
Description: "Durchsucht alle Wiki-Seiten mit BM25-Ranking — Titel-Treffer zuerst, dann Body. Ergebnisse sind nach Relevanz sortiert.",
|
||||
},
|
||||
func(_ context.Context, _ *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, searchOutput, error) {
|
||||
if in.Query == "" {
|
||||
return nil, searchOutput{}, fmt.Errorf("query darf nicht leer sein")
|
||||
}
|
||||
|
||||
// BM25-Suche über den In-Memory-Index — kein Dateisystem-Zugriff nötig
|
||||
hits := cache.SearchIndex.Search(in.Query, 20)
|
||||
|
||||
matches := make([]searchMatch, 0, len(hits))
|
||||
for _, hit := range hits {
|
||||
page, ok := cache.Get(hit.URLPath)
|
||||
if !ok || page.Hidden {
|
||||
continue
|
||||
}
|
||||
|
||||
// Snippet aus dem im Page gecacheten Rohtext extrahieren
|
||||
snippet := extractSnippet(page.RawBody, in.Query)
|
||||
|
||||
matches = append(matches, searchMatch{
|
||||
Title: page.Title,
|
||||
URL: hit.URLPath,
|
||||
Snippet: snippet,
|
||||
})
|
||||
}
|
||||
|
||||
return nil, searchOutput{Results: matches, Total: len(matches)}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// ── Tool: rebuild_index ───────────────────────────────────────────────────
|
||||
// Generiert eine strukturierte _index.md aus allen vorhandenen Wiki-Seiten.
|
||||
//
|
||||
// Inspiration: Karpathy's "LLM Wiki" Muster (https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
|
||||
// Der Index dient KI-Tools als Navigationshilfe: statt blind durch einzelne Seiten
|
||||
// zu suchen, kann ein KI-Tool zuerst den Index lesen um zu verstehen was im Wiki
|
||||
// überhaupt vorhanden ist — nach Kategorien (Tags) gegliedert.
|
||||
//
|
||||
// Die _index.md wird als versteckte Seite gespeichert (hidden: true) — sie erscheint
|
||||
// nicht in der Sidebar-Navigation, ist aber per MCP als Resource abrufbar:
|
||||
// wiki://page/_index
|
||||
//
|
||||
// Empfohlener Workflow für KI-Tools:
|
||||
// 1. rebuild_index aufrufen nachdem neue Seiten hinzugefügt wurden
|
||||
// 2. wiki://page/_index lesen für einen Überblick über das Wiki
|
||||
// 3. search_pages für spezifische Inhaltssuche nutzen
|
||||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "rebuild_index",
|
||||
Description: "Generiert _index.md neu — eine strukturierte Übersicht aller Wiki-Seiten, gegliedert nach Tags/Kategorien. Wird nach jedem upload_page automatisch aufgerufen, kann aber auch manuell ausgelöst werden.",
|
||||
},
|
||||
func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, rebuildIndexOutput, error) {
|
||||
out, err := buildWikiIndex(cache)
|
||||
return nil, out, err
|
||||
},
|
||||
)
|
||||
|
||||
// ── Tool: upload_page ─────────────────────────────────────────────────────
|
||||
// Erstellt eine neue Seite oder überschreibt eine bestehende.
|
||||
// Nach dem Schreiben wird der Cache sofort aktualisiert und — sofern es sich
|
||||
// nicht um _index.md selbst handelt — der Wiki-Index automatisch neu generiert.
|
||||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "upload_page",
|
||||
Description: "Erstellt eine neue Wiki-Seite oder überschreibt eine bestehende. Inhalt muss vollständiges Markdown mit Frontmatter sein. Der _index.md wird danach automatisch aktualisiert.",
|
||||
},
|
||||
func(ctx context.Context, _ *mcp.CallToolRequest, in uploadPageInput) (*mcp.CallToolResult, uploadPageOutput, error) {
|
||||
if in.Path == "" {
|
||||
return nil, uploadPageOutput{}, fmt.Errorf("path darf nicht leer sein")
|
||||
}
|
||||
if in.Content == "" {
|
||||
return nil, uploadPageOutput{}, fmt.Errorf("content darf nicht leer sein")
|
||||
}
|
||||
if strings.Contains(in.Path, "..") {
|
||||
return nil, uploadPageOutput{}, fmt.Errorf("ungültiger Pfad")
|
||||
}
|
||||
|
||||
fullPath := cache.URLPathToFilePath(in.Path)
|
||||
|
||||
if err := os.MkdirAll(strings.TrimSuffix(fullPath, "/"+lastSegment(fullPath)), 0755); err != nil {
|
||||
return nil, uploadPageOutput{}, fmt.Errorf("Ordner konnte nicht erstellt werden: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullPath, []byte(in.Content), 0644); err != nil {
|
||||
return nil, uploadPageOutput{}, fmt.Errorf("Datei konnte nicht geschrieben werden: %w", err)
|
||||
}
|
||||
|
||||
// Cache und Suchindex sofort aktualisieren
|
||||
if page, err := render.LoadPage(fullPath); err == nil {
|
||||
cache.Set(in.Path, page)
|
||||
}
|
||||
|
||||
// _index.md automatisch neu aufbauen — außer wenn _index.md selbst
|
||||
// hochgeladen wird (das würde eine Endlosschleife verursachen).
|
||||
if in.Path != "/_index.md" {
|
||||
buildWikiIndex(cache) //nolint:errcheck — Fehler beim Index-Rebuild sind nicht kritisch
|
||||
}
|
||||
|
||||
audit.Log(AuditEvent{
|
||||
Team: teamID,
|
||||
Actor: mcpActorLabel(ctx),
|
||||
Action: "upload",
|
||||
Path: in.Path,
|
||||
Source: "mcp",
|
||||
})
|
||||
|
||||
return nil, uploadPageOutput{Message: fmt.Sprintf("Seite gespeichert: %s", in.Path)}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// ── Tool: delete_page ─────────────────────────────────────────────────────
|
||||
// Löscht eine Wiki-Seite und entfernt sie sofort aus dem Cache.
|
||||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "delete_page",
|
||||
Description: "Löscht eine Wiki-Seite anhand ihres Pfads.",
|
||||
},
|
||||
func(ctx context.Context, _ *mcp.CallToolRequest, in deletePageInput) (*mcp.CallToolResult, deletePageOutput, error) {
|
||||
if in.Path == "" {
|
||||
return nil, deletePageOutput{}, fmt.Errorf("path darf nicht leer sein")
|
||||
}
|
||||
if strings.Contains(in.Path, "..") {
|
||||
return nil, deletePageOutput{}, fmt.Errorf("ungültiger Pfad")
|
||||
}
|
||||
|
||||
fullPath := cache.URLPathToFilePath(in.Path)
|
||||
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return nil, deletePageOutput{}, fmt.Errorf("Seite nicht gefunden: %s", in.Path)
|
||||
}
|
||||
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
return nil, deletePageOutput{}, fmt.Errorf("Seite konnte nicht gelöscht werden: %w", err)
|
||||
}
|
||||
|
||||
// Cache-Eintrag entfernen
|
||||
cache.Delete(in.Path)
|
||||
|
||||
audit.Log(AuditEvent{
|
||||
Team: teamID,
|
||||
Actor: mcpActorLabel(ctx),
|
||||
Action: "delete",
|
||||
Path: in.Path,
|
||||
Source: "mcp",
|
||||
})
|
||||
|
||||
return nil, deletePageOutput{Message: fmt.Sprintf("Seite gelöscht: %s", in.Path)}, nil
|
||||
},
|
||||
)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
|
||||
// extractSnippet gibt ~100 Zeichen um den ersten Treffer von query in content zurück.
|
||||
// Nützlich um dem KI-Tool Kontext zum Suchtreffer zu geben.
|
||||
func extractSnippet(content, query string) string {
|
||||
lowerContent := strings.ToLower(content)
|
||||
lowerQuery := strings.ToLower(query)
|
||||
|
||||
idx := strings.Index(lowerContent, lowerQuery)
|
||||
if idx == -1 {
|
||||
// Kein Treffer im Inhalt — leeren String zurückgeben
|
||||
return ""
|
||||
}
|
||||
|
||||
const radius = 100 // Zeichen vor und nach dem Treffer
|
||||
start := idx - radius
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + len(query) + radius
|
||||
if end > len(content) {
|
||||
end = len(content)
|
||||
}
|
||||
|
||||
snippet := content[start:end]
|
||||
|
||||
// "..." hinzufügen wenn der Snippet nicht am Anfang/Ende des Texts beginnt
|
||||
if start > 0 {
|
||||
snippet = "..." + snippet
|
||||
}
|
||||
if end < len(content) {
|
||||
snippet += "..."
|
||||
}
|
||||
|
||||
return strings.TrimSpace(snippet)
|
||||
}
|
||||
|
||||
// stripFrontmatter entfernt den YAML-Frontmatter-Block vom Anfang eines Markdown-Strings.
|
||||
func stripFrontmatter(content string) string {
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return content
|
||||
}
|
||||
rest := strings.TrimPrefix(content, "---\n")
|
||||
idx := strings.Index(rest, "---")
|
||||
if idx == -1 {
|
||||
return content
|
||||
}
|
||||
return strings.TrimSpace(rest[idx+4:])
|
||||
}
|
||||
|
||||
// lastSegment gibt den letzten Teil eines Dateipfads zurück (nach dem letzten "/").
|
||||
func lastSegment(path string) string {
|
||||
parts := strings.Split(path, "/")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// buildWikiIndex generiert _index.md aus allen aktuellen Wiki-Seiten und schreibt
|
||||
// sie ins Dateisystem. Cache und Suchindex werden danach sofort aktualisiert.
|
||||
//
|
||||
// Die Funktion ist als eigenständige Hilfsfunktion ausgelagert damit sowohl
|
||||
// das rebuild_index-Tool als auch upload_page sie aufrufen können, ohne Logik
|
||||
// zu duplizieren.
|
||||
func buildWikiIndex(cache *PageCache) (rebuildIndexOutput, error) {
|
||||
entries := cache.All()
|
||||
|
||||
// Seiten nach erstem Tag gruppieren.
|
||||
// Seiten ohne Tag landen in der Gruppe "Allgemein".
|
||||
groups := make(map[string][]CachedEntry)
|
||||
for _, entry := range entries {
|
||||
// _index selbst nicht aufnehmen — sonst würde er sich selbst referenzieren
|
||||
if entry.URLPath == "/_index" {
|
||||
continue
|
||||
}
|
||||
|
||||
category := "Allgemein"
|
||||
if len(entry.Page.Tags) > 0 {
|
||||
category = entry.Page.Tags[0]
|
||||
}
|
||||
groups[category] = append(groups[category], entry)
|
||||
}
|
||||
|
||||
// Kategorien alphabetisch sortieren, "Allgemein" ans Ende
|
||||
categories := make([]string, 0, len(groups))
|
||||
for cat := range groups {
|
||||
categories = append(categories, cat)
|
||||
}
|
||||
sortCategoriesWithFallbackLast(categories, "Allgemein")
|
||||
|
||||
// Markdown aufbauen
|
||||
var sb strings.Builder
|
||||
sb.WriteString("---\n")
|
||||
sb.WriteString("title: Wiki Index\n")
|
||||
sb.WriteString("hidden: true\n")
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString("Automatisch generierter Index aller Wiki-Seiten, gegliedert nach Kategorien.\n")
|
||||
sb.WriteString("Wird bei jedem `upload_page` automatisch aktualisiert.\n\n")
|
||||
|
||||
totalPages := 0
|
||||
for _, cat := range categories {
|
||||
pages := groups[cat]
|
||||
sortEntriesByTitle(pages)
|
||||
|
||||
sb.WriteString("## ")
|
||||
sb.WriteString(cat)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
for _, entry := range pages {
|
||||
title := entry.Page.Title
|
||||
if title == "" {
|
||||
title = entry.URLPath
|
||||
}
|
||||
|
||||
extraTags := filterOutFirst(entry.Page.Tags)
|
||||
tagStr := ""
|
||||
if len(extraTags) > 0 {
|
||||
tagStr = " — Tags: " + strings.Join(extraTags, ", ")
|
||||
}
|
||||
|
||||
protection := ""
|
||||
if entry.Page.Password != "" {
|
||||
protection = " 🔒"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- [%s](%s)%s%s\n", title, entry.URLPath, protection, tagStr))
|
||||
totalPages++
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// _index.md schreiben und Cache aktualisieren
|
||||
fullPath := cache.URLPathToFilePath("/_index.md")
|
||||
if err := os.WriteFile(fullPath, []byte(sb.String()), 0644); err != nil {
|
||||
return rebuildIndexOutput{}, fmt.Errorf("_index.md konnte nicht geschrieben werden: %w", err)
|
||||
}
|
||||
|
||||
if page, err := render.LoadPage(fullPath); err == nil {
|
||||
cache.Set("/_index", page)
|
||||
}
|
||||
|
||||
return rebuildIndexOutput{
|
||||
Message: fmt.Sprintf("_index.md aktualisiert — %d Seiten in %d Kategorien.", totalPages, len(categories)),
|
||||
PageCount: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sortCategoriesWithFallbackLast sortiert eine Kategorienliste alphabetisch,
|
||||
// verschiebt aber eine bestimmte Fallback-Kategorie (z.B. "Allgemein") ans Ende.
|
||||
func sortCategoriesWithFallbackLast(cats []string, fallback string) {
|
||||
// sort.Slice sortiert in-place mit einer benutzerdefinierten Vergleichsfunktion.
|
||||
// "less(i, j) = true" bedeutet: Element i soll vor Element j stehen.
|
||||
sort.Slice(cats, func(i, j int) bool {
|
||||
if cats[i] == fallback {
|
||||
return false // fallback kommt immer nach allem anderen
|
||||
}
|
||||
if cats[j] == fallback {
|
||||
return true // fallback kommt immer nach allem anderen
|
||||
}
|
||||
return cats[i] < cats[j] // sonst alphabetisch
|
||||
})
|
||||
}
|
||||
|
||||
// sortEntriesByTitle sortiert CachedEntries alphabetisch nach dem Seitentitel.
|
||||
func sortEntriesByTitle(entries []CachedEntry) {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Page.Title < entries[j].Page.Title
|
||||
})
|
||||
}
|
||||
|
||||
// filterOutFirst gibt alle Tags einer Seite zurück außer dem ersten.
|
||||
// Der erste Tag wird als Kategorie genutzt und muss nicht doppelt angezeigt werden.
|
||||
func filterOutFirst(tags []string) []string {
|
||||
if len(tags) <= 1 {
|
||||
return nil
|
||||
}
|
||||
return tags[1:]
|
||||
}
|
||||
98
handler/ratelimit.go
Normal file
98
handler/ratelimit.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Dieses File implementiert ein einfaches In-Memory-Rate-Limiting für den Login —
|
||||
// ohne das würde ein Angreifer beliebig oft Zugriffskeys durchprobieren können.
|
||||
//
|
||||
// Fixed-Window-Zähler pro Client-IP: nach loginRateLimitMax fehlgeschlagenen
|
||||
// Versuchen innerhalb von loginRateLimitWindow muss die IP bis zum Fensterende warten.
|
||||
// Ein erfolgreicher Login setzt den Zähler der IP sofort zurück.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
loginRateLimitMax = 5 // erlaubte Fehlversuche pro Fenster
|
||||
loginRateLimitWindow = 1 * time.Minute // Fenstergröße
|
||||
)
|
||||
|
||||
// attemptRecord zählt Fehlversuche einer IP innerhalb eines Zeitfensters.
|
||||
type attemptRecord struct {
|
||||
count int
|
||||
windowEnds time.Time
|
||||
}
|
||||
|
||||
// LoginRateLimiter begrenzt Login-Versuche pro Client-IP.
|
||||
type LoginRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptRecord
|
||||
}
|
||||
|
||||
// NewLoginRateLimiter erstellt einen Rate-Limiter und startet einen Hintergrund-Job
|
||||
// der abgelaufene Einträge regelmäßig entfernt (verhindert unbegrenztes Wachstum
|
||||
// der Map bei vielen verschiedenen IPs, z.B. durch Scanner/Bots).
|
||||
func NewLoginRateLimiter() *LoginRateLimiter {
|
||||
l := &LoginRateLimiter{attempts: make(map[string]*attemptRecord)}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
l.cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// Allow prüft ob die IP noch einen Versuch frei hat, ohne ihn zu verbrauchen.
|
||||
// retryAfter gibt an wie lange bei einer Sperre noch gewartet werden muss.
|
||||
func (l *LoginRateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
rec, exists := l.attempts[ip]
|
||||
now := time.Now()
|
||||
|
||||
if !exists || now.After(rec.windowEnds) {
|
||||
return true, 0
|
||||
}
|
||||
if rec.count >= loginRateLimitMax {
|
||||
return false, rec.windowEnds.Sub(now)
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// RecordFailure zählt einen fehlgeschlagenen Login-Versuch für die IP.
|
||||
func (l *LoginRateLimiter) RecordFailure(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
rec, exists := l.attempts[ip]
|
||||
if !exists || now.After(rec.windowEnds) {
|
||||
rec = &attemptRecord{windowEnds: now.Add(loginRateLimitWindow)}
|
||||
l.attempts[ip] = rec
|
||||
}
|
||||
rec.count++
|
||||
}
|
||||
|
||||
// Reset löscht den Zähler einer IP nach einem erfolgreichen Login.
|
||||
func (l *LoginRateLimiter) Reset(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, ip)
|
||||
}
|
||||
|
||||
// cleanup entfernt abgelaufene Einträge aus der Map.
|
||||
func (l *LoginRateLimiter) cleanup() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for ip, rec := range l.attempts {
|
||||
if now.After(rec.windowEnds) {
|
||||
delete(l.attempts, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
221
handler/registry.go
Normal file
221
handler/registry.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// Dieses File implementiert die TeamRegistry — die zentrale Stelle die weiß
|
||||
// welche Teams es gibt, wo ihre Markdown-Dateien liegen, und welcher Zugriffskey
|
||||
// zu welchem Team gehört.
|
||||
//
|
||||
// Jedes Team hat seinen eigenen PageCache (siehe cache.go) und damit auch seinen
|
||||
// eigenen BM25-Suchindex — Teams sind dadurch vollständig voneinander isoliert,
|
||||
// ohne dass die bestehende PageCache-Logik selbst angepasst werden musste.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Team bündelt alle Informationen zu einem einzelnen Team.
|
||||
type Team struct {
|
||||
ID string // z.B. "alpha" — auch der Ordnername unter dataRoot
|
||||
Name string // Anzeigename, z.B. "Team Alpha"
|
||||
Key string // Zugriffskey aus teams.yaml
|
||||
DocsRoot string // z.B. "data/alpha/content"
|
||||
Cache *PageCache // eigener Cache + Suchindex für dieses Team
|
||||
}
|
||||
|
||||
// TeamRegistry hält alle Teams und ermöglicht die Auflösung eines Zugriffskeys
|
||||
// zu seinem Team (oder zum globalen Admin-Zugang).
|
||||
//
|
||||
// teams kann sich zur Laufzeit ändern (siehe Reload, für Hot-Reload von teams.yaml)
|
||||
// — mu schützt den Zugriff darauf. Ein Team-Objekt selbst wird nach dem Erstellen
|
||||
// nie verändert (siehe Reload): Requests die ein *Team schon aus der Registry
|
||||
// geholt haben, arbeiten also immer mit einem konsistenten, unveränderlichen Snapshot
|
||||
// weiter, auch wenn zwischenzeitlich neu geladen wird.
|
||||
type TeamRegistry struct {
|
||||
mu sync.RWMutex
|
||||
teams map[string]*Team // key: Team-ID
|
||||
adminKey string
|
||||
dataRoot string // gebraucht um bei Reload neue Teams anzulegen
|
||||
}
|
||||
|
||||
// NewTeamRegistry erstellt für jedes konfigurierte Team den Datenordner (falls nötig)
|
||||
// und baut den zugehörigen PageCache auf.
|
||||
func NewTeamRegistry(dataRoot string, configs []TeamConfig, adminKey string) (*TeamRegistry, error) {
|
||||
reg := &TeamRegistry{
|
||||
teams: make(map[string]*Team, len(configs)),
|
||||
adminKey: adminKey,
|
||||
dataRoot: dataRoot,
|
||||
}
|
||||
|
||||
for _, cfg := range configs {
|
||||
docsRoot := filepath.Join(dataRoot, cfg.ID, "content")
|
||||
|
||||
if err := os.MkdirAll(docsRoot, 0755); err != nil {
|
||||
return nil, fmt.Errorf("Ordner für Team %q konnte nicht erstellt werden: %w", cfg.ID, err)
|
||||
}
|
||||
|
||||
cache, err := NewPageCache(docsRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cache für Team %q konnte nicht aufgebaut werden: %w", cfg.ID, err)
|
||||
}
|
||||
|
||||
reg.teams[cfg.ID] = &Team{
|
||||
ID: cfg.ID,
|
||||
Name: cfg.Name,
|
||||
Key: cfg.Key,
|
||||
DocsRoot: docsRoot,
|
||||
Cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// Reload ersetzt die Team-Liste durch eine frisch aus teams.yaml gelesene Konfiguration —
|
||||
// genutzt für Hot-Reload ohne Serverneustart (siehe StartTeamsConfigWatcher).
|
||||
//
|
||||
// Bereits bekannte Teams behalten ihren Cache (kein unnötiger Neuaufbau des
|
||||
// Suchindex bei einer reinen Namens-/Key-Änderung), neue Teams bekommen Ordner
|
||||
// und Cache frisch angelegt. Aus der Config entfernte Teams verschwinden aus der
|
||||
// Registry — ihre Dateien auf der Platte bleiben unangetastet.
|
||||
func (r *TeamRegistry) Reload(configs []TeamConfig) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
newTeams := make(map[string]*Team, len(configs))
|
||||
|
||||
for _, cfg := range configs {
|
||||
docsRoot := filepath.Join(r.dataRoot, cfg.ID, "content")
|
||||
|
||||
var cache *PageCache
|
||||
if existing, ok := r.teams[cfg.ID]; ok {
|
||||
cache = existing.Cache
|
||||
} else {
|
||||
if err := os.MkdirAll(docsRoot, 0755); err != nil {
|
||||
return fmt.Errorf("Ordner für Team %q konnte nicht erstellt werden: %w", cfg.ID, err)
|
||||
}
|
||||
c, err := NewPageCache(docsRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cache für Team %q konnte nicht aufgebaut werden: %w", cfg.ID, err)
|
||||
}
|
||||
cache = c
|
||||
}
|
||||
|
||||
// Immer ein neues Team-Objekt anlegen statt ein bestehendes zu verändern —
|
||||
// so bleiben *Team-Zeiger die laufende Requests bereits halten unverändert
|
||||
// gültig (siehe Doku-Kommentar am Struct).
|
||||
newTeams[cfg.ID] = &Team{
|
||||
ID: cfg.ID,
|
||||
Name: cfg.Name,
|
||||
Key: cfg.Key,
|
||||
DocsRoot: docsRoot,
|
||||
Cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
r.teams = newTeams
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve prüft einen Zugriffskey (aus dem Login-Formular oder dem Authorization-Header)
|
||||
// und gibt das zugehörige Team zurück. Ist der Key der globale Admin-Token, ist isAdmin
|
||||
// true und team ist nil — der Aufrufer muss dann selbst ein Team auswählen (z.B. über
|
||||
// den "team"-Parameter oder die Team-Umschaltung im Web).
|
||||
//
|
||||
// Der Vergleich läuft über subtle.ConstantTimeCompare (wie schon in middleware/auth.go
|
||||
// und webhook.go) um Timing-Angriffe zu erschweren.
|
||||
func (r *TeamRegistry) Resolve(key string) (team *Team, isAdmin bool, ok bool) {
|
||||
if key == "" {
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(key), []byte(r.adminKey)) == 1 {
|
||||
return nil, true, true
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, t := range r.teams {
|
||||
if subtle.ConstantTimeCompare([]byte(key), []byte(t.Key)) == 1 {
|
||||
return t, false, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
// ByID gibt ein Team anhand seiner ID zurück — genutzt vom Admin-Team-Switcher
|
||||
// und vom "team"-Parameter in API/MCP-Requests.
|
||||
func (r *TeamRegistry) ByID(id string) (*Team, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
t, ok := r.teams[id]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// All gibt alle Teams alphabetisch nach ID sortiert zurück.
|
||||
func (r *TeamRegistry) All() []*Team {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
all := make([]*Team, 0, len(r.teams))
|
||||
for _, t := range r.teams {
|
||||
all = append(all, t)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID })
|
||||
return all
|
||||
}
|
||||
|
||||
// RebuildAll baut den Cache jedes Teams neu auf — genutzt vom Webhook nach einem
|
||||
// "git pull" über das gesamte data-Verzeichnis.
|
||||
func (r *TeamRegistry) RebuildAll() error {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for id, t := range r.teams {
|
||||
if err := t.Cache.build(); err != nil {
|
||||
return fmt.Errorf("Cache für Team %q konnte nicht neu aufgebaut werden: %w", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Request-Context ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Nach erfolgreicher Authentifizierung (Web-Session-Cookie oder Bearer-Key)
|
||||
// legt die jeweilige Middleware das aufgelöste Team im Request-Context ab.
|
||||
// Handler lesen es über TeamFromContext statt über ein festes Struct-Feld —
|
||||
// so kann derselbe Handler je nach Request ein anderes Team bedienen.
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
teamContextKey contextKey = iota
|
||||
adminContextKey
|
||||
)
|
||||
|
||||
// WithTeam legt ein aufgelöstes Team (oder den Admin-Status) im Context ab.
|
||||
// team ist nil wenn isAdmin true ist und noch kein aktives Team gewählt wurde.
|
||||
func WithTeam(ctx context.Context, team *Team, isAdmin bool) context.Context {
|
||||
ctx = context.WithValue(ctx, adminContextKey, isAdmin)
|
||||
if team != nil {
|
||||
ctx = context.WithValue(ctx, teamContextKey, team)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// TeamFromContext liest das aktive Team aus dem Context.
|
||||
func TeamFromContext(ctx context.Context) (*Team, bool) {
|
||||
t, ok := ctx.Value(teamContextKey).(*Team)
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// IsAdminFromContext gibt zurück ob der Request mit dem globalen Admin-Zugang authentifiziert wurde.
|
||||
func IsAdminFromContext(ctx context.Context) bool {
|
||||
isAdmin, _ := ctx.Value(adminContextKey).(bool)
|
||||
return isAdmin
|
||||
}
|
||||
309
handler/search.go
Normal file
309
handler/search.go
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
// Dieses File implementiert einen In-Memory-Volltextindex mit BM25-Ranking.
|
||||
//
|
||||
// # Was ist BM25?
|
||||
//
|
||||
// BM25 (Best Match 25) ist der Standardalgorithmus moderner Suchmaschinen —
|
||||
// er steckt hinter Elasticsearch, Solr, Lucene und den meisten anderen Systemen.
|
||||
// Er verbessert das ältere TF-IDF durch zwei wichtige Korrekturen:
|
||||
//
|
||||
// 1. Sättigungsfaktor (k1): Ein Begriff der 10x so oft vorkommt wie ein anderer
|
||||
// ist nicht 10x so relevant. Ab einem gewissen Punkt "sättigt" der Nutzen —
|
||||
// das 20. Vorkommen eines Worts bringt kaum mehr Relevanz als das 10.
|
||||
//
|
||||
// 2. Längennormalisierung (b): Kurze Dokumente sollen keinen unfairen Vorteil haben.
|
||||
// "Deployment" in einem 50-Wort-Artikel ist relevanter als dasselbe Wort
|
||||
// in einem 2000-Wort-Dokument, in dem es nur am Rand auftaucht.
|
||||
//
|
||||
// # Wie funktioniert ein invertierter Index?
|
||||
//
|
||||
// Statt für jeden Suchbegriff alle Dokumente zu lesen, dreht ein invertierter Index
|
||||
// die Beziehung um:
|
||||
//
|
||||
// Normaler Index: Dokument → enthaltene Begriffe
|
||||
// Invertierter Index: Begriff → Dokumente die ihn enthalten
|
||||
//
|
||||
// Beispiel:
|
||||
//
|
||||
// "deployment" → ["/server-setup.md" (3x), "/pipeline.md" (1x)]
|
||||
// "nginx" → ["/server-setup.md" (5x), "/reverse-proxy.md" (2x)]
|
||||
//
|
||||
// Bei einer Suche wird nur noch in dieser Map nachgeschlagen — kein Dateisystem-Zugriff,
|
||||
// keine Schleife über alle Dokumente. Das macht die Suche sehr schnell.
|
||||
//
|
||||
// # Implementierungsdetails
|
||||
//
|
||||
// Der Index ist komplett im Arbeitsspeicher (kein Dateisystem).
|
||||
// Beim Serverstart wird er aus den .md-Dateien neu aufgebaut — konsistent mit dem PageCache.
|
||||
// Titel-Treffer werden mit Faktor 3 höher gewichtet als Body-Treffer.
|
||||
// Tags werden mit Faktor 2 gewichtet.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// BM25-Tuning-Parameter — Standardwerte aus der Originalliteratur (Robertson et al. 1994).
|
||||
const (
|
||||
// bm25K1 steuert die Sättigungskurve der Termhäufigkeit.
|
||||
// Typischer Bereich: 1.2–2.0. Höher = längere Sättigungskurve.
|
||||
bm25K1 = 1.2
|
||||
|
||||
// bm25B steuert die Längennormalisierung.
|
||||
// 0.0 = keine Normalisierung, 1.0 = vollständige Normalisierung.
|
||||
// 0.75 ist der Standardwert.
|
||||
bm25B = 0.75
|
||||
|
||||
// titleBoost: Treffer im Titel gelten als 3x relevanter als im Body.
|
||||
// Technisch umgesetzt durch Vervielfältigung der Titel-Tokens beim Indexieren.
|
||||
titleBoost = 3
|
||||
|
||||
// tagsBoost: Treffer in Tags gelten als 2x relevanter als im Body.
|
||||
tagsBoost = 2
|
||||
)
|
||||
|
||||
// posting speichert wie oft ein Term in einem konkreten Dokument vorkommt.
|
||||
// Es ist der Grundbaustein des invertierten Index.
|
||||
type posting struct {
|
||||
docID string // URL-Pfad des Dokuments, z.B. "/projekte/notizen"
|
||||
freq int // Häufigkeit des Terms in diesem Dokument
|
||||
}
|
||||
|
||||
// docMeta speichert die Metadaten die BM25 pro Dokument benötigt.
|
||||
type docMeta struct {
|
||||
length int // Anzahl der Terme (nach Tokenisierung und Boost)
|
||||
title string // Titel für die Ergebnisanzeige
|
||||
}
|
||||
|
||||
// SearchResult ist ein einzelnes Suchergebnis mit BM25-Score.
|
||||
type SearchResult struct {
|
||||
URLPath string // URL-Pfad der Seite, z.B. "/projekte/notizen"
|
||||
Score float64 // BM25-Relevanz-Score — nur für internes Ranking, kein fester Wertebereich
|
||||
}
|
||||
|
||||
// SearchIndex kapselt den invertierten Index und alle dazugehörigen Daten.
|
||||
// Er ist threadsicher durch eine RWMutex (mehrere Leser gleichzeitig erlaubt,
|
||||
// aber nur ein Schreiber auf einmal).
|
||||
type SearchIndex struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// invertedIndex ist der Kern: Term → Liste aller Dokumente mit Häufigkeit.
|
||||
// map[string][]posting: "nginx" → [{"/server.md", 5}, {"/proxy.md", 2}]
|
||||
invertedIndex map[string][]posting
|
||||
|
||||
// docMeta speichert Metadaten je Dokument — für BM25-Berechnungen nötig.
|
||||
docs map[string]docMeta
|
||||
|
||||
// docTerms ist ein Rückwärts-Index: Dokument → Liste seiner Terme.
|
||||
// Er macht das Aktualisieren/Löschen effizient:
|
||||
// ohne ihn müsste man beim Löschen den gesamten invertedIndex durchsuchen.
|
||||
docTerms map[string][]string
|
||||
|
||||
// totalTerms ist die Summe der Dokumentlängen — für die Durchschnittslänge.
|
||||
totalTerms int
|
||||
}
|
||||
|
||||
// NewSearchIndex erstellt einen leeren, einsatzbereiten SearchIndex.
|
||||
func NewSearchIndex() *SearchIndex {
|
||||
return &SearchIndex{
|
||||
invertedIndex: make(map[string][]posting),
|
||||
docs: make(map[string]docMeta),
|
||||
docTerms: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Index fügt ein Dokument zum Suchindex hinzu oder aktualisiert es.
|
||||
// Falls urlPath bereits existiert, wird der alte Eintrag zuerst entfernt.
|
||||
//
|
||||
// title, body und tags werden getrennt übergeben um unterschiedliche
|
||||
// Boost-Faktoren anwenden zu können.
|
||||
func (s *SearchIndex) Index(urlPath, title, body string, tags []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Zuerst alten Eintrag entfernen falls vorhanden (Update-Logik).
|
||||
s.removeUnsafe(urlPath)
|
||||
|
||||
// Tokens aus allen Quellen mit Boost-Gewichtung sammeln.
|
||||
// Boost durch Wiederholung: "nginx" im Titel → 3x "nginx" in der Tokenliste.
|
||||
// Das erhöht die Termhäufigkeit (TF) künstlich für Titel-Treffer.
|
||||
var allTokens []string
|
||||
allTokens = append(allTokens, repeatTokens(tokenize(title), titleBoost)...)
|
||||
allTokens = append(allTokens, tokenize(body)...)
|
||||
allTokens = append(allTokens, repeatTokens(tokenize(strings.Join(tags, " ")), tagsBoost)...)
|
||||
|
||||
if len(allTokens) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Termhäufigkeit pro Token zählen: "nginx" vorkommt 5x → {nginx: 5}
|
||||
termFreq := make(map[string]int, len(allTokens))
|
||||
for _, t := range allTokens {
|
||||
termFreq[t]++
|
||||
}
|
||||
|
||||
// Invertierter Index aufbauen: für jeden Term einen Posting-Eintrag hinzufügen.
|
||||
termList := make([]string, 0, len(termFreq))
|
||||
for term, freq := range termFreq {
|
||||
s.invertedIndex[term] = append(s.invertedIndex[term], posting{
|
||||
docID: urlPath,
|
||||
freq: freq,
|
||||
})
|
||||
termList = append(termList, term)
|
||||
}
|
||||
|
||||
// Metadaten und Rückwärts-Index speichern.
|
||||
s.docs[urlPath] = docMeta{length: len(allTokens), title: title}
|
||||
s.docTerms[urlPath] = termList
|
||||
s.totalTerms += len(allTokens)
|
||||
}
|
||||
|
||||
// Remove entfernt ein Dokument vollständig aus dem Index.
|
||||
func (s *SearchIndex) Remove(urlPath string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.removeUnsafe(urlPath)
|
||||
}
|
||||
|
||||
// removeUnsafe entfernt ein Dokument ohne Lock — nur aus internen Methoden aufrufen
|
||||
// die bereits einen Lock halten.
|
||||
func (s *SearchIndex) removeUnsafe(urlPath string) {
|
||||
meta, exists := s.docs[urlPath]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Alle Postings für dieses Dokument aus dem invertierten Index entfernen.
|
||||
// Wir nutzen docTerms um zu wissen welche Terme betroffen sind —
|
||||
// viel schneller als den gesamten Index zu scannen.
|
||||
for _, term := range s.docTerms[urlPath] {
|
||||
postings := s.invertedIndex[term]
|
||||
filtered := postings[:0] // wiederverwendet das bestehende Slice (kein Speicher-Allokierung)
|
||||
for _, p := range postings {
|
||||
if p.docID != urlPath {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
delete(s.invertedIndex, term)
|
||||
} else {
|
||||
s.invertedIndex[term] = filtered
|
||||
}
|
||||
}
|
||||
|
||||
s.totalTerms -= meta.length
|
||||
delete(s.docs, urlPath)
|
||||
delete(s.docTerms, urlPath)
|
||||
}
|
||||
|
||||
// Search führt eine BM25-Suche für den übergebenen Suchbegriff durch.
|
||||
// Die Ergebnisse sind absteigend nach Relevanz sortiert (bester Treffer zuerst).
|
||||
// limit begrenzt die Anzahl der zurückgegebenen Ergebnisse.
|
||||
func (s *SearchIndex) Search(queryStr string, limit int) []SearchResult {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
queryTokens := tokenize(queryStr)
|
||||
if len(queryTokens) == 0 || len(s.docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gesamtanzahl Dokumente und Durchschnittslänge für BM25 berechnen.
|
||||
N := float64(len(s.docs))
|
||||
avgDocLen := float64(s.totalTerms) / N
|
||||
|
||||
// scores sammelt den BM25-Score je Dokument über alle Query-Terme.
|
||||
// map[docID]score
|
||||
scores := make(map[string]float64)
|
||||
|
||||
for _, term := range queryTokens {
|
||||
postings, found := s.invertedIndex[term]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
// IDF (Inverse Document Frequency):
|
||||
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
|
||||
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1)
|
||||
// nq = Anzahl der Dokumente die diesen Term enthalten
|
||||
nq := float64(len(postings))
|
||||
idf := math.Log((N-nq+0.5)/(nq+0.5) + 1)
|
||||
|
||||
for _, p := range postings {
|
||||
meta := s.docs[p.docID]
|
||||
docLen := float64(meta.length)
|
||||
|
||||
// BM25-Termgewicht:
|
||||
// Zähler: tf * (k1 + 1) — gibt bei tf=0 den Wert 0
|
||||
// Nenner: tf + k1 * (1 - b + b * |D| / avgdl) — beugt Übergewichtung vor
|
||||
tf := float64(p.freq)
|
||||
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
|
||||
|
||||
scores[p.docID] += idf * tfNorm
|
||||
}
|
||||
}
|
||||
|
||||
if len(scores) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ergebnisse in eine sortierbare Liste umwandeln.
|
||||
results := make([]SearchResult, 0, len(scores))
|
||||
for docID, score := range scores {
|
||||
results = append(results, SearchResult{URLPath: docID, Score: score})
|
||||
}
|
||||
|
||||
// Absteigend nach Score sortieren (bester Treffer zuerst).
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
// Auf limit begrenzen.
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// tokenize zerlegt einen Text in Terme für die Indizierung.
|
||||
// Es werden nur Buchstaben und Ziffern behalten, alles andere gilt als Trennzeichen.
|
||||
// Einzeichen-Tokens werden ignoriert (kaum aussagekräftig).
|
||||
func tokenize(text string) []string {
|
||||
text = strings.ToLower(text)
|
||||
var tokens []string
|
||||
var current strings.Builder
|
||||
|
||||
for _, r := range text {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
current.WriteRune(r)
|
||||
} else {
|
||||
if current.Len() > 1 {
|
||||
tokens = append(tokens, current.String())
|
||||
}
|
||||
current.Reset()
|
||||
}
|
||||
}
|
||||
// Letztes Token nicht vergessen
|
||||
if current.Len() > 1 {
|
||||
tokens = append(tokens, current.String())
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
// repeatTokens wiederholt eine Tokenliste n-mal — damit wird Boost durch
|
||||
// erhöhte Termhäufigkeit (TF) simuliert, ohne die BM25-Formel zu verändern.
|
||||
func repeatTokens(tokens []string, n int) []string {
|
||||
if n <= 1 {
|
||||
return tokens
|
||||
}
|
||||
result := make([]string, 0, len(tokens)*n)
|
||||
for i := 0; i < n; i++ {
|
||||
result = append(result, tokens...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
87
handler/session.go
Normal file
87
handler/session.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Dieses File implementiert die signierte Web-Session für den Team-Login.
|
||||
//
|
||||
// Anders als beim bestehenden Seiten-Passwort-Feature (das den Klartext-Wert
|
||||
// direkt als Cookie speichert) wird hier NICHT der Zugriffskey im Cookie
|
||||
// abgelegt, sondern nur eine signierte Behauptung ("ich bin Team X, gültig bis Y").
|
||||
// Der Server prüft bei jedem Request die Signatur (HMAC-SHA256) und das Ablaufdatum —
|
||||
// ein Angreifer kann den Cookie-Inhalt zwar lesen, aber nicht fälschen, weil er
|
||||
// das SESSION_SECRET nicht kennt.
|
||||
//
|
||||
// Liegt im handler-Package (nicht middleware), weil sowohl die Login-Handler hier
|
||||
// (Session erzeugen) als auch middleware.RequireTeamSession (Session prüfen) darauf
|
||||
// zugreifen müssen — middleware importiert handler, nicht umgekehrt.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookieName ist der Name des Cookies der die signierte Session trägt.
|
||||
const SessionCookieName = "wiki_session"
|
||||
|
||||
// SessionTTL bestimmt wie lange eine Session ohne erneuten Login gültig bleibt.
|
||||
const SessionTTL = 30 * 24 * time.Hour
|
||||
|
||||
// signPayload berechnet den HMAC-SHA256 eines Payloads mit dem Session-Secret.
|
||||
func signPayload(payload string, secret []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(payload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// SignSession erstellt einen signierten Cookie-Wert für das gegebene "subject".
|
||||
//
|
||||
// subject ist eine von drei Formen:
|
||||
// - "team:<id>" — normale Team-Session
|
||||
// - "admin" — Admin-Session ohne aktives Team (zeigt Team-Übersicht)
|
||||
// - "admin:<id>" — Admin-Session mit über /switch-team gewähltem aktivem Team
|
||||
func SignSession(subject string, secret []byte) string {
|
||||
expiry := time.Now().Add(SessionTTL).Unix()
|
||||
payload := fmt.Sprintf("%s|%d", subject, expiry)
|
||||
sig := signPayload(payload, secret)
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
return encoded + "." + sig
|
||||
}
|
||||
|
||||
// VerifySession prüft Signatur und Ablaufdatum eines Cookie-Werts und gibt das
|
||||
// enthaltene subject zurück (siehe SignSession für das Format).
|
||||
func VerifySession(cookieValue string, secret []byte) (subject string, ok bool) {
|
||||
parts := strings.SplitN(cookieValue, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
payload := string(payloadBytes)
|
||||
|
||||
expectedSig := signPayload(payload, secret)
|
||||
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(expectedSig)) != 1 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
fields := strings.SplitN(payload, "|", 2)
|
||||
if len(fields) != 2 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
expiry, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().Unix() > expiry {
|
||||
return "", false // Session abgelaufen
|
||||
}
|
||||
|
||||
return fields[0], true
|
||||
}
|
||||
115
handler/teams_config.go
Normal file
115
handler/teams_config.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Dieses File lädt die Team-Konfiguration aus einer YAML-Datei (z.B. teams.yaml).
|
||||
//
|
||||
// Jedes Team bekommt einen eigenen Zugriffskey und einen eigenen Ordner unter
|
||||
// dem Daten-Wurzelverzeichnis (data/<id>/content). Die teams.yaml enthält
|
||||
// Klartext-Keys — genau wie ADMIN_TOKEN/MCP_TOKEN heute in .env — und gehört
|
||||
// deshalb NIE ins Git-Repository (siehe .gitignore).
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// teamIDPattern erlaubt nur kleine Buchstaben, Ziffern, Bindestrich und Unterstrich.
|
||||
// Das verhindert Path Traversal über die Team-ID (z.B. "../../etc" als ID)
|
||||
// und stellt sicher, dass die ID direkt als Ordnername verwendet werden kann.
|
||||
var teamIDPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
|
||||
|
||||
// TeamConfig ist ein einzelner Team-Eintrag aus der YAML-Datei.
|
||||
type TeamConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
// teamsFile bildet die Struktur der teams.yaml ab (Wurzelelement "teams").
|
||||
type teamsFile struct {
|
||||
Teams []TeamConfig `yaml:"teams"`
|
||||
}
|
||||
|
||||
// LoadTeamsConfig liest und validiert die Team-Konfiguration aus dem angegebenen Pfad.
|
||||
func LoadTeamsConfig(path string) ([]TeamConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Team-Konfiguration konnte nicht gelesen werden (%s): %w", path, err)
|
||||
}
|
||||
|
||||
var parsed teamsFile
|
||||
if err := yaml.Unmarshal(data, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("Team-Konfiguration ist kein gültiges YAML: %w", err)
|
||||
}
|
||||
|
||||
if len(parsed.Teams) == 0 {
|
||||
return nil, fmt.Errorf("Team-Konfiguration enthält keine Teams")
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(parsed.Teams))
|
||||
for _, team := range parsed.Teams {
|
||||
if !teamIDPattern.MatchString(team.ID) {
|
||||
return nil, fmt.Errorf("ungültige Team-ID %q — erlaubt sind nur a-z, 0-9, '-' und '_'", team.ID)
|
||||
}
|
||||
if team.ID == "admin" {
|
||||
return nil, fmt.Errorf("Team-ID \"admin\" ist reserviert und darf nicht vergeben werden")
|
||||
}
|
||||
if team.Key == "" {
|
||||
return nil, fmt.Errorf("Team %q hat keinen Key gesetzt", team.ID)
|
||||
}
|
||||
if seen[team.ID] {
|
||||
return nil, fmt.Errorf("Team-ID %q ist mehrfach vergeben", team.ID)
|
||||
}
|
||||
seen[team.ID] = true
|
||||
}
|
||||
|
||||
return parsed.Teams, nil
|
||||
}
|
||||
|
||||
// StartTeamsConfigWatcher prüft alle interval einmal die Änderungszeit der Team-
|
||||
// Konfigurationsdatei und lädt sie bei einer Änderung neu in die Registry — so
|
||||
// wirken neue/geänderte/entfernte Teams ohne Serverneustart.
|
||||
//
|
||||
// Ein einfacher mtime-Poll reicht hier völlig aus (eine einzelne kleine Datei,
|
||||
// Änderungen sind selten) und braucht keine zusätzliche Dependency wie fsnotify.
|
||||
// Bei einem Fehler (Datei kurzzeitig unlesbar während des Speicherns, ungültiges
|
||||
// YAML) wird die Änderung übersprungen und beim nächsten Tick erneut versucht —
|
||||
// der Server läuft mit der zuletzt gültigen Konfiguration weiter.
|
||||
func StartTeamsConfigWatcher(path string, registry *TeamRegistry, interval time.Duration) {
|
||||
var lastModTime time.Time
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
lastModTime = info.ModTime()
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !info.ModTime().After(lastModTime) {
|
||||
continue
|
||||
}
|
||||
lastModTime = info.ModTime()
|
||||
|
||||
configs, err := LoadTeamsConfig(path)
|
||||
if err != nil {
|
||||
log.Printf("teams.yaml geändert, aber ungültig — Änderung wird ignoriert: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := registry.Reload(configs); err != nil {
|
||||
log.Printf("teams.yaml: Reload fehlgeschlagen: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("teams.yaml neu geladen (%d Teams)", len(configs))
|
||||
}
|
||||
}()
|
||||
}
|
||||
202
handler/webhook.go
Normal file
202
handler/webhook.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// Dieses File implementiert den Webhook-Handler für automatische Git-Synchronisation.
|
||||
//
|
||||
// # Wie funktioniert ein Webhook?
|
||||
//
|
||||
// Ein Webhook ist ein HTTP-Request den ein externer Dienst (GitHub, GitLab, Bitbucket)
|
||||
// automatisch an eine URL schickt sobald ein bestimmtes Ereignis eintritt — in unserem
|
||||
// Fall: ein Push in das Wiki-Docs-Repository.
|
||||
//
|
||||
// Ablauf:
|
||||
// 1. Nutzer pusht neue/geänderte Markdown-Dateien in das Git-Repository
|
||||
// 2. GitHub/GitLab/Bitbucket schickt POST /api/webhook an den Wiki-Server
|
||||
// 3. Der Server prüft die Signatur (war das wirklich GitHub/GitLab/Bitbucket?)
|
||||
// 4. Der Server führt "git pull" im data-Wurzelverzeichnis aus (alle Teams liegen
|
||||
// in einem gemeinsamen Git-Repository)
|
||||
// 5. Der Cache jedes Teams wird neu aufgebaut
|
||||
// 6. Änderungen sind sofort im Wiki sichtbar — kein Neustart nötig
|
||||
//
|
||||
// # Signaturprüfung
|
||||
//
|
||||
// Jeder Anbieter signiert seinen Webhook-Request mit einem gemeinsamen Secret
|
||||
// das du beim Einrichten des Webhooks angibst. Der Server prüft diese Signatur
|
||||
// bevor er irgendetwas tut — sonst könnte jeder einen Request schicken und
|
||||
// einen git pull auslösen.
|
||||
//
|
||||
// GitHub: Header "X-Hub-Signature-256" — HMAC-SHA256 des Request-Body
|
||||
// GitLab: Header "X-Gitlab-Token" — direkter Token-Vergleich
|
||||
// Bitbucket: Header "X-Hub-Signature" — HMAC-SHA256 des Request-Body
|
||||
//
|
||||
// HMAC (Hash-based Message Authentication Code) ist ein kryptografisches Verfahren:
|
||||
// Sender und Empfänger kennen ein gemeinsames Secret. Der Sender berechnet damit
|
||||
// einen Hash des Inhalts — der Empfänger berechnet denselben Hash und vergleicht.
|
||||
// Stimmen beide überein, ist der Absender verifiziert.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WebhookHandler verarbeitet eingehende Webhook-Requests von Git-Anbietern.
|
||||
type WebhookHandler struct {
|
||||
secret string // gemeinsames Secret mit dem Git-Anbieter
|
||||
dataRoot string // Wurzelverzeichnis aller Teams (muss ein Git-Repository sein)
|
||||
registry *TeamRegistry // wird nach dem Pull komplett neu aufgebaut
|
||||
audit *AuditLogger
|
||||
}
|
||||
|
||||
// NewWebhookHandler erstellt einen neuen WebhookHandler.
|
||||
func NewWebhookHandler(dataRoot, secret string, registry *TeamRegistry, audit *AuditLogger) *WebhookHandler {
|
||||
return &WebhookHandler{
|
||||
secret: secret,
|
||||
dataRoot: dataRoot,
|
||||
registry: registry,
|
||||
audit: audit,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP ist der Haupt-Eintrittspunkt für alle Webhook-Requests.
|
||||
func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Den Request-Body einmal komplett lesen und als Byte-Slice speichern.
|
||||
// Wir brauchen die rohen Bytes für die HMAC-Berechnung — danach kann der Body
|
||||
// nicht mehr gelesen werden (er ist ein Stream der nur einmal durchläuft).
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Body konnte nicht gelesen werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Den Git-Anbieter anhand der Headers erkennen und die Signatur prüfen.
|
||||
provider, ok := h.detectAndVerify(r, body)
|
||||
if !ok {
|
||||
http.Error(w, "Ungültige Webhook-Signatur", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Webhook empfangen von: %s", provider)
|
||||
|
||||
// git pull ausführen und Cache aktualisieren.
|
||||
if err := h.syncAndUpdate(); err != nil {
|
||||
log.Printf("Webhook-Sync fehlgeschlagen: %v", err)
|
||||
h.audit.Log(AuditEvent{Actor: "webhook", Action: "webhook_sync", Source: "webhook", Detail: err.Error()})
|
||||
http.Error(w, fmt.Sprintf("Sync fehlgeschlagen: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.audit.Log(AuditEvent{Actor: "webhook", Action: "webhook_sync", Source: "webhook", Detail: provider})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "Sync erfolgreich")
|
||||
}
|
||||
|
||||
// detectAndVerify erkennt den Git-Anbieter anhand der Request-Headers und prüft die Signatur.
|
||||
// Gibt den Anbieternamen und true zurück wenn die Signatur gültig ist.
|
||||
func (h *WebhookHandler) detectAndVerify(r *http.Request, body []byte) (string, bool) {
|
||||
// GitHub: Header "X-Hub-Signature-256" mit Format "sha256=<hex>"
|
||||
if sig := r.Header.Get("X-Hub-Signature-256"); sig != "" {
|
||||
return "GitHub", verifyHMACSHA256(body, sig, h.secret)
|
||||
}
|
||||
|
||||
// Bitbucket Cloud: Header "X-Hub-Signature" mit Format "sha256=<hex>"
|
||||
// (gleiche Methode wie GitHub, anderer Header-Name)
|
||||
if sig := r.Header.Get("X-Hub-Signature"); sig != "" {
|
||||
return "Bitbucket", verifyHMACSHA256(body, sig, h.secret)
|
||||
}
|
||||
|
||||
// GitLab: Header "X-Gitlab-Token" enthält den Token direkt (kein HMAC)
|
||||
if token := r.Header.Get("X-Gitlab-Token"); token != "" {
|
||||
// subtle.ConstantTimeCompare verhindert Timing-Angriffe (wie in middleware/auth.go)
|
||||
valid := subtle.ConstantTimeCompare([]byte(token), []byte(h.secret)) == 1
|
||||
return "GitLab", valid
|
||||
}
|
||||
|
||||
// Kein bekannter Anbieter-Header gefunden
|
||||
return "unbekannt", false
|
||||
}
|
||||
|
||||
// syncAndUpdate führt "git pull" im data-Wurzelverzeichnis aus und baut danach
|
||||
// den Cache jedes Teams neu auf.
|
||||
//
|
||||
// Anders als früher (ein Cache für alle Docs) reicht ein einfacher "diff --name-only"
|
||||
// hier nicht mehr sauber aus, weil sich geänderte Dateien über mehrere Team-Ordner
|
||||
// verteilen können — ein kompletter Rebuild pro Team ist einfacher und robust genug,
|
||||
// da Webhook-Aufrufe selten sind (nur bei einem Git-Push).
|
||||
func (h *WebhookHandler) syncAndUpdate() error {
|
||||
pullOutput, err := runGit(h.dataRoot, "pull")
|
||||
if err != nil {
|
||||
return fmt.Errorf("git pull fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
if strings.Contains(pullOutput, "Already up to date") {
|
||||
log.Println("Webhook: Keine Änderungen im Repository")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := h.registry.RebuildAll(); err != nil {
|
||||
return fmt.Errorf("Cache-Rebuild nach Pull fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Webhook: alle Team-Caches neu aufgebaut")
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyHMACSHA256 prüft eine HMAC-SHA256-Signatur.
|
||||
//
|
||||
// HMAC-SHA256 funktioniert so:
|
||||
// 1. Aus dem Secret und dem Body wird mit SHA-256 ein Hash berechnet
|
||||
// 2. Der Anbieter schickt diesen Hash im Header mit
|
||||
// 3. Wir berechnen denselben Hash und vergleichen
|
||||
//
|
||||
// Format der Signatur: "sha256=<hex-digest>"
|
||||
// Wird für GitHub und Bitbucket verwendet.
|
||||
func verifyHMACSHA256(body []byte, signature, secret string) bool {
|
||||
// Die Signatur hat das Format "sha256=abc123..." — wir brauchen nur den Hex-Teil
|
||||
parts := strings.SplitN(signature, "=", 2)
|
||||
if len(parts) != 2 || parts[0] != "sha256" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Unseren eigenen HMAC berechnen
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
// Timing-sicherer Vergleich (wie in middleware/auth.go erklärt)
|
||||
return subtle.ConstantTimeCompare([]byte(parts[1]), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
// runGit führt einen git-Befehl im angegebenen Verzeichnis aus und gibt die Ausgabe zurück.
|
||||
// Das "-C <dir>" Flag sagt git in welchem Ordner es arbeiten soll.
|
||||
func runGit(dir string, args ...string) (string, error) {
|
||||
// exec.Command erstellt einen Subprozess — wie das Terminal einen Befehl ausführt.
|
||||
// Wir hängen "-C dir" vor die eigentlichen Argumente damit git im richtigen Ordner läuft.
|
||||
fullArgs := append([]string{"-C", dir}, args...)
|
||||
cmd := exec.Command("git", fullArgs...)
|
||||
|
||||
// cmd.Output() startet den Prozess, wartet bis er fertig ist und gibt stdout zurück.
|
||||
// Bei einem Fehler (Exit-Code != 0) ist err != nil und enthält stderr.
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// exec.ExitError enthält stderr — nützlich für Fehlermeldungen
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("%w\nstderr: %s", err, string(exitErr.Stderr))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
435
handler/wiki.go
Normal file
435
handler/wiki.go
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
// Package handler enthält alle HTTP-Handler — also die Funktionen die auf
|
||||
// eingehende Browser-Anfragen reagieren.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"wiki/render"
|
||||
)
|
||||
|
||||
// navItem repräsentiert einen Eintrag in der Sidebar-Navigation.
|
||||
// Einträge können entweder eine Seite (URL gesetzt) oder ein Ordner (Children gesetzt) sein.
|
||||
type navItem struct {
|
||||
Title string // Anzeigename, z.B. "Startseite" oder "Projekte"
|
||||
URL string // Link zur Seite — leer wenn es ein Ordner ist
|
||||
Children []navItem // Untereinträge — nur bei Ordnern befüllt
|
||||
}
|
||||
|
||||
// pageTemplateData bündelt alle Daten die das page.html-Template braucht.
|
||||
// Das Template greift mit {{.Title}}, {{.Body}} usw. auf diese Felder zu.
|
||||
//
|
||||
// Bei einer Suchanfrage sind Query und SearchResults gesetzt — das Template
|
||||
// blendet dann die Ergebnisse statt des normalen Body ein.
|
||||
type pageTemplateData struct {
|
||||
Title string
|
||||
Body template.HTML // template.HTML sagt Go: diesen String NICHT escapen — er ist bereits sicheres HTML
|
||||
Nav []navItem
|
||||
Query string // Aktueller Suchbegriff — für die Vorausfüllung des Suchfelds
|
||||
SearchResults []searchResultItem // Suchergebnisse — nil = normale Seitenansicht
|
||||
NoResults bool // true wenn Suche durchgeführt wurde, aber nichts gefunden wurde
|
||||
TeamName string // Name des aktiven Teams — für die Anzeige in der Sidebar
|
||||
IsAdmin bool // true wenn mit dem globalen Admin-Zugang eingeloggt — zeigt den "Team wechseln"-Link
|
||||
}
|
||||
|
||||
// passwordTemplateData bündelt alle Daten für das password.html-Template.
|
||||
type passwordTemplateData struct {
|
||||
Path string // Der Pfad der geschützten Seite, z.B. "projekte/internes"
|
||||
Error string // Fehlermeldung bei falschem Passwort (leer = kein Fehler)
|
||||
}
|
||||
|
||||
// searchResultItem ist ein einzelnes Suchergebnis für die Template-Ausgabe.
|
||||
type searchResultItem struct {
|
||||
Title string // Seitentitel
|
||||
URL string // URL der Seite
|
||||
Snippet string // Textstelle um den Treffer herum
|
||||
}
|
||||
|
||||
// WikiHandler ist eine Struct die alle nötigen Konfigurationen für den Wiki-Handler hält.
|
||||
// docsRoot und Cache gibt es nicht mehr als feste Felder — welches Team bedient wird,
|
||||
// entscheidet sich pro Request über das Team im Request-Context (siehe registry.go),
|
||||
// das die vorgeschaltete Session-Middleware dort ablegt.
|
||||
type WikiHandler struct {
|
||||
pageTemplate *template.Template // Das geladene page.html-Template
|
||||
passwordTemplate *template.Template // Das geladene password.html-Template
|
||||
|
||||
// rateLimiter begrenzt Versuche gegen das Seiten-Passwort (siehe HandlePasswordSubmit) —
|
||||
// ohne das könnte ein Angreifer beliebig oft raten, genau wie beim Team-Login
|
||||
// (siehe LoginRateLimiter in ratelimit.go, hier wiederverwendet).
|
||||
rateLimiter *LoginRateLimiter
|
||||
}
|
||||
|
||||
// NewWikiHandler erstellt einen neuen WikiHandler und lädt die Templates.
|
||||
//
|
||||
// templatesFS ist ein fs.FS — ein virtuelles Dateisystem das die Template-Dateien enthält.
|
||||
// In der Praxis ist das ein embed.FS aus main.go, das die Dateien direkt im Binary trägt.
|
||||
// fs.FS ist ein Interface (eine Schnittstelle) — das bedeutet: die Funktion akzeptiert
|
||||
// jeden Typ der Dateien lesen kann, nicht nur embed.FS. Das macht den Code flexibler.
|
||||
func NewWikiHandler(templatesFS fs.FS) (*WikiHandler, error) {
|
||||
// template.ParseFS liest Templates aus einem fs.FS statt vom Dateisystem.
|
||||
// Da das embed.FS den vollen Pfad enthält (z.B. "templates/page.html"),
|
||||
// geben wir genau diesen Pfad an.
|
||||
pageTmpl, err := template.ParseFS(templatesFS, "templates/page.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passwordTmpl, err := template.ParseFS(templatesFS, "templates/password.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &WikiHandler{
|
||||
pageTemplate: pageTmpl,
|
||||
passwordTemplate: passwordTmpl,
|
||||
rateLimiter: NewLoginRateLimiter(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ServeHTTP ist die Hauptfunktion des WikiHandlers.
|
||||
// Sie wird bei jedem GET-Request auf eine Wiki-Seite aufgerufen.
|
||||
// (w = ResponseWriter = womit wir antworten; r = Request = was der Browser geschickt hat)
|
||||
func (h *WikiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Das Team wurde von middleware.RequireTeamSession bereits aufgelöst und im
|
||||
// Context abgelegt. Kein Team → Admin ohne aktive Auswahl (zur Team-Übersicht)
|
||||
// oder ein Zustand der eigentlich nicht vorkommen sollte (zur Sicherheit: /login).
|
||||
team, ok := TeamFromContext(r.Context())
|
||||
if !ok {
|
||||
if IsAdminFromContext(r.Context()) {
|
||||
http.Redirect(w, r, "/switch-team", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Den URL-Pfad in einen Dateipfad umwandeln.
|
||||
// Aus "/" wird "<team>/content/index.md", aus "/projekte/setup" wird "<team>/content/projekte/setup.md"
|
||||
filePath := h.urlToFilePath(team, r.URL.Path)
|
||||
|
||||
// Die Markdown-Datei laden und rendern.
|
||||
page, err := render.LoadPage(filePath)
|
||||
if err != nil {
|
||||
// Datei nicht gefunden → 404
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn die Seite passwortgeschützt ist, müssen wir den Cookie prüfen.
|
||||
if page.Password != "" {
|
||||
if !h.isAuthenticated(r, team, r.URL.Path, page.Password) {
|
||||
// Nicht authentifiziert → Passwort-Formular zeigen
|
||||
h.showPasswordForm(w, r.URL.Path, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation aus dem Dateisystem aufbauen.
|
||||
nav := h.buildNav(team)
|
||||
|
||||
// Template mit den Daten befüllen und an den Browser schicken.
|
||||
data := pageTemplateData{
|
||||
Title: page.Title,
|
||||
Body: template.HTML(page.Body), // Typ-Umwandlung: string → template.HTML (kein Escaping)
|
||||
Nav: nav,
|
||||
TeamName: team.Name,
|
||||
IsAdmin: IsAdminFromContext(r.Context()),
|
||||
}
|
||||
|
||||
h.pageTemplate.Execute(w, data)
|
||||
}
|
||||
|
||||
// HandlePasswordSubmit verarbeitet das abgesendete Passwort-Formular (POST /auth/:path).
|
||||
func (h *WikiHandler) HandlePasswordSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
team, ok := TeamFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Den Seiten-Pfad aus der URL holen.
|
||||
// URL ist z.B. /auth/projekte/internes → wir wollen "projekte/internes"
|
||||
pagePath := strings.TrimPrefix(r.URL.Path, "/auth")
|
||||
|
||||
ip := clientIP(r)
|
||||
|
||||
// Rate-Limiting: ohne das könnte ein Angreifer das Seiten-Passwort beliebig
|
||||
// oft durchprobieren (anders als beim Team-Login gab es hier bisher keine Sperre).
|
||||
if allowed, retryAfter := h.rateLimiter.Allow(ip); !allowed {
|
||||
seconds := int(retryAfter.Seconds()) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(seconds))
|
||||
http.Error(w, fmt.Sprintf("Zu viele Versuche. Bitte in %d Sekunden erneut versuchen.", seconds), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
// Das eingetippte Passwort aus dem Formular holen.
|
||||
// r.FormValue liest POST-Formulardaten — "password" ist der name="" im HTML.
|
||||
enteredPassword := r.FormValue("password")
|
||||
|
||||
// Die zugehörige Markdown-Datei laden um das richtige Passwort zu kennen.
|
||||
filePath := h.urlToFilePath(team, pagePath)
|
||||
page, err := render.LoadPage(filePath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Eingetipptes Passwort konstantzeitig mit dem Passwort aus dem Frontmatter
|
||||
// vergleichen (wie schon bei Session-Signatur, Team-Keys und Webhook-Secret —
|
||||
// verhindert dass die Vergleichsdauer Rückschlüsse auf richtige Zeichen erlaubt).
|
||||
if subtle.ConstantTimeCompare([]byte(enteredPassword), []byte(page.Password)) != 1 {
|
||||
h.rateLimiter.RecordFailure(ip)
|
||||
// Falsches Passwort → Formular nochmal zeigen, diesmal mit Fehlermeldung
|
||||
h.showPasswordForm(w, pagePath, "Falsches Passwort.")
|
||||
return
|
||||
}
|
||||
|
||||
h.rateLimiter.Reset(ip)
|
||||
|
||||
// Richtiges Passwort → Cookie setzen damit der Browser nicht nochmal fragen muss.
|
||||
// Der Cookie-Wert ist das Passwort selbst (simpel, reicht für unser Usecase).
|
||||
// Der Team-ID-Präfix verhindert dass zwei Teams mit gleichnamigen Pfaden
|
||||
// (z.B. beide haben "/setup") sich den Auth-Cookie teilen.
|
||||
cookieName := "auth_" + team.ID + "_" + pathToCookieName(pagePath)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: page.Password,
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(30 * 24 * time.Hour), // 30 Tage gültig
|
||||
// HttpOnly verhindert dass JavaScript den Cookie lesen kann — gute Praxis
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
// Zur eigentlichen Seite weiterleiten.
|
||||
http.Redirect(w, r, pagePath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// showPasswordForm rendert das Passwort-Formular und schickt es an den Browser.
|
||||
func (h *WikiHandler) showPasswordForm(w http.ResponseWriter, pagePath string, errorMsg string) {
|
||||
// Den führenden "/" entfernen damit das Formular-Action korrekt ist
|
||||
cleanPath := strings.TrimPrefix(pagePath, "/")
|
||||
|
||||
data := passwordTemplateData{
|
||||
Path: cleanPath,
|
||||
Error: errorMsg,
|
||||
}
|
||||
h.passwordTemplate.Execute(w, data)
|
||||
}
|
||||
|
||||
// isAuthenticated prüft ob der Browser einen gültigen Auth-Cookie hat.
|
||||
func (h *WikiHandler) isAuthenticated(r *http.Request, team *Team, pagePath string, correctPassword string) bool {
|
||||
cookieName := "auth_" + team.ID + "_" + pathToCookieName(pagePath)
|
||||
|
||||
// r.Cookie(name) sucht den Cookie im Request.
|
||||
// Gibt einen Fehler zurück wenn der Cookie nicht existiert.
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
// Kein Cookie vorhanden → nicht eingeloggt
|
||||
return false
|
||||
}
|
||||
|
||||
// Cookie-Wert mit dem richtigen Passwort vergleichen.
|
||||
return cookie.Value == correctPassword
|
||||
}
|
||||
|
||||
// urlToFilePath wandelt einen URL-Pfad in einen Dateisystem-Pfad innerhalb des
|
||||
// Team-Ordners um. Beispiele (Team "alpha"):
|
||||
//
|
||||
// / → data/alpha/content/index.md
|
||||
// /setup → data/alpha/content/setup.md
|
||||
// /projekte/internes → data/alpha/content/projekte/internes.md
|
||||
func (h *WikiHandler) urlToFilePath(team *Team, urlPath string) string {
|
||||
// "/" wird zur index.md
|
||||
if urlPath == "/" {
|
||||
return filepath.Join(team.DocsRoot, "index.md")
|
||||
}
|
||||
|
||||
// Führenden Slash entfernen und .md anhängen
|
||||
cleanPath := strings.TrimPrefix(urlPath, "/")
|
||||
// Falls der Pfad schon auf .md endet, nicht doppelt anhängen.
|
||||
cleanPath = strings.TrimSuffix(cleanPath, ".md")
|
||||
return filepath.Join(team.DocsRoot, cleanPath+".md")
|
||||
}
|
||||
|
||||
// buildNav startet die Navigation beim docs-Wurzelordner des Teams.
|
||||
func (h *WikiHandler) buildNav(team *Team) []navItem {
|
||||
return buildNavForDir(team.DocsRoot, team.DocsRoot)
|
||||
}
|
||||
|
||||
// buildNavForDir liest einen Ordner und gibt dessen Navigationselemente zurück.
|
||||
// Dateien werden zu Links, Unterordner werden zu aufklappbaren Gruppen.
|
||||
// Die Funktion ruft sich selbst rekursiv für Unterordner auf.
|
||||
// docsRoot bleibt über die ganze Rekursion hinweg gleich — wird gebraucht um aus
|
||||
// einem vollen Dateipfad die relative URL zu berechnen.
|
||||
func buildNavForDir(dir string, docsRoot string) []navItem {
|
||||
// os.ReadDir liest den Inhalt eines Ordners — gibt Einträge alphabetisch sortiert zurück.
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []navItem
|
||||
|
||||
for _, entry := range entries {
|
||||
fullPath := filepath.Join(dir, entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
// Unterordner: rekursiv dessen Inhalt laden
|
||||
children := buildNavForDir(fullPath, docsRoot)
|
||||
|
||||
// Leere Ordner (keine sichtbaren .md-Dateien) nicht anzeigen
|
||||
if len(children) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ordnername als Titel — ersten Buchstaben großschreiben
|
||||
folderTitle := strings.ToUpper(entry.Name()[:1]) + entry.Name()[1:]
|
||||
|
||||
items = append(items, navItem{
|
||||
Title: folderTitle,
|
||||
Children: children, // URL bleibt leer — Ordner sind keine Links
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Nur .md-Dateien, keine anderen Dateitypen
|
||||
if !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Seite laden um Titel und Hidden-Flag aus dem Frontmatter zu lesen
|
||||
page, err := render.LoadPage(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Seiten mit hidden: true überspringen
|
||||
if page.Hidden {
|
||||
continue
|
||||
}
|
||||
|
||||
// Dateipfad → URL: data/alpha/content/projekte/setup.md → /projekte/setup
|
||||
rel := strings.TrimPrefix(fullPath, docsRoot)
|
||||
rel = strings.TrimSuffix(rel, ".md")
|
||||
url := rel
|
||||
if url == "/index" {
|
||||
url = "/"
|
||||
}
|
||||
|
||||
title := page.Title
|
||||
if title == "" {
|
||||
// Kein Titel im Frontmatter → Dateiname ohne .md als Fallback
|
||||
title = strings.TrimSuffix(entry.Name(), ".md")
|
||||
}
|
||||
|
||||
items = append(items, navItem{Title: title, URL: url})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// HandleSearch verarbeitet GET /search?q=... und zeigt BM25-Ergebnisse im
|
||||
// normalen Wiki-Layout an. Das Suchfeld in der Sidebar bleibt sichtbar,
|
||||
// der Content-Bereich zeigt die Trefferliste statt einer Seite.
|
||||
func (h *WikiHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
team, ok := TeamFromContext(r.Context())
|
||||
if !ok {
|
||||
if IsAdminFromContext(r.Context()) {
|
||||
http.Redirect(w, r, "/switch-team", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
|
||||
nav := h.buildNav(team)
|
||||
|
||||
// Leere Suchanfrage → einfach die Startseite zeigen
|
||||
if query == "" {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
hits := team.Cache.SearchIndex.Search(query, 20)
|
||||
|
||||
var results []searchResultItem
|
||||
for _, hit := range hits {
|
||||
page, ok := team.Cache.Get(hit.URLPath)
|
||||
if !ok || page.Hidden {
|
||||
continue
|
||||
}
|
||||
|
||||
// Snippet aus dem gecacheten Rohtext — kein Dateisystem-Zugriff nötig
|
||||
snippet := extractSearchSnippet(page.RawBody, query)
|
||||
|
||||
results = append(results, searchResultItem{
|
||||
Title: page.Title,
|
||||
URL: hit.URLPath,
|
||||
Snippet: snippet,
|
||||
})
|
||||
}
|
||||
|
||||
data := pageTemplateData{
|
||||
Title: "Suche: " + query,
|
||||
Nav: nav,
|
||||
Query: query,
|
||||
SearchResults: results,
|
||||
NoResults: len(results) == 0,
|
||||
TeamName: team.Name,
|
||||
IsAdmin: IsAdminFromContext(r.Context()),
|
||||
}
|
||||
|
||||
h.pageTemplate.Execute(w, data)
|
||||
}
|
||||
|
||||
// extractSearchSnippet gibt ~120 Zeichen um den ersten Treffer in content zurück.
|
||||
// Eigene Funktion damit wiki.go keine Abhängigkeit zu mcp.go bekommt.
|
||||
func extractSearchSnippet(content, query string) string {
|
||||
lower := strings.ToLower(content)
|
||||
idx := strings.Index(lower, strings.ToLower(query))
|
||||
if idx == -1 {
|
||||
// Kein exakter Treffer — Anfang des Texts als Vorschau
|
||||
if len(content) > 160 {
|
||||
return strings.TrimSpace(content[:160]) + "..."
|
||||
}
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
const radius = 120
|
||||
start := idx - radius
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + len(query) + radius
|
||||
if end > len(content) {
|
||||
end = len(content)
|
||||
}
|
||||
|
||||
snippet := content[start:end]
|
||||
if start > 0 {
|
||||
snippet = "..." + snippet
|
||||
}
|
||||
if end < len(content) {
|
||||
snippet += "..."
|
||||
}
|
||||
return strings.TrimSpace(snippet)
|
||||
}
|
||||
|
||||
// pathToCookieName wandelt einen URL-Pfad in einen gültigen Cookie-Namen um.
|
||||
// Slashes sind in Cookie-Namen nicht erlaubt, deshalb ersetzen wir sie.
|
||||
// /projekte/internes → projekte_internes
|
||||
func pathToCookieName(path string) string {
|
||||
clean := strings.TrimPrefix(path, "/")
|
||||
return strings.ReplaceAll(clean, "/", "_")
|
||||
}
|
||||
194
main.go
Normal file
194
main.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// Das main-Package ist der Einstiegspunkt jedes Go-Programms.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"wiki/handler"
|
||||
"wiki/middleware"
|
||||
)
|
||||
|
||||
// //go:embed ist eine spezielle Go-Direktive die Dateien beim Kompilieren direkt
|
||||
// ins Binary einbettet. Nach dem Build sind templates/ und static/ nicht mehr als
|
||||
// separate Ordner nötig — alles steckt im Binary selbst.
|
||||
//
|
||||
// Das Ergebnis ist ein einzelnes Binary das überall läuft ohne externe Dateien.
|
||||
|
||||
//go:embed templates/*
|
||||
var templatesFS embed.FS // enthält templates/page.html, password.html, login.html, switch-team.html
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS // enthält static/style.css
|
||||
|
||||
func main() {
|
||||
// .env-Datei laden falls vorhanden.
|
||||
// Echte Umgebungsvariablen haben immer Vorrang über .env.
|
||||
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("Hinweis: .env konnte nicht geladen werden: %v", err)
|
||||
}
|
||||
|
||||
// Konfiguration lesen — Reihenfolge: echte ENV > .env > Standardwert
|
||||
dataRoot := getEnvOrDefault("DATA_ROOT", "data")
|
||||
teamsConfig := getEnvOrDefault("TEAMS_CONFIG", "teams.yaml")
|
||||
adminToken := os.Getenv("ADMIN_TOKEN")
|
||||
sessionSecret := os.Getenv("SESSION_SECRET")
|
||||
webhookSecret := os.Getenv("WEBHOOK_SECRET")
|
||||
port := getEnvOrDefault("PORT", "8080")
|
||||
|
||||
if sessionSecret == "" {
|
||||
log.Fatal("SESSION_SECRET ist nicht gesetzt — wird gebraucht um Web-Sessions fälschungssicher zu signieren. Siehe .env.example.")
|
||||
}
|
||||
|
||||
// Kein unsicherer Default (z.B. "dev-token") — der globale Admin-Zugang sieht/verwaltet
|
||||
// ALLE Teams, ein erratbarer Standardwert wäre ein Backdoor. Lieber der Server startet
|
||||
// gar nicht, als dass er mit einem schwachen Token läuft.
|
||||
if adminToken == "" {
|
||||
log.Fatal("ADMIN_TOKEN ist nicht gesetzt — wird für den globalen Admin-Zugang gebraucht. Siehe .env.example.")
|
||||
}
|
||||
|
||||
// data-Wurzelordner automatisch anlegen falls er noch nicht existiert.
|
||||
if err := os.MkdirAll(dataRoot, 0755); err != nil {
|
||||
log.Fatal("Daten-Ordner konnte nicht erstellt werden:", err)
|
||||
}
|
||||
|
||||
// Team-Konfiguration laden (siehe teams.yaml.example) und die Registry aufbauen —
|
||||
// legt für jedes Team den Ordner data/<id>/content an und baut dessen PageCache auf.
|
||||
teamsCfg, err := handler.LoadTeamsConfig(teamsConfig)
|
||||
if err != nil {
|
||||
log.Fatal("Team-Konfiguration konnte nicht geladen werden:", err)
|
||||
}
|
||||
|
||||
registry, err := handler.NewTeamRegistry(dataRoot, teamsCfg, adminToken)
|
||||
if err != nil {
|
||||
log.Fatal("Fehler beim Aufbauen der Team-Registry:", err)
|
||||
}
|
||||
|
||||
// teams.yaml auf Änderungen überwachen — neue/geänderte/entfernte Teams wirken
|
||||
// dann ohne Serverneustart (siehe handler/teams_config.go).
|
||||
handler.StartTeamsConfigWatcher(teamsConfig, registry, 5*time.Second)
|
||||
|
||||
// Audit-Log: protokolliert Upload/Delete/Login/Webhook-Sync als JSON Lines.
|
||||
// Eine Datei für alle Teams, siehe handler/audit.go.
|
||||
auditLogger := handler.NewAuditLogger(filepath.Join(dataRoot, "audit.log"))
|
||||
|
||||
// Die Handler erstellen.
|
||||
// templatesFS wird an die Handler übergeben — sie lesen die Templates
|
||||
// direkt aus dem eingebetteten Dateisystem im Binary.
|
||||
wikiHandler, err := handler.NewWikiHandler(templatesFS)
|
||||
if err != nil {
|
||||
log.Fatal("Fehler beim Laden der Templates:", err)
|
||||
}
|
||||
|
||||
authHandler, err := handler.NewAuthHandler(templatesFS, registry, []byte(sessionSecret), auditLogger)
|
||||
if err != nil {
|
||||
log.Fatal("Fehler beim Laden der Login-Templates:", err)
|
||||
}
|
||||
|
||||
auditPageHandler, err := handler.NewAuditPageHandler(templatesFS, auditLogger)
|
||||
if err != nil {
|
||||
log.Fatal("Fehler beim Laden des Audit-Log-Templates:", err)
|
||||
}
|
||||
|
||||
apiHandler := handler.NewAPIHandler(registry, auditLogger)
|
||||
mcpHandler := handler.NewMCPHandler(registry, auditLogger)
|
||||
|
||||
// Den Router einrichten.
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Statische Dateien aus dem eingebetteten Dateisystem ausliefern.
|
||||
// fs.Sub() gibt eine "Unterperspektive" des FS zurück — schneidet den
|
||||
// "static/"-Prefix weg damit der FileServer die Dateien direkt findet.
|
||||
// Ohne Sub() würde /static/style.css nach "static/static/style.css" suchen.
|
||||
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
log.Fatal("Fehler beim Einrichten des Static-FS:", err)
|
||||
}
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSubFS))))
|
||||
|
||||
// Login/Logout brauchen selbst keine bestehende Session.
|
||||
mux.HandleFunc("/login", authHandler.HandleLogin)
|
||||
mux.HandleFunc("/logout", authHandler.HandleLogout)
|
||||
|
||||
sessionSecretBytes := []byte(sessionSecret)
|
||||
|
||||
// Wiki-Seiten anzeigen, Suche, Seiten-Passwort-Formular und Team-Umschaltung —
|
||||
// alle hinter der Team-Session (siehe middleware/session.go).
|
||||
mux.Handle("/", middleware.RequireTeamSession(wikiHandler, registry, sessionSecretBytes))
|
||||
mux.Handle("/search", middleware.RequireTeamSession(http.HandlerFunc(wikiHandler.HandleSearch), registry, sessionSecretBytes))
|
||||
mux.Handle("/auth/", middleware.RequireTeamSession(http.HandlerFunc(wikiHandler.HandlePasswordSubmit), registry, sessionSecretBytes))
|
||||
mux.Handle("/switch-team", middleware.RequireTeamSession(http.HandlerFunc(authHandler.HandleSwitchTeam), registry, sessionSecretBytes))
|
||||
mux.Handle("/admin/audit", middleware.RequireTeamSession(http.HandlerFunc(auditPageHandler.HandleAuditPage), registry, sessionSecretBytes))
|
||||
|
||||
// REST-API — durch Team-Key oder Admin-Token geschützt (middleware.RequireTeamOrAdminKey
|
||||
// löst den Bearer-Key gegen die TeamRegistry auf; der Admin-Token braucht zusätzlich
|
||||
// den Parameter "team", siehe handler/api.go).
|
||||
mux.Handle("/api/upload", middleware.RequireTeamOrAdminKey(
|
||||
http.HandlerFunc(apiHandler.HandleUpload), registry,
|
||||
))
|
||||
mux.Handle("/api/delete", middleware.RequireTeamOrAdminKey(
|
||||
http.HandlerFunc(apiHandler.HandleDelete), registry,
|
||||
))
|
||||
|
||||
// MCP-Endpoint für KI-Tools — gleiche Team-Auflösung wie die REST-API;
|
||||
// der Admin-Token braucht hier den Header "X-Wiki-Team" (siehe handler/mcp.go).
|
||||
mux.Handle("/mcp", middleware.RequireTeamOrAdminKey(mcpHandler, registry))
|
||||
|
||||
// Webhook — nur aktiv wenn WEBHOOK_SECRET gesetzt ist.
|
||||
if webhookSecret != "" {
|
||||
webhookHandler := handler.NewWebhookHandler(dataRoot, webhookSecret, registry, auditLogger)
|
||||
mux.Handle("/api/webhook", webhookHandler)
|
||||
log.Println("Git-Webhook aktiv unter /api/webhook")
|
||||
} else {
|
||||
log.Println("Hinweis: WEBHOOK_SECRET nicht gesetzt — Git-Webhook deaktiviert")
|
||||
}
|
||||
|
||||
// Server starten mit Graceful Shutdown.
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
log.Printf("Wiki läuft auf http://localhost:%s", port)
|
||||
log.Printf("Daten-Ordner: %s (%d Teams)", dataRoot, len(registry.All()))
|
||||
|
||||
err := server.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal("Server-Fehler:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("Server wird gestoppt...")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatal("Fehler beim Stoppen:", err)
|
||||
}
|
||||
|
||||
log.Println("Server gestoppt.")
|
||||
}
|
||||
|
||||
// getEnvOrDefault liest eine Umgebungsvariable und gibt einen Standardwert zurück
|
||||
// falls die Variable nicht gesetzt ist.
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
43
middleware/auth.go
Normal file
43
middleware/auth.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Package middleware enthält HTTP-Middleware — das sind Funktionen die sich
|
||||
// "vor" den eigentlichen Handlern schalten und z.B. Authentifizierung prüfen.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"wiki/handler"
|
||||
)
|
||||
|
||||
// RequireTeamOrAdminKey schützt /api/upload, /api/delete und /mcp.
|
||||
//
|
||||
// Der Bearer-Key wird gegen die TeamRegistry aufgelöst: ein Team-Key beschränkt
|
||||
// den Request auf das eigene Team, der globale Admin-Key (ADMIN_TOKEN) erlaubt
|
||||
// Zugriff auf jedes Team, muss dafür aber explizit sagen welches (siehe api.go:
|
||||
// Formfeld/Query-Param "team"; mcp.go: Header "X-Wiki-Team").
|
||||
//
|
||||
// Das aufgelöste Team (oder der Admin-Status) landet im Request-Context —
|
||||
// siehe handler.WithTeam / handler.TeamFromContext.
|
||||
func RequireTeamOrAdminKey(next http.Handler, registry *handler.TeamRegistry) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
http.Error(w, "Authorization header fehlt", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Timing-sicherer Vergleich passiert bereits innerhalb von registry.Resolve
|
||||
// (subtle.ConstantTimeCompare, siehe registry.go) — verhindert dass ein
|
||||
// Angreifer anhand der Antwortzeit erraten kann wie viele Zeichen seines
|
||||
// Keys korrekt sind.
|
||||
team, isAdmin, ok := registry.Resolve(key)
|
||||
if !ok {
|
||||
http.Error(w, "Ungültiger Key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(handler.WithTeam(r.Context(), team, isAdmin)))
|
||||
})
|
||||
}
|
||||
59
middleware/session.go
Normal file
59
middleware/session.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Dieses File enthält die Middleware die eine Web-Session prüft — das eigentliche
|
||||
// Signieren/Verifizieren steckt in handler.SignSession/handler.VerifySession
|
||||
// (dort auch von den Login-Handlern genutzt).
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"wiki/handler"
|
||||
)
|
||||
|
||||
// RequireTeamSession ist die Middleware für die Web-Ansicht ("/" und "/search").
|
||||
// Ohne gültigen Session-Cookie wird auf /login umgeleitet. Mit gültigem Cookie
|
||||
// wird das aufgelöste Team (bzw. der Admin-Status) im Request-Context abgelegt —
|
||||
// siehe handler.WithTeam / handler.TeamFromContext.
|
||||
func RequireTeamSession(next http.Handler, registry *handler.TeamRegistry, secret []byte) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(handler.SessionCookieName)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
subject, ok := handler.VerifySession(cookie.Value, secret)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case subject == "admin":
|
||||
next.ServeHTTP(w, r.WithContext(handler.WithTeam(r.Context(), nil, true)))
|
||||
|
||||
case strings.HasPrefix(subject, "admin:"):
|
||||
teamID := strings.TrimPrefix(subject, "admin:")
|
||||
team, ok := registry.ByID(teamID)
|
||||
if !ok {
|
||||
// Team wurde inzwischen entfernt — Admin sieht wieder die Übersicht.
|
||||
next.ServeHTTP(w, r.WithContext(handler.WithTeam(r.Context(), nil, true)))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(handler.WithTeam(r.Context(), team, true)))
|
||||
|
||||
case strings.HasPrefix(subject, "team:"):
|
||||
teamID := strings.TrimPrefix(subject, "team:")
|
||||
team, ok := registry.ByID(teamID)
|
||||
if !ok {
|
||||
// Team wurde inzwischen entfernt (z.B. aus teams.yaml gelöscht) — neu einloggen lassen.
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(handler.WithTeam(r.Context(), team, false)))
|
||||
|
||||
default:
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
})
|
||||
}
|
||||
137
render/markdown.go
Normal file
137
render/markdown.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Package render ist zuständig für alles rund ums Lesen und Umwandeln von Markdown-Dateien.
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Page repräsentiert eine einzelne Wiki-Seite.
|
||||
// Ein Struct in Go ist wie eine Datenklasse: es bündelt zusammengehörige Felder.
|
||||
type Page struct {
|
||||
Title string // Titel aus dem Frontmatter
|
||||
Password string // Passwort aus dem Frontmatter (leer = öffentlich)
|
||||
Tags []string // Tags aus dem Frontmatter (optional)
|
||||
Hidden bool // Wenn true: Seite erscheint nicht in der Navigation (aber URL bleibt erreichbar)
|
||||
Body string // Der fertig gerenderte HTML-Inhalt der Seite
|
||||
RawBody string // Der originale Markdown-Text ohne Frontmatter — wird für die Volltextsuche genutzt
|
||||
}
|
||||
|
||||
// frontmatter ist ein internes Struct nur zum Parsen des YAML-Blocks.
|
||||
// Wir brauchen es nur kurz, deshalb ist es kleingeschrieben (= nicht exportiert,
|
||||
// also nur innerhalb dieses Packages sichtbar).
|
||||
type frontmatter struct {
|
||||
Title string `yaml:"title"`
|
||||
Password string `yaml:"password"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Hidden bool `yaml:"hidden"`
|
||||
}
|
||||
|
||||
// LoadPage liest eine Markdown-Datei vom Dateisystem, parst das Frontmatter
|
||||
// und wandelt den Markdown-Inhalt in HTML um.
|
||||
//
|
||||
// In Go geben Funktionen oft zwei Werte zurück: das Ergebnis und einen Fehler.
|
||||
// Der Aufrufer muss den Fehler prüfen — Go hat keine Exceptions.
|
||||
func LoadPage(filePath string) (*Page, error) {
|
||||
// os.ReadFile liest die komplette Datei in einen Byte-Slice ([]byte).
|
||||
// Ein Byte-Slice ist einfach eine Liste von Bytes — rohe Dateidaten.
|
||||
rawContent, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
// Wir geben nil (kein Ergebnis) und den Fehler zurück.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wir wandeln den Byte-Slice in einen String um, damit wir damit arbeiten können.
|
||||
content := string(rawContent)
|
||||
|
||||
// Frontmatter und Markdown-Body trennen.
|
||||
fm, body, err := splitFrontmatter(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Den Markdown-Body in HTML umwandeln.
|
||||
htmlBody, err := markdownToHTML(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Das fertige Page-Struct befüllen und zurückgeben.
|
||||
// Das * vor Page bedeutet: wir geben einen Zeiger zurück (eine Referenz auf
|
||||
// das Struct im Speicher), nicht eine Kopie davon.
|
||||
return &Page{
|
||||
Title: fm.Title,
|
||||
Password: fm.Password,
|
||||
Tags: fm.Tags,
|
||||
Hidden: fm.Hidden,
|
||||
Body: htmlBody,
|
||||
RawBody: body, // Rohtext für die BM25-Volltextsuche — HTML-Tags verfälschen die Tokenisierung
|
||||
}, nil
|
||||
}
|
||||
|
||||
// splitFrontmatter trennt den YAML-Frontmatter-Block vom Markdown-Inhalt.
|
||||
//
|
||||
// Eine typische Datei sieht so aus:
|
||||
//
|
||||
// ---
|
||||
// title: Meine Seite
|
||||
// password: geheim
|
||||
// ---
|
||||
//
|
||||
// # Überschrift
|
||||
// Inhalt hier...
|
||||
func splitFrontmatter(content string) (frontmatter, string, error) {
|
||||
var fm frontmatter
|
||||
|
||||
// Frontmatter beginnt und endet mit "---".
|
||||
// Wir prüfen ob die Datei überhaupt Frontmatter hat.
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
// Kein Frontmatter — leeres Struct zurückgeben, ganzer Inhalt ist Body.
|
||||
return fm, content, nil
|
||||
}
|
||||
|
||||
// Den ersten "---" abschneiden und nach dem zweiten suchen.
|
||||
rest := strings.TrimPrefix(content, "---\n")
|
||||
closingIndex := strings.Index(rest, "---")
|
||||
|
||||
if closingIndex == -1 {
|
||||
// Öffnendes "---" gefunden, aber kein schließendes — Datei ist kaputt.
|
||||
return fm, content, nil
|
||||
}
|
||||
|
||||
// Den YAML-Block und den Body-Teil extrahieren.
|
||||
yamlBlock := rest[:closingIndex]
|
||||
body := rest[closingIndex+4:] // +4 um "---\n" zu überspringen
|
||||
|
||||
// Den YAML-Block parsen und in unser frontmatter-Struct füllen.
|
||||
// yaml.Unmarshal erwartet einen Byte-Slice, deshalb []byte(yamlBlock).
|
||||
err := yaml.Unmarshal([]byte(yamlBlock), &fm)
|
||||
if err != nil {
|
||||
return fm, body, err
|
||||
}
|
||||
|
||||
return fm, body, nil
|
||||
}
|
||||
|
||||
// markdownToHTML wandelt einen Markdown-String in einen HTML-String um.
|
||||
// Dafür nutzen wir die goldmark-Bibliothek.
|
||||
func markdownToHTML(markdownContent string) (string, error) {
|
||||
// bytes.Buffer ist ein beschreibbarer Puffer im Speicher — wie eine Datei,
|
||||
// aber im RAM. Goldmark schreibt das HTML-Ergebnis hinein.
|
||||
var htmlBuffer bytes.Buffer
|
||||
|
||||
// goldmark.Convert macht die eigentliche Umwandlung.
|
||||
// []byte(markdownContent) wandelt den String zurück in Bytes,
|
||||
// weil die Bibliothek Bytes erwartet.
|
||||
err := goldmark.Convert([]byte(markdownContent), &htmlBuffer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// htmlBuffer.String() holt den fertigen HTML-String aus dem Puffer.
|
||||
return htmlBuffer.String(), nil
|
||||
}
|
||||
56
static/sidebar.js
Normal file
56
static/sidebar.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Steuert das Ein-/Ausklappen der Sidebar — sowohl auf breiten Bildschirmen
|
||||
// (Desktop: Sidebar verschwindet, Inhalt füllt den Platz) als auch auf schmalen
|
||||
// Bildschirmen (Mobile: Sidebar liegt als Overlay über dem Inhalt).
|
||||
//
|
||||
// Bewusst zwei getrennte CSS-Klassen statt einer gemeinsamen:
|
||||
// "sidebar-collapsed" → Desktop-Zustand, wird in localStorage gemerkt
|
||||
// "sidebar-mobile-open" → Mobile-Zustand, startet nach jedem Seitenaufruf zu
|
||||
// (das Wiki lädt bei jeder Navigation die Seite komplett
|
||||
// neu — ein offenes Mobile-Menü würde sonst irritierend
|
||||
// "hängen bleiben")
|
||||
// So beeinflussen sich Desktop- und Mobile-Verhalten nicht gegenseitig, siehe
|
||||
// style.css für die dazugehörigen Regeln.
|
||||
(function () {
|
||||
var MOBILE_BREAKPOINT = 768; // muss zum @media-Breakpoint in style.css passen
|
||||
var STORAGE_KEY = "wiki-sidebar-collapsed";
|
||||
|
||||
function isMobile() {
|
||||
return window.innerWidth <= MOBILE_BREAKPOINT;
|
||||
}
|
||||
|
||||
// Auf Desktop den zuletzt gewählten Zustand aus einem vorherigen Besuch übernehmen.
|
||||
if (!isMobile() && localStorage.getItem(STORAGE_KEY) === "true") {
|
||||
document.body.classList.add("sidebar-collapsed");
|
||||
}
|
||||
|
||||
var toggleButton = document.getElementById("sidebar-toggle");
|
||||
var backdrop = document.getElementById("sidebar-backdrop");
|
||||
|
||||
function toggleSidebar() {
|
||||
if (isMobile()) {
|
||||
document.body.classList.toggle("sidebar-mobile-open");
|
||||
return;
|
||||
}
|
||||
|
||||
var collapsed = document.body.classList.toggle("sidebar-collapsed");
|
||||
localStorage.setItem(STORAGE_KEY, collapsed ? "true" : "false");
|
||||
}
|
||||
|
||||
toggleButton.addEventListener("click", toggleSidebar);
|
||||
|
||||
// Auf das Overlay neben der Sidebar klicken schließt sie wieder (nur Mobile).
|
||||
backdrop.addEventListener("click", function () {
|
||||
document.body.classList.remove("sidebar-mobile-open");
|
||||
});
|
||||
|
||||
// Wechselt der Nutzer die Fensterbreite über den Breakpoint hinweg, sollen
|
||||
// keine widersprüchlichen Zustände übrig bleiben (z.B. Mobile-Menü offen,
|
||||
// obwohl das Fenster inzwischen breit genug für die normale Desktop-Ansicht ist).
|
||||
window.addEventListener("resize", function () {
|
||||
if (isMobile()) {
|
||||
document.body.classList.remove("sidebar-collapsed");
|
||||
} else {
|
||||
document.body.classList.remove("sidebar-mobile-open");
|
||||
}
|
||||
});
|
||||
})();
|
||||
519
static/style.css
Normal file
519
static/style.css
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
/* ── Reset ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Basis ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #222;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Layout ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Zweispaltiges Layout: Sidebar links, Inhalt rechts */
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: #f5f5f5;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
/* Oben etwas mehr Platz als früher — macht Raum für den fest positionierten
|
||||
Ein-/Ausklapp-Button (siehe Abschnitt "Sidebar: Ein-/Ausklappen & Responsive"). */
|
||||
padding: 3.5rem 1rem 2rem;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease, padding 0.2s ease;
|
||||
}
|
||||
|
||||
/* Navigation: flache Liste ohne Bullet-Punkte */
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar ul li a {
|
||||
display: block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: #444;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar ul li a:hover {
|
||||
background: #e8e8e8;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* <details> ist das HTML-Element für aufklappbare Bereiche (kein JavaScript nötig) */
|
||||
.sidebar details {
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
/* <summary> ist der klickbare Kopfbereich eines <details>-Elements */
|
||||
.sidebar summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Standard-Pfeil in WebKit-Browsern (Safari, Chrome) entfernen */
|
||||
.sidebar summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.sidebar summary:hover {
|
||||
background: #e8e8e8;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Eigener Pfeil per CSS — dreht sich wenn <details> offen ist */
|
||||
.sidebar summary::before {
|
||||
content: "▶";
|
||||
font-size: 0.6rem;
|
||||
color: #999;
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar details[open] > summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Einträge innerhalb eines Ordners einrücken */
|
||||
.sidebar details ul {
|
||||
padding-left: 1rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* ── Sidebar: Ein-/Ausklappen & Responsive ───────────────────────────────────
|
||||
Siehe static/sidebar.js für die Logik dahinter. Kurz zusammengefasst:
|
||||
- "sidebar-collapsed" auf <body> → Desktop: Sidebar auf Breite 0 einklappen
|
||||
- "sidebar-mobile-open" auf <body> → Mobile: Sidebar als Overlay einblenden
|
||||
Ohne JavaScript bleibt die Sidebar einfach immer sichtbar (kein kaputter
|
||||
Zustand, nur kein Ein-/Ausklappen möglich). */
|
||||
|
||||
/* Fest positionierte Kopfzeile: Toggle-Button + "Wiki"-Beschriftung daneben,
|
||||
bleibt an derselben Stelle sichtbar unabhängig vom Sidebar-Zustand. */
|
||||
.topbar {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 110; /* über Sidebar (100) und Overlay (90) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: #eee;
|
||||
border-color: #999;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Desktop: eingeklappt bedeutet Breite 0 — der Inhalt rutscht dank Flexbox
|
||||
automatisch nach links und füllt den frei gewordenen Platz. */
|
||||
body.sidebar-collapsed .sidebar {
|
||||
width: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
/* Dunkles Overlay hinter der Sidebar auf Mobile — schließt sie per Klick daneben. */
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Auf schmalen Bildschirmen ist die Sidebar immer ein Overlay über dem
|
||||
Inhalt, unabhängig vom Desktop-Einklapp-Zustand — deshalb hier alle
|
||||
relevanten Werte (Breite, Padding, Rahmen) explizit neu gesetzt statt
|
||||
sich auf die Desktop-Regeln oben zu verlassen. */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
width: 260px;
|
||||
padding: 3.5rem 1rem 2rem;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
body.sidebar-mobile-open .sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
body.sidebar-mobile-open .sidebar-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 3.5rem 1.25rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hauptinhalt ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
/* Oben etwas mehr Platz als früher, aus demselben Grund wie bei .sidebar. */
|
||||
padding: 3.5rem 3rem 2.5rem;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Markdown-Elemente */
|
||||
.content h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||
.content h2 { font-size: 1.4rem; margin: 2rem 0 0.75rem; }
|
||||
.content h3 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
||||
|
||||
.content p { margin-bottom: 1rem; }
|
||||
|
||||
.content ul, .content ol {
|
||||
margin: 0 0 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.content a {
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.content code {
|
||||
background: #f0f0f0;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.content pre {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.content blockquote {
|
||||
border-left: 3px solid #ccc;
|
||||
padding-left: 1rem;
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content th, .content td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Team-Leiste (aktives Team, Logout, Team wechseln) ───────────────────── */
|
||||
|
||||
/* Menü-Links untereinander (Team wechseln, Audit-Log, Logout) */
|
||||
.sidebar-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-menu a {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-menu a:hover {
|
||||
color: #0066cc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Trennlinie + aktives Team, unterhalb der Menü-Links */
|
||||
.team-bar {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
/* ── Suchfeld in der Sidebar ─────────────────────────────────────────────── */
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.search-form input[type="search"] {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
outline: none;
|
||||
/* WebKit-Standard-Suchfeld-Styling entfernen */
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.search-form input[type="search"]:focus {
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
|
||||
}
|
||||
|
||||
/* ── Suchergebnisseite ────────────────────────────────────────────────────── */
|
||||
|
||||
.search-query {
|
||||
font-weight: normal;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.search-count {
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
color: #666;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.search-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.search-result-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.search-result-url {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.search-result-snippet {
|
||||
font-size: 0.9rem;
|
||||
color: #444;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Audit-Log-Seite ──────────────────────────────────────────────────────── */
|
||||
|
||||
.audit-page {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.audit-intro {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.audit-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.audit-table th,
|
||||
.audit-table td {
|
||||
border: 1px solid #eee;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.audit-table tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* ── Passwort-Formular ────────────────────────────────────────────────────── */
|
||||
|
||||
.password-page {
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.card h1 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fff0f0;
|
||||
border: 1px solid #ffcccc;
|
||||
color: #cc0000;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="password"]:focus {
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: #0066cc;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
/* Mehrere Buttons untereinander (z.B. Team-Auswahl) brauchen etwas Abstand */
|
||||
.card form button {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card form button:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
21
teams.yaml.example
Normal file
21
teams.yaml.example
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Team-Konfiguration — Vorlage
|
||||
# Kopiere diese Datei nach teams.yaml und trage deine Teams ein:
|
||||
# cp teams.yaml.example teams.yaml
|
||||
#
|
||||
# Wichtig: teams.yaml enthält Zugriffskeys im Klartext und ist in .gitignore
|
||||
# ausgeschlossen. Niemals teams.yaml in Git committen!
|
||||
#
|
||||
# Jedes Team bekommt einen eigenen Ordner unter data/<id>/content — dort liegen
|
||||
# die Markdown-Dateien dieses Teams. Der Ordner wird beim Serverstart automatisch
|
||||
# angelegt falls er noch nicht existiert.
|
||||
#
|
||||
# Die ID darf nur aus a-z, 0-9, "-" und "_" bestehen und wird 1:1 als Ordnername
|
||||
# verwendet. "admin" ist als ID reserviert (siehe ADMIN_TOKEN in .env).
|
||||
|
||||
teams:
|
||||
- id: alpha
|
||||
name: "Team Alpha"
|
||||
key: "hier-sicheren-key-fuer-team-alpha-eintragen"
|
||||
- id: beta
|
||||
name: "Team Beta"
|
||||
key: "hier-sicheren-key-fuer-team-beta-eintragen"
|
||||
50
templates/audit.html
Normal file
50
templates/audit.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audit-Log – Wiki</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<main class="content audit-page">
|
||||
<h1>Audit-Log</h1>
|
||||
<p class="audit-intro">Die letzten {{len .Events}} protokollierten Schreib-Aktionen, neueste zuerst.</p>
|
||||
|
||||
{{if not .Events}}
|
||||
<p class="search-empty">Noch keine Ereignisse protokolliert.</p>
|
||||
{{else}}
|
||||
<div class="audit-table-wrap">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>Team</th>
|
||||
<th>Akteur</th>
|
||||
<th>Aktion</th>
|
||||
<th>Pfad</th>
|
||||
<th>Quelle</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Events}}
|
||||
<tr>
|
||||
<td>{{.Time.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>{{if .Team}}{{.Team}}{{else}}—{{end}}</td>
|
||||
<td>{{.Actor}}</td>
|
||||
<td>{{.Action}}</td>
|
||||
<td>{{if .Path}}{{.Path}}{{else}}—{{end}}</td>
|
||||
<td>{{.Source}}</td>
|
||||
<td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
26
templates/login.html
Normal file
26
templates/login.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anmelden – Wiki</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="password-page">
|
||||
<div class="card">
|
||||
<h1>Wiki-Login</h1>
|
||||
<p>Bitte gib deinen Team-Zugriffskey ein.</p>
|
||||
|
||||
{{/* Wenn .Error nicht leer ist, zeigen wir die Fehlermeldung an */}}
|
||||
{{if .Error}}
|
||||
<div class="error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<label for="key">Zugriffskey</label>
|
||||
<input type="password" id="key" name="key" autofocus placeholder="Key eingeben">
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
98
templates/page.html
Normal file
98
templates/page.html
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} – Wiki</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{/* Fest positionierte Kopfzeile — bleibt sichtbar unabhängig davon ob die
|
||||
Sidebar gerade ein- oder ausgeklappt ist. sidebar.js hängt sich am Button ein. */}}
|
||||
<div class="topbar">
|
||||
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Navigation ein-/ausblenden" aria-controls="sidebar">☰</button>
|
||||
<span class="topbar-title">Wiki</span>
|
||||
</div>
|
||||
|
||||
{{/* Nur auf schmalen Bildschirmen sichtbar (siehe style.css) — schließt die
|
||||
Sidebar per Klick daneben, wie man es von mobilen Menüs kennt. */}}
|
||||
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
||||
|
||||
<div class="layout">
|
||||
<nav class="sidebar" id="sidebar">
|
||||
{{/* Menü-Links (für Admins zusätzlich Team-Umschaltung + Audit-Log),
|
||||
dann eine Trennlinie und darunter das aktive Team. */}}
|
||||
<div class="sidebar-menu">
|
||||
{{if .IsAdmin}}
|
||||
<a href="/switch-team">Team wechseln</a>
|
||||
<a href="/admin/audit">Audit-Log</a>
|
||||
{{end}}
|
||||
<a href="/logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<div class="team-bar">
|
||||
<span class="team-name">{{.TeamName}}</span>
|
||||
</div>
|
||||
|
||||
{{/* Suchfeld — sendet GET /search?q=... */}}
|
||||
<form action="/search" method="get" class="search-form">
|
||||
<input type="search" name="q" placeholder="Suchen…" value="{{.Query}}" autocomplete="off">
|
||||
</form>
|
||||
|
||||
{{/* Ein benanntes Template das sich selbst aufrufen kann — das ermöglicht
|
||||
die rekursive Darstellung von beliebig tiefen Ordnerstrukturen.
|
||||
"define" legt den Namen fest, "template" ruft ihn auf. */}}
|
||||
{{define "navItems"}}
|
||||
<ul>
|
||||
{{range .}}
|
||||
{{if .Children}}
|
||||
{{/* Dieser Eintrag ist ein Ordner: <details> klappt auf/zu */}}
|
||||
<li>
|
||||
<details open>
|
||||
<summary>{{.Title}}</summary>
|
||||
{{/* Rekursiver Aufruf: die Kinder des Ordners rendern */}}
|
||||
{{template "navItems" .Children}}
|
||||
</details>
|
||||
</li>
|
||||
{{else}}
|
||||
{{/* Dieser Eintrag ist eine Seite: normaler Link */}}
|
||||
<li><a href="{{.URL}}">{{.Title}}</a></li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{/* Das Template mit der obersten Nav-Ebene starten */}}
|
||||
{{template "navItems" .Nav}}
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
{{if .NoResults}}
|
||||
{{/* Suche durchgeführt, aber kein Treffer */}}
|
||||
<h1>Keine Ergebnisse</h1>
|
||||
<p class="search-empty">Keine Seiten gefunden für <strong>„{{.Query}}"</strong>.</p>
|
||||
{{else if .SearchResults}}
|
||||
{{/* Suchergebnisse anzeigen */}}
|
||||
<h1>Suchergebnisse <span class="search-query">„{{.Query}}"</span></h1>
|
||||
<p class="search-count">{{len .SearchResults}} Treffer</p>
|
||||
<ul class="search-results">
|
||||
{{range .SearchResults}}
|
||||
<li class="search-result">
|
||||
<a href="{{.URL}}" class="search-result-title">{{.Title}}</a>
|
||||
<span class="search-result-url">{{.URL}}</span>
|
||||
{{if .Snippet}}
|
||||
<p class="search-result-snippet">{{.Snippet}}</p>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
{{/* Normaler Seiteninhalt — .Body enthält bereits fertiges HTML */}}
|
||||
{{.Body}}
|
||||
{{end}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/sidebar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
templates/password.html
Normal file
27
templates/password.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Passwort erforderlich – Wiki</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="password-page">
|
||||
<div class="card">
|
||||
<h1>Geschützte Seite</h1>
|
||||
<p>Diese Seite ist mit einem Passwort geschützt.</p>
|
||||
|
||||
{{/* Wenn .Error nicht leer ist, zeigen wir die Fehlermeldung an */}}
|
||||
{{if .Error}}
|
||||
<div class="error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Das Formular sendet ein POST an /auth/:path */}}
|
||||
<form method="POST" action="/auth/{{.Path}}">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" autofocus placeholder="Passwort eingeben">
|
||||
<button type="submit">Weiter</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
21
templates/switch-team.html
Normal file
21
templates/switch-team.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Team wählen – Wiki</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="password-page">
|
||||
<div class="card">
|
||||
<h1>Team wählen</h1>
|
||||
<p>Als Admin kannst du dir jedes Team ansehen.</p>
|
||||
|
||||
<form method="POST" action="/switch-team">
|
||||
{{range .Teams}}
|
||||
<button type="submit" name="team" value="{{.ID}}">{{.Name}}</button>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue