Introduce operator enrollment partials and handlers that route plan-tier granting and revocation through entitlements.Transition(). Add member-facing tier labels, plan architecture and grant-plan-safety documentation, plus unit and e2e tests. Also add small querier helpers and wire Temporal client hooks for trial expiration scheduling.
355 lines
13 KiB
Go
355 lines
13 KiB
Go
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
|
|
}
|