// Package rest implements the HTTP/JSON transport for the notifier // service: request routing, handlers, authentication and CORS middleware, // and API key management endpoints. package rest import ( "context" "encoding/json" "net/http" "strconv" "strings" "time" "github.com/gorilla/mux" "github.com/igodwin/notifier/internal/auth" "github.com/igodwin/notifier/internal/domain" "github.com/igodwin/notifier/internal/logging" ) // CORSConfig contains CORS middleware configuration type CORSConfig struct { // AllowedOrigins is a whitelist of allowed origins (e.g., ["https://example.com", "https://app.example.com"]) // Wildcards are NOT supported for security reasons AllowedOrigins []string // AllowedMethods is a list of allowed HTTP methods (e.g., ["GET", "POST", "OPTIONS", "DELETE"]) AllowedMethods []string // AllowedHeaders is a list of allowed HTTP headers (e.g., ["Content-Type", "Authorization"]) AllowedHeaders []string // AllowCredentials indicates whether credentials (cookies, authorization headers) are allowed // Note: When true, AllowedOrigins must NOT contain wildcards AllowCredentials bool // MaxAge is the duration in seconds that browsers can cache preflight responses MaxAge int } // DefaultCORSConfig returns a secure default CORS configuration // By default, no origins are allowed - you must explicitly configure allowed origins func DefaultCORSConfig() *CORSConfig { return &CORSConfig{ AllowedOrigins: []string{}, // Empty by default - must be explicitly configured AllowedMethods: []string{"GET", "POST", "OPTIONS", "DELETE"}, AllowedHeaders: []string{"Content-Type", "Authorization"}, AllowCredentials: false, MaxAge: 3600, // 1 hour } } // ReadinessCheck reports whether a named dependency is ready. Implementations // should be cheap; they run on every /readyz request. type ReadinessCheck func(ctx context.Context) error // RouterOptions configures the REST router. type RouterOptions struct { Service domain.NotificationService Logger *logging.Logger AuthStore *auth.APIKeyStore // nil disables authentication KeyStore *auth.HybridKeyStore // nil disables key-management routes CORS *CORSConfig // nil disables CORS headers entirely // Readiness maps a component name (e.g. "queue", "database") to its check. Readiness map[string]ReadinessCheck // Instrument, when set, wraps the router for request instrumentation // (e.g. Prometheus HTTP metrics). Instrument func(http.Handler) http.Handler } // NewRouter creates a new HTTP router with all routes configured func NewRouter(service domain.NotificationService, logger *logging.Logger) *mux.Router { return NewRouterWithOptions(RouterOptions{Service: service, Logger: logger}) } // NewRouterWithAuth creates a new HTTP router with optional authentication func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore) *mux.Router { return NewRouterWithOptions(RouterOptions{Service: service, Logger: logger, AuthStore: authStore}) } // NewRouterWithAuthAndKeyStore creates a new HTTP router with authentication and key management func NewRouterWithAuthAndKeyStore(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore, keyStore *auth.HybridKeyStore) *mux.Router { return NewRouterWithOptions(RouterOptions{Service: service, Logger: logger, AuthStore: authStore, KeyStore: keyStore}) } // NewRouterWithOptions creates the HTTP router from RouterOptions. func NewRouterWithOptions(opts RouterOptions) *mux.Router { handler := NewHandler(opts.Service, opts.Logger) router := mux.NewRouter() // API v1 routes v1 := router.PathPrefix("/api/v1").Subrouter() // Apply authentication middleware if auth store is provided if opts.AuthStore != nil { authMiddleware := auth.NewRESTAuthMiddleware(opts.AuthStore, opts.Logger) v1.Use(authMiddleware.Middleware) } // Notification routes v1.HandleFunc("/notifications", handler.SendNotification).Methods(http.MethodPost) v1.HandleFunc("/notifications/batch", handler.SendBatchNotifications).Methods(http.MethodPost) v1.HandleFunc("/notifications", handler.ListNotifications).Methods(http.MethodGet) v1.HandleFunc("/notifications/{id}", handler.GetNotification).Methods(http.MethodGet) v1.HandleFunc("/notifications/{id}", handler.CancelNotification).Methods(http.MethodDelete) v1.HandleFunc("/notifications/{id}/retry", handler.RetryNotification).Methods(http.MethodPost) // Stats route v1.HandleFunc("/stats", handler.GetStats).Methods(http.MethodGet) // Notifiers route v1.HandleFunc("/notifiers", handler.GetNotifiers).Methods(http.MethodGet) // Key management routes (requires auth and keystore) if opts.AuthStore != nil && opts.KeyStore != nil { keyHandler := NewKeyManagementHandler(opts.KeyStore, opts.Logger) v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost) v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet) v1.HandleFunc("/admin/keys/{name}", keyHandler.RevokeKey).Methods(http.MethodDelete) v1.HandleFunc("/admin/keys/{name}/rotate", keyHandler.RotateKey).Methods(http.MethodPost) v1.HandleFunc("/admin/keys/{name}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet) } // Liveness and readiness routes (no auth required). /health stays a pure // liveness signal; /readyz fails when a dependency is unavailable. router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet) router.HandleFunc("/readyz", readinessHandler(opts.Readiness)).Methods(http.MethodGet) // Middleware - CORS (when configured), logging, and request size limits if opts.CORS != nil && len(opts.CORS.AllowedOrigins) > 0 { router.Use(newCORSMiddleware(opts.CORS)) } if opts.Instrument != nil { router.Use(mux.MiddlewareFunc(opts.Instrument)) } router.Use(loggingMiddleware) v1.Use(maxBodySizeMiddleware(1 << 20)) // 1 MB limit on API request bodies return router } // LivenessHandler returns a minimal liveness handler for dedicated health // listeners (the REST router serves the same signal at /health). func LivenessHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") // Headers are already written, so an encode error can only be // dropped on the floor here. _ = json.NewEncoder(w).Encode(map[string]interface{}{ "status": "healthy", "service": "notifier", "time": time.Now().UTC(), }) }) } // ReadinessHandler exposes the readiness checks for dedicated health listeners. func ReadinessHandler(checks map[string]ReadinessCheck) http.Handler { return readinessHandler(checks) } // readinessHandler runs each dependency check and reports 503 if any fail. func readinessHandler(checks map[string]ReadinessCheck) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() status := http.StatusOK components := make(map[string]string, len(checks)) for name, check := range checks { if err := check(ctx); err != nil { status = http.StatusServiceUnavailable components[name] = "unavailable" } else { components[name] = "ok" } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) ready := status == http.StatusOK // Headers are already written, so an encode error can only be // dropped on the floor here. _ = json.NewEncoder(w).Encode(map[string]interface{}{ "ready": ready, "components": components, }) } } // maxBodySizeMiddleware limits the size of incoming request bodies to prevent DoS. func maxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, maxBytes) } next.ServeHTTP(w, r) }) } } // loggingMiddleware logs incoming requests func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // You can add structured logging here next.ServeHTTP(w, r) }) } // newCORSMiddleware creates a CORS middleware with origin whitelist validation func newCORSMiddleware(config *CORSConfig) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") // Check if the origin is in the allowed list allowed := false for _, allowedOrigin := range config.AllowedOrigins { if origin == allowedOrigin { allowed = true break } } // Only set CORS headers if the origin is allowed if allowed { // Set the exact origin (never use wildcard) w.Header().Set("Access-Control-Allow-Origin", origin) // Set allowed methods if len(config.AllowedMethods) > 0 { w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", ")) } // Set allowed headers if len(config.AllowedHeaders) > 0 { w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", ")) } // Set credentials header if enabled if config.AllowCredentials { w.Header().Set("Access-Control-Allow-Credentials", "true") } // Set max age for preflight caching if config.MaxAge > 0 { w.Header().Set("Access-Control-Max-Age", strconv.FormatInt(int64(config.MaxAge), 10)) } } // Handle preflight OPTIONS requests: succeed only for allowed // origins; disallowed cross-origin preflights get 403 with no // CORS headers so browsers block the actual request. if r.Method == http.MethodOptions && origin != "" { if allowed { w.WriteHeader(http.StatusNoContent) } else { w.WriteHeader(http.StatusForbidden) } return } next.ServeHTTP(w, r) }) } }