package server import ( "context" "html/template" "io/fs" "log/slog" "net" "net/http" "net/url" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/auth" "git.coopcloud.tech/wiki-cafe/member-console/internal/db" "git.coopcloud.tech/wiki-cafe/member-console/internal/embeds" "git.coopcloud.tech/wiki-cafe/member-console/internal/middleware" "github.com/gorilla/csrf" "github.com/rs/cors" "github.com/spf13/viper" "go.temporal.io/sdk/client" ) // Config holds the configuration for the server. type Config struct { Port string Env string CSRFSecret string Logger *slog.Logger DB db.Querier FedWikiFarmAPIURL string // URL for FarmManager API calls FedWikiAllowedDomains []string // Domains where users can create sites FedWikiSiteScheme string // http or https for site URLs FedWikiAdminToken string SupportURL string TemporalClient client.Client // nil if not configured } // Start initializes and starts the HTTP server. func Start(ctx context.Context, cfg Config) error { // Create a new HTTP request router httpRequestRouter := http.NewServeMux() // Set up authentication. Pass the database connection to the auth package. authConfig, err := auth.Setup(cfg.DB) if err != nil { cfg.Logger.Error("failed to set up authentication", slog.Any("error", err)) return err } // Register auth handlers authConfig.RegisterHandlers(httpRequestRouter) // Register FedWiki API handlers fedwikiHandler := NewFedWikiHandler(FedWikiHandlerConfig{ DB: cfg.DB, Logger: cfg.Logger, TemporalClient: cfg.TemporalClient, AuthConfig: authConfig, FedWikiAllowedDomains: cfg.FedWikiAllowedDomains, FedWikiSiteScheme: cfg.FedWikiSiteScheme, SupportURL: cfg.SupportURL, }) fedwikiHandler.RegisterRoutes(httpRequestRouter) // Register FedWiki HTMX partials handlers fedwikiPartialsHandler, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{ DB: cfg.DB, Logger: cfg.Logger, TemporalClient: cfg.TemporalClient, AuthConfig: authConfig, FedWikiAllowedDomains: cfg.FedWikiAllowedDomains, FedWikiSiteScheme: cfg.FedWikiSiteScheme, SupportURL: cfg.SupportURL, }) if err != nil { cfg.Logger.Error("failed to set up FedWiki partials handler", slog.Any("error", err)) return err } fedwikiPartialsHandler.RegisterRoutes(httpRequestRouter) // Create CORS configuration with default options corsOptions := cors.Options{ // Define minimal defaults - GET method is required AllowedMethods: []string{"GET"}, } // Create empty CSRF configuration with default values var csrfConfig middleware.CSRFConfig // Get and validate CSRF secret from config csrfKey, err := middleware.ParseCSRFKey(cfg.CSRFSecret) if err != nil { cfg.Logger.Error("invalid csrf-secret", slog.String("error", err.Error()), slog.String("hint", "must be exactly 32 bytes and persist across restarts")) return err } csrfConfig.Secret = csrfKey // Add CSRF error handler for debugging csrfConfig.ErrorHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cfg.Logger.Error("CSRF validation failed", slog.String("path", r.URL.Path), slog.String("method", r.Method), slog.String("reason", csrf.FailureReason(r).Error()), slog.String("origin", r.Header.Get("Origin")), slog.String("referer", r.Header.Get("Referer"))) // Include "CSRF" in response for client-side detection // This allows the frontend to preserve user input and show a helpful message http.Error(w, "CSRF token invalid or expired. Please refresh the page.", http.StatusForbidden) }) // Only override specific settings when needed if cfg.Env == "development" { // In development, cookies often need to work without HTTPS csrfConfig.Cookie.Secure = false } // Always set cookie path to "/" to avoid multiple CSRF cookies with different paths // Without this, gorilla/csrf creates separate cookies for different URL paths, // causing token mismatches (e.g., token from "/" doesn't match cookie from "/partials/fedwiki") csrfConfig.Cookie.Path = "/" // Add base URL as trusted origin for CSRF validation // gorilla/csrf expects just host:port, not the full URL with scheme baseURL := viper.GetString("base-url") if baseURL != "" { // Parse the URL to extract just the host if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" { csrfConfig.TrustedOrigins = []string{parsed.Host} cfg.Logger.Info("CSRF trusted origins configured", slog.Any("origins", csrfConfig.TrustedOrigins)) } } // Create middleware stack stack := middleware.CreateStack( middleware.RequestID(), // Generate a unique request ID middleware.Logging(), // Log requests with structured logging middleware.Recovery(), // Catch all panics middleware.Timeout(32*time.Second), // Set request timeout middleware.MaxBodySize(1024*1024), // 1MB size limit middleware.SecureHeaders(), // Set secure headers middleware.CORS(corsOptions), // CORS configuration middleware.CSRF(csrfConfig), // CSRF protection middleware.Compress(), // Response compression authConfig.Middleware(), // OIDC authentication middleware ) // Create HTTP server server := http.Server{ Addr: ":" + cfg.Port, Handler: stack(httpRequestRouter), ReadTimeout: 4 * time.Second, WriteTimeout: 8 * time.Second, IdleTimeout: 16 * time.Second, MaxHeaderBytes: 1024 * 1024, // 1MB BaseContext: func(_ net.Listener) context.Context { return ctx }, // Pass base context to all requests } // For embedded templates templateSubFS, err := fs.Sub(embeds.Templates, "templates") if err != nil { cfg.Logger.Error("Failed to create sub filesystem for templates", slog.Any("error", err)) return err } // Parse templates from embedded FS tmpl := template.Must(template.ParseFS(templateSubFS, "*.html")) // Serve index.html via template rendering httpRequestRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Always serve HTML w.Header().Set("Content-Type", "text/html; charset=utf-8") // Ensure user is authenticated session, err := authConfig.Store.Get(r, authConfig.SessionName) if err != nil { http.Redirect(w, r, "/login", http.StatusFound) return } name, _ := session.Values["name"].(string) username, _ := session.Values["username"].(string) email, _ := session.Values["email"].(string) // Create Keycloak Account URL keycloakAccountURL := viper.GetString("oidc-idp-issuer-url") + "/account" // Get CSRF token for HTMX requests csrfToken := middleware.CSRFToken(r) data := struct { Name string Username string Email string KeycloakAccountURL string CSRFToken string }{Name: name, Username: username, Email: email, KeycloakAccountURL: keycloakAccountURL, CSRFToken: csrfToken} if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil { cfg.Logger.Error("template execute error", slog.Any("error", err)) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } }) // For embedded static files httpRequestRouter.Handle("/static/", http.FileServer(http.FS(embeds.Static))) // Log server startup with structured logging cfg.Logger.Info("starting server", slog.String("port", cfg.Port), slog.String("environment", cfg.Env), slog.String("address", "http://localhost:"+cfg.Port)) // Start server and log any errors if err := server.ListenAndServe(); err != nil { cfg.Logger.Error("server error", slog.Any("error", err)) return err } return nil }