package server import ( "context" "database/sql" "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/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/embeds" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/fedwiki" "git.coopcloud.tech/wiki-cafe/member-console/internal/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/middleware" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/stripe" "github.com/gorilla/csrf" stripe "github.com/stripe/stripe-go/v81" "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 Database *sql.DB // Raw DB connection IdentityQ identity.Querier // Identity module queries OrgQ organization.Querier // Organization module queries EntitlementsQ entitlements.Querier // Entitlements module queries BillingQ billing.Querier // Billing module queries StripeQ stripedb.Querier // Stripe module queries SiteQ fwmod.Querier // FedWiki site queries 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 StripeWebhookSecret string // Stripe webhook signing secret StripeAPIKey string // Stripe API secret key StripeDashboardURL string // Stripe dashboard base URL for deep links BaseURL string // Application base URL for redirects } // 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 with identity and organization query interfaces. authConfig, err := auth.Setup(cfg.Database, cfg.IdentityQ, cfg.OrgQ) 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{ SiteQ: cfg.SiteQ, EntitlementsQ: cfg.EntitlementsQ, Database: cfg.Database, 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{ SiteQ: cfg.SiteQ, EntitlementsQ: cfg.EntitlementsQ, Database: cfg.Database, 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) // Register Operator page handler operatorHandler, err := NewOperatorHandler(OperatorHandlerConfig{ AuthConfig: authConfig, Logger: cfg.Logger, }) if err != nil { cfg.Logger.Error("failed to set up Operator handler", slog.Any("error", err)) return err } operatorHandler.RegisterRoutes(httpRequestRouter) // Register Operator HTMX partials handlers operatorPartialsHandler, err := NewOperatorPartialsHandler(OperatorPartialsConfig{ SiteQ: cfg.SiteQ, EntitlementsQ: cfg.EntitlementsQ, BillingQ: cfg.BillingQ, StripeQ: cfg.StripeQ, Database: cfg.Database, IdentityQ: cfg.IdentityQ, OrgQ: cfg.OrgQ, Logger: cfg.Logger, AuthConfig: authConfig, StripeDashboardURL: cfg.StripeDashboardURL, TemporalClient: cfg.TemporalClient, }) if err != nil { cfg.Logger.Error("failed to set up Operator partials handler", slog.Any("error", err)) return err } operatorPartialsHandler.RegisterRoutes(httpRequestRouter) // Register Workspace partials handlers workspacePartialsHandler, err := NewWorkspacePartialsHandler(WorkspacePartialsConfig{ OrgQ: cfg.OrgQ, EntitlementsQ: cfg.EntitlementsQ, AuthConfig: authConfig, Logger: cfg.Logger, }) if err != nil { cfg.Logger.Error("failed to set up Workspace partials handler", slog.Any("error", err)) return err } workspacePartialsHandler.RegisterRoutes(httpRequestRouter) // Register Member Products partials handlers memberProductsHandler, err := NewMemberProductsHandler(MemberProductsConfig{ EntitlementsQ: cfg.EntitlementsQ, BillingQ: cfg.BillingQ, AuthConfig: authConfig, Logger: cfg.Logger, }) if err != nil { cfg.Logger.Error("failed to set up Member Products handler", slog.Any("error", err)) return err } memberProductsHandler.RegisterRoutes(httpRequestRouter) // Register Stripe webhook handler (before CSRF middleware — it uses its own signature verification) if cfg.StripeWebhookSecret != "" { stripeWebhook := &StripeWebhookHandler{ DB: cfg.Database, WebhookSecret: cfg.StripeWebhookSecret, Logger: cfg.Logger, } httpRequestRouter.Handle("POST /webhooks/stripe", stripeWebhook) } // Register billing checkout handler and set Stripe API key if cfg.StripeAPIKey != "" { stripe.Key = cfg.StripeAPIKey billingCheckout := &BillingCheckoutHandler{ Database: cfg.Database, BillingQ: cfg.BillingQ, AuthConfig: authConfig, Logger: cfg.Logger, BaseURL: cfg.BaseURL, } httpRequestRouter.HandleFunc("POST /billing/checkout", billingCheckout.HandleCheckout) } // 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 // Bypass CSRF for Stripe webhook endpoint (uses its own signature verification) csrfConfig.Ignore = append(csrfConfig.Ignore, func(r *http.Request) bool { return r.URL.Path == "/webhooks/stripe" }) // 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.SessionManager.LoadAndSave, // Session management (must be before auth middleware) 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")) safeTmpl := NewSafeTemplates(tmpl, cfg.Logger) // 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") // Get session data ctx := r.Context() name := authConfig.GetUserName(ctx) username := authConfig.GetUsername(ctx) email := authConfig.GetUserEmail(ctx) // Create Keycloak Account URL keycloakAccountURL := viper.GetString("oidc-idp-issuer-url") + "/account" // Get CSRF token for HTMX requests csrfToken := middleware.CSRFToken(r) // Check if user has operator role isOperator := authConfig.HasRole(r, OperatorRole) // Check workspace count for progressive disclosure hasMultipleWorkspaces := false session := authConfig.GetUserSession(ctx) if session != nil { wsCount, err := cfg.OrgQ.CountWorkspacesByOrgID(ctx, session.OrgID) if err == nil && wsCount > 1 { hasMultipleWorkspaces = true } } data := struct { Name string Username string Email string KeycloakAccountURL string CSRFToken string IsOperator bool HasMultipleWorkspaces bool }{Name: name, Username: username, Email: email, KeycloakAccountURL: keycloakAccountURL, CSRFToken: csrfToken, IsOperator: isOperator, HasMultipleWorkspaces: hasMultipleWorkspaces} safeTmpl.Render(w, "index.html", data) }) // Serve products.html via template rendering httpRequestRouter.HandleFunc("GET /products", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") keycloakAccountURL := viper.GetString("oidc-idp-issuer-url") + "/account" csrfToken := middleware.CSRFToken(r) isOperator := authConfig.HasRole(r, OperatorRole) data := struct { KeycloakAccountURL string CSRFToken string IsOperator bool }{KeycloakAccountURL: keycloakAccountURL, CSRFToken: csrfToken, IsOperator: isOperator} safeTmpl.Render(w, "products.html", data) }) // 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 }