initial commit

This commit is contained in:
Tom 2026-07-14 20:46:19 +02:00
commit d86235564b
34 changed files with 5173 additions and 0 deletions

14
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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 | ~12 MB | <0,1 s |
| 500 | ~510 MB | <0,5 s |
| 1.000 | ~2040 MB | ~1 s |
| 5.000 | ~150250 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.22.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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>