package handlers import ( "archive/zip" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "path" "strings" "time" "github.com/flatrender/render-svc/internal/aep" "github.com/flatrender/render-svc/internal/db" "github.com/flatrender/render-svc/internal/models" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/minio/minio-go/v7" ) // ScanHandler exposes the headless Go "quick scan" and the async AE scan-job // lifecycle. Both produce the same ScanResult shape the content importer consumes. type ScanHandler struct { store *db.Store minio *minio.Client templatesBucket string } func NewScanHandler(store *db.Store, mc *minio.Client, templatesBucket string) *ScanHandler { return &ScanHandler{store: store, minio: mc, templatesBucket: templatesBucket} } // ── ScanResult shape (matches content-svc) ──────────────────────────────────── type scanColor struct { ElementKey string `json:"element_key"` Title string `json:"title"` AttrValue string `json:"attr_value"` DefaultColor string `json:"default_color"` Sort int `json:"sort"` } type scanScene struct { Key string `json:"key"` Title string `json:"title"` SceneType string `json:"scene_type"` DefaultDurationSec *float64 `json:"default_duration_sec,omitempty"` Sort int `json:"sort"` Elements []interface{} `json:"elements"` Colors []scanColor `json:"colors"` } type scanResult struct { Source string `json:"source"` RenderComp string `json:"render_comp,omitempty"` Scenes []scanScene `json:"scenes"` SharedColors []scanColor `json:"shared_colors"` } // reserved comp names that are not scenes. func isReservedComp(name string) bool { switch strings.ToLower(name) { case "frfinal", "frshare", "all": return true } return false } // ── Quick scan (headless, no AE) ────────────────────────────────────────────── // POST /v1/template-scans/:project_id/quick func (h *ScanHandler) QuickScan(c *gin.Context) { pid, err := uuid.Parse(c.Param("project_id")) if err != nil { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid project_id"}) return } if h.minio == nil { c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "no_storage", Message: "object storage not configured"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) defer cancel() data, err := h.loadAep(ctx, pid) if err != nil { c.JSON(http.StatusNotFound, models.APIError{Code: "no_template", Message: err.Error()}) return } comps, err := aep.ParseComps(data) if err != nil { c.JSON(http.StatusUnprocessableEntity, models.APIError{Code: "parse_failed", Message: err.Error()}) return } res := scanResult{Source: "go-parser", Scenes: []scanScene{}, SharedColors: []scanColor{}} for _, comp := range comps { if strings.EqualFold(comp.Name, "frfinal") { res.RenderComp = "frfinal" } if isReservedComp(comp.Name) { continue } sc := scanScene{ Key: comp.Name, Title: comp.Name, SceneType: "Normal", Sort: len(res.Scenes), Elements: []interface{}{}, Colors: []scanColor{}, } if comp.DurationSec > 0 { d := comp.DurationSec sc.DefaultDurationSec = &d } res.Scenes = append(res.Scenes, sc) } c.JSON(http.StatusOK, res) } // loadAep fetches the project's template .aep bytes from MinIO — directly when a // raw .aep was uploaded, or by extracting the .aep from the .zip bundle. func (h *ScanHandler) loadAep(ctx context.Context, pid uuid.UUID) ([]byte, error) { tryGet := func(key string) ([]byte, bool) { if _, err := h.minio.StatObject(ctx, h.templatesBucket, key, minio.StatObjectOptions{}); err != nil { return nil, false } obj, err := h.minio.GetObject(ctx, h.templatesBucket, key, minio.GetObjectOptions{}) if err != nil { return nil, false } defer obj.Close() b, rerr := io.ReadAll(obj) if rerr != nil || len(b) == 0 { return nil, false } return b, true } if b, ok := tryGet(fmt.Sprintf("templates/%s/template.aep", pid)); ok { return b, nil } if zb, ok := tryGet(fmt.Sprintf("templates/%s/bundle.zip", pid)); ok { return extractAepFromZip(zb) } return nil, errors.New("no .aep template uploaded for this project (a raw .aepx can only be read by the AE scan)") } func extractAepFromZip(zb []byte) ([]byte, error) { zr, err := zip.NewReader(bytes.NewReader(zb), int64(len(zb))) if err != nil { return nil, fmt.Errorf("open bundle: %w", err) } var best *zip.File bestDepth := 1 << 30 for _, f := range zr.File { base := path.Base(f.Name) if strings.HasPrefix(base, "._") || strings.Contains(f.Name, "__MACOSX") { continue } if strings.ToLower(path.Ext(f.Name)) == ".aep" { depth := strings.Count(f.Name, "/") if depth < bestDepth { bestDepth = depth best = f } } } if best == nil { return nil, errors.New("no .aep file found inside the bundle") } rc, err := best.Open() if err != nil { return nil, err } defer rc.Close() return io.ReadAll(rc) } // ── AE scan jobs (async, full fidelity) ─────────────────────────────────────── // POST /v1/template-scans/:project_id/jobs (admin) func (h *ScanHandler) CreateJob(c *gin.Context) { pid, err := uuid.Parse(c.Param("project_id")) if err != nil { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid project_id"}) return } id, err := h.store.CreateScanJob(c.Request.Context(), pid, "ae-jsx") if err != nil { c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) return } c.JSON(http.StatusOK, gin.H{"id": id, "status": "queued"}) } // GET /v1/template-scan-jobs/:id (admin) func (h *ScanHandler) GetJob(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"}) return } j, err := h.store.GetScanJob(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) return } if j == nil { c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "scan job not found"}) return } c.JSON(http.StatusOK, j) } // ── internal (node agents, HMAC) ────────────────────────────────────────────── // POST /v1/internal/scan/claim func (h *ScanHandler) Claim(c *gin.Context) { var req models.ClaimJobRequest _ = c.ShouldBindJSON(&req) claim, err := h.store.ClaimScanJob(c.Request.Context(), req.NodeID) if err != nil { c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) return } if claim == nil { c.Status(http.StatusNoContent) return } url, isBundle, md5 := resolveTemplateObject(h.minio, h.templatesBucket, claim.ProjectID) c.JSON(http.StatusOK, gin.H{ "scan_job_id": claim.ID, "project_id": claim.ProjectID, "aep_download_url": url, "is_bundle": isBundle, "bundle_md5": md5, }) } // POST /v1/internal/scan/:id/result body = ScanResult JSON func (h *ScanHandler) Result(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"}) return } body, err := io.ReadAll(c.Request.Body) if err != nil || !json.Valid(body) { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "result must be valid JSON"}) return } if err := h.store.SetScanResult(c.Request.Context(), id, string(body)); err != nil { c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) return } c.Status(http.StatusNoContent) } // POST /v1/internal/scan/:id/fail body {reason} func (h *ScanHandler) Fail(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"}) return } var req struct { Reason string `json:"reason"` } _ = c.ShouldBindJSON(&req) if req.Reason == "" { req.Reason = "scan failed" } if err := h.store.SetScanError(c.Request.Context(), id, req.Reason); err != nil { c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) return } c.Status(http.StatusNoContent) } // resolveTemplateObject presigns the canonical template object for a project, // probing bundle.zip → template.aep → template.aepx (same order as render claim). func resolveTemplateObject(mc *minio.Client, bucket string, projectID uuid.UUID) (url string, isBundle bool, md5 string) { if mc == nil { return "", false, "" } candidates := []struct { name string bundle bool }{ {"bundle.zip", true}, {"template.aep", false}, {"template.aepx", false}, } for _, cand := range candidates { key := fmt.Sprintf("templates/%s/%s", projectID, cand.name) info, serr := mc.StatObject(context.Background(), bucket, key, minio.StatObjectOptions{}) if serr != nil { continue } purl, perr := mc.PresignedGetObject(context.Background(), bucket, key, 2*time.Hour, nil) if perr != nil { continue } return purl.String(), cand.bundle, strings.Trim(info.ETag, "\"") } return "", false, "" }