// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "sync" "sync/atomic" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/adapter/kubernetes" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/sanitize" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // ProjectsHandler handles project-related endpoints. type ProjectsHandler struct { // Legacy dependencies (for backward compatibility) projectRepo *kubernetes.ProjectRepository executor *kubernetes.Executor streams *streamManager cmdID atomic.Uint64 // New hexagonal architecture dependencies projectService *service.ProjectService } // NewProjectsHandler creates a new projects handler with injected dependencies. func NewProjectsHandler(projectRepo *kubernetes.ProjectRepository, executor *kubernetes.Executor) *ProjectsHandler { return &ProjectsHandler{ projectRepo: projectRepo, executor: executor, streams: newStreamManager(), } } // NewProjectsHandlerWithService creates a new projects handler with injected service. func NewProjectsHandlerWithService(projectService *service.ProjectService) *ProjectsHandler { return &ProjectsHandler{ projectService: projectService, } } // Mount registers the projects routes. func (h *ProjectsHandler) Mount(r api.Router) { r.Route("/projects", func(r chi.Router) { r.Get("/", h.List) r.Get("/{id}", h.Get) r.Post("/{id}/claude", h.RunClaude) r.Post("/{id}/shell", h.RunShell) r.Post("/{id}/git", h.RunGit) r.Get("/{id}/events", h.Events) }) } // getAuditContext extracts audit-related information from the HTTP request. func getAuditContext(r *http.Request) *service.AuditContext { apiKey := auth.GetAPIKey(r.Context()) if apiKey == nil { return nil } return &service.AuditContext{ APIKeyID: apiKey.ID, ClientIP: getClientIP(r), UserAgent: r.UserAgent(), } } // getClientIP extracts the client IP from the request. func getClientIP(r *http.Request) string { // Check X-Forwarded-For header (set by proxies/load balancers) if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // Take the first IP in the chain if idx := strings.Index(xff, ","); idx != -1 { return strings.TrimSpace(xff[:idx]) } return strings.TrimSpace(xff) } // Check X-Real-IP header if xri := r.Header.Get("X-Real-IP"); xri != "" { return strings.TrimSpace(xri) } // Fall back to RemoteAddr addr := r.RemoteAddr // Handle IPv6 addresses like "[::1]:8080" if strings.HasPrefix(addr, "[") { if idx := strings.LastIndex(addr, "]:"); idx != -1 { return addr[1:idx] } return strings.Trim(addr, "[]") } // Handle IPv4 addresses like "192.168.1.1:8080" if idx := strings.LastIndex(addr, ":"); idx != -1 { return addr[:idx] } return addr } // List returns all available projects. // GET /projects func (h *ProjectsHandler) List(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() // Use new service if available if h.projectService != nil { projects, err := h.projectService.List(ctx) if err != nil { api.WriteInternalError(w, r, "failed to list projects") return } api.WriteSuccess(w, r, projects) return } // Legacy path using hexagonal types if h.projectRepo != nil { _ = h.projectRepo.RefreshStatus(ctx) projects, err := h.projectRepo.List(ctx) if err != nil { api.WriteInternalError(w, r, "failed to list projects") return } api.WriteSuccess(w, r, projects) return } api.WriteInternalError(w, r, "no project service configured") } // Get returns a specific project by ID. // GET /projects/{id} func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() // Use new service if available if h.projectService != nil { project, err := h.projectService.Get(ctx, domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } api.WriteSuccess(w, r, project) return } // Legacy path using hexagonal types if h.projectRepo != nil { _ = h.projectRepo.RefreshStatus(ctx) project, err := h.projectRepo.Get(ctx, domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } api.WriteSuccess(w, r, project) return } api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) } // ClaudeRequest is the request body for POST /projects/{id}/claude. type ClaudeRequest struct { Prompt string `json:"prompt"` StreamID string `json:"stream_id,omitempty"` } // RunClaude executes a Claude command in the project's claudebox. // POST /projects/{id}/claude func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req ClaudeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Use new service if available if h.projectService != nil { result, err := h.projectService.ExecuteClaude(r.Context(), service.ExecuteClaudeRequest{ ProjectID: domain.ProjectID(id), Prompt: req.Prompt, StreamID: req.StreamID, Audit: getAuditContext(r), }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { api.WriteBadRequest(w, r, err.Error()) return } api.WriteInternalError(w, r, "failed to execute command") return } api.WriteCreated(w, r, map[string]any{ "id": result.CommandID, "project": id, "type": "claude", "status": "running", "stream_url": result.StreamURL, }) return } // Legacy path using hexagonal types if h.projectRepo == nil || h.executor == nil { api.WriteInternalError(w, r, "no project service configured") return } project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.Required(req.Prompt, "prompt"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Sanitize prompt if err := sanitize.ClaudePrompt(req.Prompt); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Generate command ID cmdNum := h.cmdID.Add(1) cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) if req.StreamID != "" { cmdID = req.StreamID } // Create the command using domain types cmd := &domain.Command{ ID: domain.CommandID(cmdID), ProjectID: domain.ProjectID(id), Type: domain.CommandTypeClaude, Args: []string{req.Prompt}, StartedAt: time.Now(), } // Execute in background go h.executeCommand(cmd, project.PodName) api.WriteCreated(w, r, map[string]any{ "id": cmdID, "project": id, "type": "claude", "status": "running", "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), }) } // ShellRequest is the request body for POST /projects/{id}/shell. type ShellRequest struct { Command string `json:"command"` StreamID string `json:"stream_id,omitempty"` } // RunShell executes a shell command in the project's claudebox. // POST /projects/{id}/shell func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req ShellRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Use new service if available if h.projectService != nil { result, err := h.projectService.ExecuteShell(r.Context(), service.ExecuteShellRequest{ ProjectID: domain.ProjectID(id), Command: req.Command, StreamID: req.StreamID, Audit: getAuditContext(r), }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { api.WriteBadRequest(w, r, err.Error()) return } api.WriteInternalError(w, r, "failed to execute command") return } api.WriteCreated(w, r, map[string]any{ "id": result.CommandID, "project": id, "type": "shell", "status": "running", "stream_url": result.StreamURL, }) return } // Legacy path using hexagonal types if h.projectRepo == nil || h.executor == nil { api.WriteInternalError(w, r, "no project service configured") return } project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.Required(req.Command, "command"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Sanitize command - CRITICAL for security if err := sanitize.ShellCommand(req.Command); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Generate command ID cmdNum := h.cmdID.Add(1) cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) if req.StreamID != "" { cmdID = req.StreamID } // Create the command using domain types cmd := &domain.Command{ ID: domain.CommandID(cmdID), ProjectID: domain.ProjectID(id), Type: domain.CommandTypeShell, Args: []string{req.Command}, StartedAt: time.Now(), } // Execute in background go h.executeCommand(cmd, project.PodName) api.WriteCreated(w, r, map[string]any{ "id": cmdID, "project": id, "type": "shell", "status": "running", "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), }) } // GitRequest is the request body for POST /projects/{id}/git. type GitRequest struct { Args []string `json:"args"` StreamID string `json:"stream_id,omitempty"` } // RunGit executes a git command in the project's claudebox. // POST /projects/{id}/git func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req GitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Use new service if available if h.projectService != nil { result, err := h.projectService.ExecuteGit(r.Context(), service.ExecuteGitRequest{ ProjectID: domain.ProjectID(id), Args: req.Args, StreamID: req.StreamID, Audit: getAuditContext(r), }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { api.WriteBadRequest(w, r, err.Error()) return } api.WriteInternalError(w, r, "failed to execute command") return } api.WriteCreated(w, r, map[string]any{ "id": result.CommandID, "project": id, "type": "git", "status": "running", "stream_url": result.StreamURL, }) return } // Legacy path using hexagonal types if h.projectRepo == nil || h.executor == nil { api.WriteInternalError(w, r, "no project service configured") return } project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.RequiredSlice(req.Args, "args"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Sanitize git args if err := sanitize.GitArgs(req.Args); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Generate command ID cmdNum := h.cmdID.Add(1) cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) if req.StreamID != "" { cmdID = req.StreamID } // Create the command using domain types cmd := &domain.Command{ ID: domain.CommandID(cmdID), ProjectID: domain.ProjectID(id), Type: domain.CommandTypeGit, Args: req.Args, StartedAt: time.Now(), } // Execute in background go h.executeCommand(cmd, project.PodName) api.WriteCreated(w, r, map[string]any{ "id": cmdID, "project": id, "type": "git", "status": "running", "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), }) } // executeCommand runs a command and streams output to subscribers. func (h *ProjectsHandler) executeCommand(cmd *domain.Command, podName string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() cmdID := string(cmd.ID) result, _ := h.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) { h.streams.Send(cmdID, "output", map[string]any{ "line": line.Line, "stream": line.Stream, }) }) // Send completion event h.streams.Send(cmdID, "complete", map[string]any{ "exit_code": result.ExitCode, "duration_ms": result.DurationMs, }) // Clean up stream after a delay go func() { time.Sleep(30 * time.Second) h.streams.Close(cmdID) }() } // Events streams command output via Server-Sent Events. // GET /projects/{id}/events // Supports Last-Event-ID header for reconnection with event replay. func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") streamID := r.URL.Query().Get("stream_id") lastEventID := r.Header.Get("Last-Event-ID") // Check project exists if h.projectService != nil { exists, err := h.projectService.Exists(r.Context(), domain.ProjectID(id)) if err != nil || !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } } else if h.projectRepo != nil { exists, err := h.projectRepo.Exists(r.Context(), domain.ProjectID(id)) if err != nil || !exists { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } } else { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") flusher, ok := w.(http.Flusher) if !ok { api.WriteInternalError(w, r, "SSE not supported") return } // Subscribe to events - use service if available, with Last-Event-ID support var events <-chan port.StreamEvent var cleanup func() if h.projectService != nil { if lastEventID != "" { events, cleanup = h.projectService.SubscribeFromID(streamID, lastEventID) } else { events, cleanup = h.projectService.Subscribe(streamID) } } else { legacyEvents := h.streams.Subscribe(streamID) // Create adapter from legacy to port.StreamEvent with context cancellation portEvents := make(chan port.StreamEvent, 100) adapterCtx, adapterCancel := context.WithCancel(r.Context()) go func() { defer close(portEvents) for { select { case ev, ok := <-legacyEvents: if !ok { return } select { case portEvents <- port.StreamEvent{Type: ev.Type, Data: ev.Data}: case <-adapterCtx.Done(): return } case <-adapterCtx.Done(): return } } }() events = portEvents cleanup = func() { adapterCancel() h.streams.Unsubscribe(streamID, legacyEvents) } } defer cleanup() // Send initial connected event writeSSE(w, flusher, "connected", map[string]any{ "project": id, "stream_id": streamID, "reconnecting": lastEventID != "", }) // Stream events until client disconnects or stream closes ctx := r.Context() heartbeat := time.NewTicker(30 * time.Second) defer heartbeat.Stop() for { select { case <-ctx.Done(): return case event, ok := <-events: if !ok { return } // Include event ID in SSE output for reconnection support writeSSEWithID(w, flusher, event.ID, event.Type, event.Data) if event.Type == "complete" { return } case <-heartbeat.C: writeSSE(w, flusher, "heartbeat", map[string]any{ "timestamp": time.Now().UTC().Format(time.RFC3339), }) } } } // writeSSE writes a Server-Sent Event. func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) { writeSSEWithID(w, flusher, "", event, data) } // writeSSEWithID writes a Server-Sent Event with an optional event ID. func writeSSEWithID(w http.ResponseWriter, flusher http.Flusher, id, event string, data map[string]any) { dataBytes, _ := json.Marshal(data) if id != "" { _, _ = fmt.Fprintf(w, "id: %s\n", id) } _, _ = fmt.Fprintf(w, "event: %s\n", event) _, _ = fmt.Fprintf(w, "data: %s\n\n", dataBytes) flusher.Flush() } // streamManager manages SSE event streams. type streamManager struct { mu sync.RWMutex streams map[string][]chan streamEvent } type streamEvent struct { Type string Data map[string]any } func newStreamManager() *streamManager { return &streamManager{ streams: make(map[string][]chan streamEvent), } } func (sm *streamManager) Subscribe(streamID string) chan streamEvent { sm.mu.Lock() defer sm.mu.Unlock() ch := make(chan streamEvent, 100) sm.streams[streamID] = append(sm.streams[streamID], ch) return ch } func (sm *streamManager) Unsubscribe(streamID string, ch chan streamEvent) { sm.mu.Lock() defer sm.mu.Unlock() channels := sm.streams[streamID] for i, c := range channels { if c == ch { sm.streams[streamID] = append(channels[:i], channels[i+1:]...) close(ch) break } } } func (sm *streamManager) Send(streamID, eventType string, data map[string]any) { sm.mu.RLock() defer sm.mu.RUnlock() for _, ch := range sm.streams[streamID] { select { case ch <- streamEvent{Type: eventType, Data: data}: default: // Channel full, skip } } } func (sm *streamManager) Close(streamID string) { sm.mu.Lock() defer sm.mu.Unlock() for _, ch := range sm.streams[streamID] { close(ch) } delete(sm.streams, streamID) } // ProjectRepository returns the project repository for use by other handlers. func (h *ProjectsHandler) ProjectRepository() *kubernetes.ProjectRepository { return h.projectRepo } // Executor returns the executor for use by other handlers. func (h *ProjectsHandler) Executor() *kubernetes.Executor { return h.executor }