All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
365 lines
10 KiB
Go
365 lines
10 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/auth"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
"github.com/orchard9/rdev/internal/worker"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
// VerifyHandler handles visual verification endpoints.
|
|
type VerifyHandler struct {
|
|
verifyService *service.VerifyService
|
|
streams port.StreamPublisher
|
|
}
|
|
|
|
// NewVerifyHandler creates a new verify handler.
|
|
func NewVerifyHandler(verifyService *service.VerifyService, streams port.StreamPublisher) *VerifyHandler {
|
|
return &VerifyHandler{
|
|
verifyService: verifyService,
|
|
streams: streams,
|
|
}
|
|
}
|
|
|
|
// Mount registers the verify routes.
|
|
func (h *VerifyHandler) Mount(r api.Router) {
|
|
r.Route("/verify", func(r chi.Router) {
|
|
r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Post("/", h.Submit)
|
|
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}", h.Get)
|
|
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}/stream", h.Stream)
|
|
r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Delete("/{taskId}", h.Cancel)
|
|
})
|
|
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin), auth.RequireProjectAccess("id")).Get("/projects/{id}/verify", h.ListByProject)
|
|
}
|
|
|
|
// SubmitVerifyRequest is the request body for POST /verify.
|
|
type SubmitVerifyRequest struct {
|
|
ProjectID string `json:"project_id"`
|
|
URL string `json:"url"`
|
|
Viewports []string `json:"viewports,omitempty"`
|
|
WaitFor string `json:"wait_for,omitempty"`
|
|
WaitTimeout int `json:"wait_timeout,omitempty"`
|
|
FullPage bool `json:"full_page,omitempty"`
|
|
Video bool `json:"video,omitempty"`
|
|
CallbackURL string `json:"callback_url,omitempty"`
|
|
}
|
|
|
|
// SubmitVerifyResponse is the response for POST /verify.
|
|
type SubmitVerifyResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
StatusURL string `json:"status_url"`
|
|
StreamURL string `json:"stream_url"`
|
|
}
|
|
|
|
// VerifyTaskResponse is the response for GET /verify/{taskId}.
|
|
type VerifyTaskResponse struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
Status string `json:"status"`
|
|
URL string `json:"url"`
|
|
Screenshots map[string]string `json:"screenshots,omitempty"`
|
|
Video string `json:"video,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
|
}
|
|
|
|
// Submit enqueues a visual verification task.
|
|
// POST /verify
|
|
func (h *VerifyHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
|
var req SubmitVerifyRequest
|
|
if err := api.DecodeJSON(r, &req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.ProjectID == "" {
|
|
api.WriteBadRequest(w, r, "project_id is required")
|
|
return
|
|
}
|
|
if req.URL == "" {
|
|
api.WriteBadRequest(w, r, "url is required")
|
|
return
|
|
}
|
|
|
|
// Validate callback URL if provided
|
|
if req.CallbackURL != "" {
|
|
if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Build verify spec
|
|
spec := domain.VerifySpec{
|
|
URL: req.URL,
|
|
Viewports: req.Viewports,
|
|
WaitFor: req.WaitFor,
|
|
WaitTimeout: req.WaitTimeout,
|
|
FullPage: req.FullPage,
|
|
Video: req.Video,
|
|
CallbackURL: req.CallbackURL,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
|
defer cancel()
|
|
|
|
taskID, err := h.verifyService.SubmitCapture(ctx, req.ProjectID, spec)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrVerifyURLRequired) {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to submit verify task")
|
|
return
|
|
}
|
|
|
|
api.WriteCreated(w, r, SubmitVerifyResponse{
|
|
TaskID: taskID,
|
|
StatusURL: "/verify/" + taskID,
|
|
StreamURL: "/verify/" + taskID + "/stream",
|
|
})
|
|
}
|
|
|
|
// Get retrieves a verify task by ID.
|
|
// GET /verify/{taskId}
|
|
func (h *VerifyHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
if taskID == "" {
|
|
api.WriteBadRequest(w, r, "task ID is required")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
|
defer cancel()
|
|
|
|
task, err := h.verifyService.GetCapture(ctx, taskID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get verify task")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, toVerifyTaskResponse(task))
|
|
}
|
|
|
|
// Stream streams verify task events via SSE.
|
|
// GET /verify/{taskId}/stream
|
|
func (h *VerifyHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
if taskID == "" {
|
|
api.WriteBadRequest(w, r, "task ID is required")
|
|
return
|
|
}
|
|
|
|
lastEventID := r.Header.Get("Last-Event-ID")
|
|
|
|
// Verify task exists
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
|
defer cancel()
|
|
|
|
_, err := h.verifyService.GetCapture(ctx, taskID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get verify task")
|
|
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
|
|
var events <-chan port.StreamEvent
|
|
var cleanup func()
|
|
|
|
if lastEventID != "" {
|
|
events, cleanup = h.streams.SubscribeFromID(taskID, lastEventID)
|
|
} else {
|
|
events, cleanup = h.streams.Subscribe(taskID)
|
|
}
|
|
defer cleanup()
|
|
|
|
// Send initial connected event
|
|
writeSSE(w, flusher, "connected", map[string]any{
|
|
"task_id": taskID,
|
|
"reconnecting": lastEventID != "",
|
|
})
|
|
|
|
// Stream events until client disconnects or stream closes
|
|
reqCtx := r.Context()
|
|
heartbeat := time.NewTicker(30 * time.Second)
|
|
defer heartbeat.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-reqCtx.Done():
|
|
return
|
|
case event, ok := <-events:
|
|
if !ok {
|
|
return
|
|
}
|
|
writeSSEWithID(w, flusher, event.ID, event.Type, event.Data)
|
|
// Check for terminal events
|
|
if event.Type == worker.VerifyEventCompleted || event.Type == worker.VerifyEventFailed {
|
|
return
|
|
}
|
|
case <-heartbeat.C:
|
|
writeSSE(w, flusher, "heartbeat", map[string]any{
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cancel cancels a pending verify task.
|
|
// DELETE /verify/{taskId}
|
|
func (h *VerifyHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
if taskID == "" {
|
|
api.WriteBadRequest(w, r, "task ID is required")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
|
defer cancel()
|
|
|
|
if err := h.verifyService.CancelCapture(ctx, taskID); err != nil {
|
|
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
|
|
return
|
|
}
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"task_id": taskID,
|
|
"status": "cancelled",
|
|
"message": "verify task cancelled successfully",
|
|
})
|
|
}
|
|
|
|
// ListByProject returns verify tasks for a project.
|
|
// GET /projects/{id}/verify?limit=50&offset=0
|
|
func (h *VerifyHandler) ListByProject(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
if projectID == "" {
|
|
api.WriteBadRequest(w, r, "project ID is required")
|
|
return
|
|
}
|
|
|
|
// Parse pagination options
|
|
opts := domain.DefaultWorkListOptions()
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
limit, err := strconv.Atoi(limitStr)
|
|
if err != nil {
|
|
api.WriteBadRequest(w, r, "limit must be a valid integer")
|
|
return
|
|
}
|
|
opts.Limit = limit
|
|
}
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
offset, err := strconv.Atoi(offsetStr)
|
|
if err != nil {
|
|
api.WriteBadRequest(w, r, "offset must be a valid integer")
|
|
return
|
|
}
|
|
opts.Offset = offset
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
|
|
defer cancel()
|
|
|
|
result, err := h.verifyService.ListCaptures(ctx, projectID, opts)
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to list verify tasks")
|
|
return
|
|
}
|
|
|
|
dtos := make([]*VerifyTaskResponse, len(result.Tasks))
|
|
for i, task := range result.Tasks {
|
|
dtos[i] = toVerifyTaskResponse(task)
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"tasks": dtos,
|
|
"project_id": projectID,
|
|
"total": result.Total,
|
|
"limit": result.Limit,
|
|
"offset": result.Offset,
|
|
})
|
|
}
|
|
|
|
// toVerifyTaskResponse converts a WorkTask to a VerifyTaskResponse.
|
|
func toVerifyTaskResponse(task *domain.WorkTask) *VerifyTaskResponse {
|
|
if task == nil {
|
|
return nil
|
|
}
|
|
|
|
resp := &VerifyTaskResponse{
|
|
ID: task.ID,
|
|
ProjectID: task.ProjectID,
|
|
Status: string(task.Status),
|
|
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
Error: task.Error,
|
|
}
|
|
|
|
// Extract URL from spec
|
|
if url, ok := task.Spec["url"].(string); ok {
|
|
resp.URL = url
|
|
}
|
|
|
|
// Extract results from task.Result artifacts
|
|
if task.Result != nil && task.Result.Artifacts != nil {
|
|
resp.Screenshots = make(map[string]string)
|
|
for key, value := range task.Result.Artifacts {
|
|
if len(key) > 11 && key[:11] == "screenshot_" {
|
|
viewport := key[11:]
|
|
resp.Screenshots[viewport] = value
|
|
}
|
|
if key == "video" {
|
|
resp.Video = value
|
|
}
|
|
if key == "duration_ms" {
|
|
if durationMs, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
resp.DurationMs = durationMs
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if task.CompletedAt != nil {
|
|
resp.CompletedAt = task.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
|
|
return resp
|
|
}
|