203 lines
6 KiB
Go
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)
|
|
}
|