package api import ( "encoding/json" "fmt" "log" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "fuego/internal/auth" "fuego/internal/database" "fuego/internal/storage" ) // Server represents the API server type Server struct { db *database.DB auth *auth.Auth secrets *auth.Secrets storage *storage.Storage router *chi.Mux } // NewServer creates a new API server func NewServer(db *database.DB, auth *auth.Auth, storage *storage.Storage) (*Server, error) { secrets, err := auth.NewSecrets(db.DB) if err != nil { return nil, fmt.Errorf("failed to initialize secrets: %w", err) } s := &Server{ db: db, auth: auth, secrets: secrets, storage: storage, router: chi.NewRouter(), } s.setupMiddleware() s.setupRoutes() return s, nil } // setupMiddleware configures middleware func (s *Server) setupMiddleware() { s.router.Use(middleware.Logger) s.router.Use(middleware.Recoverer) s.router.Use(middleware.Timeout(60 * time.Second)) s.router.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, ExposedHeaders: []string{"Link"}, AllowCredentials: true, MaxAge: 300, })) } // setupRoutes configures routes func (s *Server) setupRoutes() { // Public routes s.router.Route("/api/auth", func(r chi.Router) { r.Get("/google/login", s.handleGoogleLogin) r.Get("/google/callback", s.handleGoogleCallback) r.Get("/discord/login", s.handleDiscordLogin) r.Get("/discord/callback", s.handleDiscordCallback) r.Post("/logout", s.handleLogout) r.Get("/me", s.handleGetMe) }) // Protected routes s.router.Route("/api/jobs", func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(s.auth.Middleware(next.ServeHTTP)) }) r.Post("/", s.handleCreateJob) r.Get("/", s.handleListJobs) r.Get("/{id}", s.handleGetJob) r.Delete("/{id}", s.handleCancelJob) r.Post("/{id}/upload", s.handleUploadJobFile) r.Get("/{id}/files", s.handleListJobFiles) r.Get("/{id}/files/{fileId}/download", s.handleDownloadJobFile) r.Get("/{id}/video", s.handleStreamVideo) }) s.router.Route("/api/runners", func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(s.auth.Middleware(next.ServeHTTP)) }) r.Get("/", s.handleListRunners) }) // Admin routes s.router.Route("/api/admin", func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(s.auth.AdminMiddleware(next.ServeHTTP)) }) r.Route("/runners", func(r chi.Router) { r.Route("/tokens", func(r chi.Router) { r.Post("/", s.handleGenerateRegistrationToken) r.Get("/", s.handleListRegistrationTokens) r.Delete("/{id}", s.handleRevokeRegistrationToken) }) r.Get("/", s.handleListRunnersAdmin) r.Post("/{id}/verify", s.handleVerifyRunner) r.Delete("/{id}", s.handleDeleteRunner) }) }) // Runner API s.router.Route("/api/runner", func(r chi.Router) { // Registration doesn't require auth (uses token) r.Post("/register", s.handleRegisterRunner) // All other endpoints require runner authentication r.Group(func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(s.runnerAuthMiddleware(next.ServeHTTP)) }) r.Post("/heartbeat", s.handleRunnerHeartbeat) r.Get("/tasks", s.handleGetRunnerTasks) r.Post("/tasks/{id}/complete", s.handleCompleteTask) r.Post("/tasks/{id}/progress", s.handleUpdateTaskProgress) r.Get("/files/{jobId}/{fileName}", s.handleDownloadFileForRunner) r.Post("/files/{jobId}/upload", s.handleUploadFileFromRunner) r.Get("/jobs/{jobId}/status", s.handleGetJobStatusForRunner) r.Get("/jobs/{jobId}/files", s.handleGetJobFilesForRunner) }) }) // Serve static files (built React app) s.router.Handle("/*", http.FileServer(http.Dir("./web/dist"))) } // ServeHTTP implements http.Handler func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) } // JSON response helpers func (s *Server) respondJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(data); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func (s *Server) respondError(w http.ResponseWriter, status int, message string) { s.respondJSON(w, status, map[string]string{"error": message}) } // Auth handlers func (s *Server) handleGoogleLogin(w http.ResponseWriter, r *http.Request) { url, err := s.auth.GoogleLoginURL() if err != nil { s.respondError(w, http.StatusInternalServerError, err.Error()) return } http.Redirect(w, r, url, http.StatusFound) } func (s *Server) handleGoogleCallback(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") if code == "" { s.respondError(w, http.StatusBadRequest, "Missing code parameter") return } session, err := s.auth.GoogleCallback(r.Context(), code) if err != nil { s.respondError(w, http.StatusInternalServerError, err.Error()) return } sessionID := s.auth.CreateSession(session) http.SetCookie(w, &http.Cookie{ Name: "session_id", Value: sessionID, Path: "/", MaxAge: 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/", http.StatusFound) } func (s *Server) handleDiscordLogin(w http.ResponseWriter, r *http.Request) { url, err := s.auth.DiscordLoginURL() if err != nil { s.respondError(w, http.StatusInternalServerError, err.Error()) return } http.Redirect(w, r, url, http.StatusFound) } func (s *Server) handleDiscordCallback(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") if code == "" { s.respondError(w, http.StatusBadRequest, "Missing code parameter") return } session, err := s.auth.DiscordCallback(r.Context(), code) if err != nil { s.respondError(w, http.StatusInternalServerError, err.Error()) return } sessionID := s.auth.CreateSession(session) http.SetCookie(w, &http.Cookie{ Name: "session_id", Value: sessionID, Path: "/", MaxAge: 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/", http.StatusFound) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_id") if err == nil { s.auth.DeleteSession(cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: "session_id", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) s.respondJSON(w, http.StatusOK, map[string]string{"message": "Logged out"}) } func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_id") if err != nil { s.respondError(w, http.StatusUnauthorized, "Not authenticated") return } session, ok := s.auth.GetSession(cookie.Value) if !ok { s.respondError(w, http.StatusUnauthorized, "Invalid session") return } s.respondJSON(w, http.StatusOK, map[string]interface{}{ "id": session.UserID, "email": session.Email, "name": session.Name, "is_admin": session.IsAdmin, }) } // Helper to get user ID from context func getUserID(r *http.Request) (int64, error) { userID, ok := auth.GetUserID(r.Context()) if !ok { return 0, fmt.Errorf("user ID not found in context") } return userID, nil } // Helper to parse ID from URL func parseID(r *http.Request, param string) (int64, error) { idStr := chi.URLParam(r, param) id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return 0, fmt.Errorf("invalid ID: %s", idStr) } return id, nil }