From d86235564bf9108993286ce2bd936f16b268da86 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 14 Jul 2026 20:46:19 +0200 Subject: [PATCH] initial commit --- .dockerignore | 14 + .env.example | 34 ++ .gitignore | 17 + Dockerfile | 56 +++ README.md | 530 +++++++++++++++++++++++++++++ REPOMAP.md | 295 ++++++++++++++++ deploy.sh | 65 ++++ go.mod | 16 + go.sum | 21 ++ handler/api.go | 203 +++++++++++ handler/audit.go | 127 +++++++ handler/audit_page.go | 49 +++ handler/auth.go | 181 ++++++++++ handler/cache.go | 166 +++++++++ handler/mcp.go | 681 +++++++++++++++++++++++++++++++++++++ handler/ratelimit.go | 98 ++++++ handler/registry.go | 221 ++++++++++++ handler/search.go | 309 +++++++++++++++++ handler/session.go | 87 +++++ handler/teams_config.go | 115 +++++++ handler/webhook.go | 202 +++++++++++ handler/wiki.go | 435 +++++++++++++++++++++++ main.go | 194 +++++++++++ middleware/auth.go | 43 +++ middleware/session.go | 59 ++++ render/markdown.go | 137 ++++++++ static/sidebar.js | 56 +++ static/style.css | 519 ++++++++++++++++++++++++++++ teams.yaml.example | 21 ++ templates/audit.html | 50 +++ templates/login.html | 26 ++ templates/page.html | 98 ++++++ templates/password.html | 27 ++ templates/switch-team.html | 21 ++ 34 files changed, 5173 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 REPOMAP.md create mode 100755 deploy.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler/api.go create mode 100644 handler/audit.go create mode 100644 handler/audit_page.go create mode 100644 handler/auth.go create mode 100644 handler/cache.go create mode 100644 handler/mcp.go create mode 100644 handler/ratelimit.go create mode 100644 handler/registry.go create mode 100644 handler/search.go create mode 100644 handler/session.go create mode 100644 handler/teams_config.go create mode 100644 handler/webhook.go create mode 100644 handler/wiki.go create mode 100644 main.go create mode 100644 middleware/auth.go create mode 100644 middleware/session.go create mode 100644 render/markdown.go create mode 100644 static/sidebar.js create mode 100644 static/style.css create mode 100644 teams.yaml.example create mode 100644 templates/audit.html create mode 100644 templates/login.html create mode 100644 templates/page.html create mode 100644 templates/password.html create mode 100644 templates/switch-team.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..60a91b3 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f187d0c --- /dev/null +++ b/.env.example @@ -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 +# //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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c5a8d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02ed446 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..eacf3fe --- /dev/null +++ b/README.md @@ -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//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//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 `/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//content/index.md` | `/` | +| `data//content/setup.md` | `/setup` | +| `data//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 `/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) | diff --git a/REPOMAP.md b/REPOMAP.md new file mode 100644 index 0000000..d0ec688 --- /dev/null +++ b/REPOMAP.md @@ -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//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:", "admin" +│ │ oder "admin:") — 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. + ├── / + │ └── 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:" 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 +``` diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..75bd1a3 --- /dev/null +++ b/deploy.sh @@ -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= 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 " + 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!" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..be029e7 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..035b9ca --- /dev/null +++ b/go.sum @@ -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= diff --git a/handler/api.go b/handler/api.go new file mode 100644 index 0000000..e85f42f --- /dev/null +++ b/handler/api.go @@ -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) +} diff --git a/handler/audit.go b/handler/audit.go new file mode 100644 index 0000000..b8a3476 --- /dev/null +++ b/handler/audit.go @@ -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 +} diff --git a/handler/audit_page.go b/handler/audit_page.go new file mode 100644 index 0000000..a8dc389 --- /dev/null +++ b/handler/audit_page.go @@ -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}) +} diff --git a/handler/auth.go b/handler/auth.go new file mode 100644 index 0000000..dedec9f --- /dev/null +++ b/handler/auth.go @@ -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, + }) +} diff --git a/handler/cache.go b/handler/cache.go new file mode 100644 index 0000000..6537d36 --- /dev/null +++ b/handler/cache.go @@ -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") +} diff --git a/handler/mcp.go b/handler/mcp.go new file mode 100644 index 0000000..c9292b4 --- /dev/null +++ b/handler/mcp.go @@ -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:] +} diff --git a/handler/ratelimit.go b/handler/ratelimit.go new file mode 100644 index 0000000..781c5c0 --- /dev/null +++ b/handler/ratelimit.go @@ -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) + } + } +} diff --git a/handler/registry.go b/handler/registry.go new file mode 100644 index 0000000..7f21a0f --- /dev/null +++ b/handler/registry.go @@ -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 +} diff --git a/handler/search.go b/handler/search.go new file mode 100644 index 0000000..8761cb0 --- /dev/null +++ b/handler/search.go @@ -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 +} diff --git a/handler/session.go b/handler/session.go new file mode 100644 index 0000000..aa0dc10 --- /dev/null +++ b/handler/session.go @@ -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:" — normale Team-Session +// - "admin" — Admin-Session ohne aktives Team (zeigt Team-Übersicht) +// - "admin:" — 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 +} diff --git a/handler/teams_config.go b/handler/teams_config.go new file mode 100644 index 0000000..897ea58 --- /dev/null +++ b/handler/teams_config.go @@ -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//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)) + } + }() +} diff --git a/handler/webhook.go b/handler/webhook.go new file mode 100644 index 0000000..95ea5e9 --- /dev/null +++ b/handler/webhook.go @@ -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=" + 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=" + // (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=" +// 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 " 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 +} diff --git a/handler/wiki.go b/handler/wiki.go new file mode 100644 index 0000000..085edcf --- /dev/null +++ b/handler/wiki.go @@ -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 "/content/index.md", aus "/projekte/setup" wird "/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, "/", "_") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5846d4e --- /dev/null +++ b/main.go @@ -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//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 +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..ed9f7a8 --- /dev/null +++ b/middleware/auth.go @@ -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))) + }) +} diff --git a/middleware/session.go b/middleware/session.go new file mode 100644 index 0000000..c712dd7 --- /dev/null +++ b/middleware/session.go @@ -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) + } + }) +} diff --git a/render/markdown.go b/render/markdown.go new file mode 100644 index 0000000..549023a --- /dev/null +++ b/render/markdown.go @@ -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 +} diff --git a/static/sidebar.js b/static/sidebar.js new file mode 100644 index 0000000..d0aa6da --- /dev/null +++ b/static/sidebar.js @@ -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"); + } + }); +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..c110b89 --- /dev/null +++ b/static/style.css @@ -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; +} + +/*
ist das HTML-Element für aufklappbare Bereiche (kein JavaScript nötig) */ +.sidebar details { + margin-bottom: 0.1rem; +} + +/* ist der klickbare Kopfbereich eines
-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
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 → Desktop: Sidebar auf Breite 0 einklappen + - "sidebar-mobile-open" auf → 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; +} diff --git a/teams.yaml.example b/teams.yaml.example new file mode 100644 index 0000000..22cf9dd --- /dev/null +++ b/teams.yaml.example @@ -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//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" diff --git a/templates/audit.html b/templates/audit.html new file mode 100644 index 0000000..1f8f1f3 --- /dev/null +++ b/templates/audit.html @@ -0,0 +1,50 @@ + + + + + + Audit-Log – Wiki + + + +
+
+

Audit-Log

+

Die letzten {{len .Events}} protokollierten Schreib-Aktionen, neueste zuerst.

+ + {{if not .Events}} +

Noch keine Ereignisse protokolliert.

+ {{else}} +
+ + + + + + + + + + + + + + {{range .Events}} + + + + + + + + + + {{end}} + +
ZeitTeamAkteurAktionPfadQuelleDetails
{{.Time.Format "2006-01-02 15:04:05"}}{{if .Team}}{{.Team}}{{else}}—{{end}}{{.Actor}}{{.Action}}{{if .Path}}{{.Path}}{{else}}—{{end}}{{.Source}}{{if .Detail}}{{.Detail}}{{else}}—{{end}}
+
+ {{end}} +
+
+ + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..344c699 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,26 @@ + + + + + + Anmelden – Wiki + + + +
+

Wiki-Login

+

Bitte gib deinen Team-Zugriffskey ein.

+ + {{/* Wenn .Error nicht leer ist, zeigen wir die Fehlermeldung an */}} + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ + + +
+
+ + diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..b62a5df --- /dev/null +++ b/templates/page.html @@ -0,0 +1,98 @@ + + + + + + {{.Title}} – Wiki + + + + {{/* Fest positionierte Kopfzeile — bleibt sichtbar unabhängig davon ob die + Sidebar gerade ein- oder ausgeklappt ist. sidebar.js hängt sich am Button ein. */}} +
+ + Wiki +
+ + {{/* Nur auf schmalen Bildschirmen sichtbar (siehe style.css) — schließt die + Sidebar per Klick daneben, wie man es von mobilen Menüs kennt. */}} + + +
+ + +
+ {{if .NoResults}} + {{/* Suche durchgeführt, aber kein Treffer */}} +

Keine Ergebnisse

+

Keine Seiten gefunden für „{{.Query}}".

+ {{else if .SearchResults}} + {{/* Suchergebnisse anzeigen */}} +

Suchergebnisse „{{.Query}}"

+

{{len .SearchResults}} Treffer

+
    + {{range .SearchResults}} +
  • + {{.Title}} + {{.URL}} + {{if .Snippet}} +

    {{.Snippet}}

    + {{end}} +
  • + {{end}} +
+ {{else}} + {{/* Normaler Seiteninhalt — .Body enthält bereits fertiges HTML */}} + {{.Body}} + {{end}} +
+
+ + + + diff --git a/templates/password.html b/templates/password.html new file mode 100644 index 0000000..db01bf3 --- /dev/null +++ b/templates/password.html @@ -0,0 +1,27 @@ + + + + + + Passwort erforderlich – Wiki + + + +
+

Geschützte Seite

+

Diese Seite ist mit einem Passwort geschützt.

+ + {{/* Wenn .Error nicht leer ist, zeigen wir die Fehlermeldung an */}} + {{if .Error}} +
{{.Error}}
+ {{end}} + + {{/* Das Formular sendet ein POST an /auth/:path */}} +
+ + + +
+
+ + diff --git a/templates/switch-team.html b/templates/switch-team.html new file mode 100644 index 0000000..e2b8d6a --- /dev/null +++ b/templates/switch-team.html @@ -0,0 +1,21 @@ + + + + + + Team wählen – Wiki + + + +
+

Team wählen

+

Als Admin kannst du dir jedes Team ansehen.

+ +
+ {{range .Teams}} + + {{end}} +
+
+ +