commit d86235564bf9108993286ce2bd936f16b268da86 Author: Tom Date: Tue Jul 14 20:46:19 2026 +0200 initial commit 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}} +
+
+ +