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