// 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, teams-admin.html, audit.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) } // Fehlt teams.yaml komplett (typischer Erststart), automatisch eine mit einem // Standard-Team anlegen statt abzubrechen — siehe handler.EnsureTeamsConfig. if err := handler.EnsureTeamsConfig(teamsConfig); err != nil { log.Fatal("Team-Konfiguration konnte nicht angelegt 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) } teamsAdminHandler, err := handler.NewTeamsAdminHandler(templatesFS, teamsConfig, registry, auditLogger) if err != nil { log.Fatal("Fehler beim Laden des Team-Verwaltungs-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)) // Team-Verwaltung — Teams anlegen/umbenennen/löschen und Keys neu erzeugen. // Wie /admin/audit über die Team-Session erreichbar, der Handler selbst prüft // zusätzlich IsAdminFromContext (siehe handler/teams_admin.go). mux.Handle("/admin/teams", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleTeamsPage), registry, sessionSecretBytes)) mux.Handle("/admin/teams/create", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleCreateTeam), registry, sessionSecretBytes)) mux.Handle("/admin/teams/rename", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleRenameTeam), registry, sessionSecretBytes)) mux.Handle("/admin/teams/regenerate-key", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleRegenerateKey), registry, sessionSecretBytes)) mux.Handle("/admin/teams/delete", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleDeleteTeam), 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 }