package http import ( "context" "fmt" "net/http" "time" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) // Server represents the HTTP server type Server struct { server *http.Server logger *zap.Logger config *config.Config mux *http.ServeMux middleware middleware.Middleware handlers *Handlers } // NewServer creates a new HTTP server func NewServer( cfg *config.Config, logger *zap.Logger, mw middleware.Middleware, handlers *Handlers, ) *Server { mux := http.NewServeMux() s := &Server{ logger: logger, config: cfg, mux: mux, middleware: mw, handlers: handlers, } // Register routes s.registerRoutes() // Create HTTP server with configuration 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 *Server) 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 err } return nil } // Shutdown gracefully shuts down the HTTP server func (s *Server) Shutdown(ctx context.Context) error { s.logger.Info("Shutting down HTTP server") return s.server.Shutdown(ctx) } // applyMiddleware applies global middleware to the handler func (s *Server) applyMiddleware(handler http.Handler) http.Handler { // Apply middleware in reverse order (last applied is executed first) // TODO: Add more middleware in Phase 6 // Logging middleware (outermost) handler = s.loggingMiddleware(handler) // CORS middleware handler = s.corsMiddleware(handler) // Recovery middleware (catches panics) handler = s.recoveryMiddleware(handler) return handler } // registerRoutes registers all HTTP routes func (s *Server) registerRoutes() { s.logger.Info("Registering HTTP routes") // ===== Public Routes ===== s.mux.HandleFunc("GET /health", s.healthCheckHandler) s.mux.HandleFunc("GET /version", s.versionHandler) // TODO: Auth routes to be implemented in Phase 7 // s.mux.HandleFunc("POST /api/v1/auth/register", authHandler.Register) // s.mux.HandleFunc("POST /api/v1/auth/login", authHandler.Login) // ===== 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) // 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) // 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("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) // Tags - Basic CRUD s.mux.HandleFunc("POST /api/v1/tags", s.handlers.CreateTag.ServeHTTP) s.mux.HandleFunc("GET /api/v1/tags", s.handlers.ListTags.ServeHTTP) 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) // Tags - Assignment 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 - Entity lookups s.mux.HandleFunc("GET /api/v1/collections/{id}/tags", s.handlers.GetTagsForCollection.ServeHTTP) s.mux.HandleFunc("GET /api/v1/files/{id}/tags", s.handlers.GetTagsForFile.ServeHTTP) // Tags - Multi-tag filtering (requires tags query parameter with comma-separated UUIDs) 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.logger.Info("HTTP routes registered", zap.Int("total_routes", 58)) } // Health check handler func (s *Server) 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"}`)) } // Version handler func (s *Server) 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"}`, s.config.App.Version, s.config.App.Environment) w.Write([]byte(version)) } // Middleware implementations func (s *Server) loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip logging health check requests to reduce noise if r.URL.Path == "/health" { next.ServeHTTP(w, r) return } start := time.Now() // Wrap response writer to capture status code wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} next.ServeHTTP(wrapped, r) duration := time.Since(start) s.logger.Info("HTTP request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Int("status", wrapped.statusCode), zap.Duration("duration", duration), zap.String("remote_addr", validation.MaskIP(r.RemoteAddr)), ) }) } func (s *Server) 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 *Server) 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) }) } // responseWriter wraps http.ResponseWriter to capture status code type responseWriter struct { http.ResponseWriter statusCode int } func (rw *responseWriter) WriteHeader(code int) { rw.statusCode = code rw.ResponseWriter.WriteHeader(code) }