go-wiki/handler/api.go
2026-07-14 20:46:19 +02:00

203 lines
6 KiB
Go

// 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)
}