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

194 lines
7.3 KiB
Go

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