package http import ( "context" "fmt" "net/http" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware" svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) // WireServer is a Wire-compatible HTTP server (without fx.Lifecycle dependency) type WireServer struct { server *http.Server logger *zap.Logger config *config.Config mux *http.ServeMux handlers *Handlers registerHandler *auth.RegisterHandler requestOTTHandler *auth.RequestOTTHandler verifyOTTHandler *auth.VerifyOTTHandler completeLoginHandler *auth.CompleteLoginHandler refreshTokenHandler *auth.RefreshTokenHandler verifyEmailHandler *auth.VerifyEmailHandler resendVerificationHandler *auth.ResendVerificationHandler recoveryInitiateHandler *auth.RecoveryInitiateHandler recoveryVerifyHandler *auth.RecoveryVerifyHandler recoveryCompleteHandler *auth.RecoveryCompleteHandler rateLimitMiddleware *middleware.RateLimitMiddleware securityHeadersMiddleware *middleware.SecurityHeadersMiddleware } // NewWireServer creates a Wire-compatible HTTP server func NewWireServer( cfg *config.Config, logger *zap.Logger, handlers *Handlers, registerService svc_auth.RegisterService, verifyEmailService svc_auth.VerifyEmailService, resendVerificationService svc_auth.ResendVerificationService, requestOTTService svc_auth.RequestOTTService, verifyOTTService svc_auth.VerifyOTTService, completeLoginService svc_auth.CompleteLoginService, refreshTokenService svc_auth.RefreshTokenService, recoveryInitiateService svc_auth.RecoveryInitiateService, recoveryVerifyService svc_auth.RecoveryVerifyService, recoveryCompleteService svc_auth.RecoveryCompleteService, rateLimitMiddleware *middleware.RateLimitMiddleware, securityHeadersMiddleware *middleware.SecurityHeadersMiddleware, ) *WireServer { mux := http.NewServeMux() // Initialize auth handlers with services registerHandler := auth.NewRegisterHandler(logger, registerService) verifyEmailHandler := auth.NewVerifyEmailHandler(logger, verifyEmailService) resendVerificationHandler := auth.NewResendVerificationHandler(logger, resendVerificationService) requestOTTHandler := auth.NewRequestOTTHandler(logger, requestOTTService) verifyOTTHandler := auth.NewVerifyOTTHandler(logger, verifyOTTService) completeLoginHandler := auth.NewCompleteLoginHandler(logger, completeLoginService) refreshTokenHandler := auth.NewRefreshTokenHandler(logger, refreshTokenService) recoveryInitiateHandler := auth.NewRecoveryInitiateHandler(logger, recoveryInitiateService) recoveryVerifyHandler := auth.NewRecoveryVerifyHandler(logger, recoveryVerifyService) recoveryCompleteHandler := auth.NewRecoveryCompleteHandler(logger, recoveryCompleteService) s := &WireServer{ logger: logger, config: cfg, mux: mux, handlers: handlers, registerHandler: registerHandler, requestOTTHandler: requestOTTHandler, verifyOTTHandler: verifyOTTHandler, completeLoginHandler: completeLoginHandler, refreshTokenHandler: refreshTokenHandler, verifyEmailHandler: verifyEmailHandler, resendVerificationHandler: resendVerificationHandler, recoveryInitiateHandler: recoveryInitiateHandler, recoveryVerifyHandler: recoveryVerifyHandler, recoveryCompleteHandler: recoveryCompleteHandler, rateLimitMiddleware: rateLimitMiddleware, securityHeadersMiddleware: securityHeadersMiddleware, } // Register routes (simplified for Phase 2) s.registerRoutes() // Create HTTP server with middleware s.server = &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), Handler: s.applyMiddleware(mux), ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout, } return s } // Start starts the HTTP server func (s *WireServer) Start() error { s.logger.Info("Starting HTTP server", zap.String("address", s.server.Addr), zap.String("environment", s.config.App.Environment), ) if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { return fmt.Errorf("HTTP server failed: %w", err) } return nil } // Shutdown gracefully shuts down the HTTP server func (s *WireServer) Shutdown(ctx context.Context) error { s.logger.Info("Shutting down HTTP server") return s.server.Shutdown(ctx) } // registerRoutes registers all HTTP routes func (s *WireServer) registerRoutes() { s.logger.Info("Registering HTTP routes") // ===== Public Routes ===== s.mux.HandleFunc("GET /health", s.healthCheckHandler) s.mux.HandleFunc("GET /version", s.versionHandler) // User lookup - Public route for user public key lookup s.mux.HandleFunc("GET /iam/api/v1/users/lookup", s.handlers.UserPublicLookup.ServeHTTP) // Auth routes - Registration & Email Verification (with auth rate limiting) // These endpoints use general auth rate limiting to prevent automated abuse s.mux.HandleFunc("POST /api/v1/register", s.rateLimitMiddleware.AuthRateLimit(s.registerHandler.ServeHTTP)) s.mux.HandleFunc("POST /api/v1/verify-email-code", s.rateLimitMiddleware.AuthRateLimit(s.verifyEmailHandler.ServeHTTP)) s.mux.HandleFunc("POST /api/v1/resend-verification", s.rateLimitMiddleware.AuthRateLimit(s.resendVerificationHandler.ServeHTTP)) // Auth routes - Login Flow (OTT-based) (with login rate limiting) // These endpoints use login-specific rate limiting with account lockout // CWE-307: Protection against brute force attacks s.mux.HandleFunc("POST /api/v1/request-ott", s.rateLimitMiddleware.LoginRateLimit(s.requestOTTHandler.ServeHTTP)) s.mux.HandleFunc("POST /api/v1/verify-ott", s.rateLimitMiddleware.LoginRateLimit(s.verifyOTTHandler.ServeHTTP)) s.mux.HandleFunc("POST /api/v1/complete-login", s.rateLimitMiddleware.LoginRateLimit(s.completeLoginHandler.ServeHTTP)) // Auth routes - Token Management (with auth rate limiting) s.mux.HandleFunc("POST /api/v1/token/refresh", s.rateLimitMiddleware.AuthRateLimit(s.refreshTokenHandler.ServeHTTP)) // Auth routes - Account Recovery (with login rate limiting) // Recovery endpoints need same protection as login to prevent enumeration attacks s.mux.HandleFunc("POST /api/v1/recovery/initiate", s.rateLimitMiddleware.LoginRateLimit(s.recoveryInitiateHandler.ServeHTTP)) s.mux.HandleFunc("POST /api/v1/recovery/verify", s.rateLimitMiddleware.LoginRateLimit(s.recoveryVerifyHandler.ServeHTTP)) s.mux.HandleFunc("POST /api/v1/recovery/complete", s.rateLimitMiddleware.LoginRateLimit(s.recoveryCompleteHandler.ServeHTTP)) // ===== Protected Routes ===== // Me / Profile routes s.mux.HandleFunc("GET /api/v1/me", s.handlers.GetMe.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/me", s.handlers.UpdateMe.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/me", s.handlers.DeleteMe.ServeHTTP) s.mux.HandleFunc("POST /api/v1/me/email/change-request", s.handlers.ChangeEmailRequest.ServeHTTP) s.mux.HandleFunc("POST /api/v1/me/email/change-verify", s.handlers.ChangeEmailVerify.ServeHTTP) // Blocked Email routes s.mux.HandleFunc("POST /api/v1/me/blocked-emails", s.handlers.CreateBlockedEmail.ServeHTTP) s.mux.HandleFunc("GET /api/v1/me/blocked-emails", s.handlers.ListBlockedEmails.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/me/blocked-emails/{email}", s.handlers.DeleteBlockedEmail.ServeHTTP) // Invite Email routes s.mux.HandleFunc("POST /api/v1/invites/send-email", s.handlers.SendInviteEmail.ServeHTTP) // Dashboard s.mux.HandleFunc("GET /api/v1/dashboard", s.handlers.GetDashboard.ServeHTTP) // Collections - Basic CRUD s.mux.HandleFunc("POST /api/v1/collections", s.handlers.CreateCollection.ServeHTTP) s.mux.HandleFunc("GET /api/v1/collections", s.handlers.ListUserCollections.ServeHTTP) s.mux.HandleFunc("GET /api/v1/collections/{id}", s.handlers.GetCollection.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/collections/{id}", s.handlers.UpdateCollection.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/collections/{id}", s.handlers.SoftDeleteCollection.ServeHTTP) // Collections - Hierarchical s.mux.HandleFunc("GET /api/v1/collections/root", s.handlers.FindRootCollections.ServeHTTP) s.mux.HandleFunc("GET /api/v1/collections/parent/{parent_id}", s.handlers.FindCollectionsByParent.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/collections/{id}/move", s.handlers.MoveCollection.ServeHTTP) // Collections - Sharing s.mux.HandleFunc("POST /api/v1/collections/{id}/share", s.handlers.ShareCollection.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/collections/{id}/members/{user_id}", s.handlers.RemoveMember.ServeHTTP) s.mux.HandleFunc("GET /api/v1/collections/shared", s.handlers.ListSharedCollections.ServeHTTP) // Collections - Operations s.mux.HandleFunc("PUT /api/v1/collections/{id}/archive", s.handlers.ArchiveCollection.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/collections/{id}/restore", s.handlers.RestoreCollection.ServeHTTP) s.mux.HandleFunc("GET /api/v1/collections/filtered", s.handlers.GetFilteredCollections.ServeHTTP) s.mux.HandleFunc("POST /api/v1/collections/sync", s.handlers.CollectionSync.ServeHTTP) // Tags - Basic CRUD (non-parameterized routes first) s.mux.HandleFunc("POST /api/v1/tags", s.handlers.CreateTag.ServeHTTP) s.mux.HandleFunc("GET /api/v1/tags", s.handlers.ListTags.ServeHTTP) // Tags - Filtering operations (specific paths before wildcards) s.mux.HandleFunc("GET /api/v1/tags/collections", s.handlers.ListCollectionsByTag.ServeHTTP) s.mux.HandleFunc("GET /api/v1/tags/files", s.handlers.ListFilesByTag.ServeHTTP) s.mux.HandleFunc("GET /api/v1/tags/search", s.handlers.SearchByTags.ServeHTTP) // Tags - Retrieval by entity (using /for/ prefix to avoid route conflicts) s.mux.HandleFunc("GET /api/v1/tags/for/collection/{collection_id}", s.handlers.GetTagsForCollection.ServeHTTP) s.mux.HandleFunc("GET /api/v1/tags/for/file/{file_id}", s.handlers.GetTagsForFile.ServeHTTP) // Tags - Assignment operations (specific paths before generic {id}) s.mux.HandleFunc("POST /api/v1/tags/{id}/assign", s.handlers.AssignTag.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/tags/{tagId}/entities/{entityId}", s.handlers.UnassignTag.ServeHTTP) // Tags - Generic CRUD with {id} parameter (MUST come last to avoid conflicts) s.mux.HandleFunc("GET /api/v1/tags/{id}", s.handlers.GetTag.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/tags/{id}", s.handlers.UpdateTag.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/tags/{id}", s.handlers.DeleteTag.ServeHTTP) // Files - Non-parameterized routes (no wildcards) s.mux.HandleFunc("POST /api/v1/files/pending", s.handlers.CreatePendingFile.ServeHTTP) s.mux.HandleFunc("POST /api/v1/files/delete-multiple", s.handlers.DeleteMultipleFiles.ServeHTTP) s.mux.HandleFunc("GET /api/v1/files/recent", s.handlers.ListRecentFiles.ServeHTTP) s.mux.HandleFunc("POST /api/v1/files/sync", s.handlers.FileSync.ServeHTTP) // Files - Parameterized routes under /file/ prefix (singular) to avoid conflicts s.mux.HandleFunc("POST /api/v1/file/{id}/complete", s.handlers.CompleteFileUpload.ServeHTTP) s.mux.HandleFunc("POST /api/v1/file/{id}/download-completed", s.handlers.ReportDownloadCompleted.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/file/{id}/archive", s.handlers.ArchiveFile.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/file/{id}/restore", s.handlers.RestoreFile.ServeHTTP) s.mux.HandleFunc("GET /api/v1/file/{id}/upload-url", s.handlers.GetPresignedUploadURL.ServeHTTP) s.mux.HandleFunc("GET /api/v1/file/{id}/download-url", s.handlers.GetPresignedDownloadURL.ServeHTTP) s.mux.HandleFunc("GET /api/v1/file/{id}", s.handlers.GetFile.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/file/{id}", s.handlers.UpdateFile.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/file/{id}", s.handlers.SoftDeleteFile.ServeHTTP) // Files by collection - under /collection/ prefix s.mux.HandleFunc("GET /api/v1/collection/{collection_id}/files", s.handlers.ListFilesByCollection.ServeHTTP) s.logger.Info("HTTP routes registered", zap.Int("total_routes", 71)) } // Health check handler func (s *WireServer) healthCheckHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"healthy","service":"maplefile-backend","di":"Wire"}`)) } // Version handler func (s *WireServer) versionHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) version := fmt.Sprintf(`{"version":"%s","environment":"%s","di":"Wire"}`, s.config.App.Version, s.config.App.Environment) w.Write([]byte(version)) } // Middleware implementations // applyMiddleware applies global middleware to the handler func (s *WireServer) applyMiddleware(handler http.Handler) http.Handler { // Apply middleware in reverse order (last applied is executed first) // Logging middleware (outermost) handler = s.loggingMiddleware(handler) // CORS middleware handler = s.corsMiddleware(handler) // Security headers middleware (adds security headers to all responses) handler = s.securityHeadersMiddleware.Handler(handler) // Recovery middleware (catches panics) handler = s.recoveryMiddleware(handler) return handler } func (s *WireServer) loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip logging health check requests if r.URL.Path == "/health" { next.ServeHTTP(w, r) return } // Simple logging for Wire version s.logger.Info("HTTP request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.String("remote_addr", validation.MaskIP(r.RemoteAddr)), ) next.ServeHTTP(w, r) }) } func (s *WireServer) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Get the origin from the request origin := r.Header.Get("Origin") // Build allowed origins map allowedOrigins := make(map[string]bool) // In development, always allow localhost origins if s.config.App.Environment == "development" { allowedOrigins["http://localhost:5173"] = true // Vite dev server allowedOrigins["http://localhost:5174"] = true // Alternative Vite port allowedOrigins["http://localhost:3000"] = true // Common React port allowedOrigins["http://127.0.0.1:5173"] = true allowedOrigins["http://127.0.0.1:5174"] = true allowedOrigins["http://127.0.0.1:3000"] = true } // Add production origins from configuration for _, allowedOrigin := range s.config.Security.AllowedOrigins { if allowedOrigin != "" { allowedOrigins[allowedOrigin] = true } } // Check if the request origin is allowed if allowedOrigins[origin] { // SECURITY FIX: Validate origin before setting CORS headers // CWE-942: Permissive Cross-domain Policy with Untrusted Domains // OWASP A05:2021: Security Misconfiguration - Secure CORS configuration // Prevent wildcard origin with credentials (major security risk) if origin == "*" { s.logger.Error("CRITICAL: Wildcard origin (*) cannot be used with credentials", zap.String("path", r.URL.Path)) // Don't set CORS headers for wildcard - this is a misconfiguration next.ServeHTTP(w, r) return } // In production, enforce HTTPS origins for security if s.config.App.Environment == "production" { if len(origin) >= 5 && origin[:5] == "http:" { s.logger.Warn("Non-HTTPS origin rejected in production", zap.String("origin", origin), zap.String("path", r.URL.Path)) // Don't set CORS headers for non-HTTPS origins in production next.ServeHTTP(w, r) return } } // Set CORS headers for validated origins w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") // Only set credentials for specific, non-wildcard origins // This prevents credential leakage to untrusted domains if origin != "*" && origin != "" { w.Header().Set("Access-Control-Allow-Credentials", "true") } w.Header().Set("Access-Control-Max-Age", "3600") // Cache preflight for 1 hour s.logger.Debug("CORS headers added", zap.String("origin", origin), zap.String("path", r.URL.Path), zap.Bool("credentials_allowed", origin != "*")) } else if origin != "" { // Log rejected origins for debugging s.logger.Warn("CORS request from disallowed origin", zap.String("origin", origin), zap.String("path", r.URL.Path)) } // Handle preflight requests if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) } func (s *WireServer) recoveryMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { s.logger.Error("Panic recovered", zap.Any("error", err), zap.String("path", r.URL.Path), ) problem := httperror.NewInternalServerError("An unexpected error occurred") problem.WithInstance(r.URL.Path) httperror.RespondWithProblem(w, problem) } }() next.ServeHTTP(w, r) }) }