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