package handlers import ( "context" "fmt" "net/http" "net/url" "path" "strings" "time" "github.com/flatrender/render-svc/internal/models" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/minio/minio-go/v7" ) // TemplateBundleHandler stores the canonical After Effects template object for a // project. Each project has ONE template at a deterministic per-project key, and // every render of that project reuses it. The source may be a plain .aep/.aepx or // a .zip bundle (the .aep plus its footage/fonts) — the node agent extracts zips. type TemplateBundleHandler struct { minio *minio.Client templatesBucket string } func NewTemplateBundleHandler(mc *minio.Client, templatesBucket string) *TemplateBundleHandler { return &TemplateBundleHandler{minio: mc, templatesBucket: templatesBucket} } type setBundleRequest struct { // SourceURL is the public/path-style MinIO URL of the freshly uploaded file, // e.g. http://host:9000/user-uploads/uploads//.zip SourceURL string `json:"source_url" binding:"required"` } type setBundleResponse struct { Bucket string `json:"bucket"` Key string `json:"key"` MD5 string `json:"md5"` Size int64 `json:"size_bytes"` IsBundle bool `json:"is_bundle"` } // POST /v1/template-bundles/:project_id // Copies an uploaded .aep/.aepx/.zip into templates/{project_id}/(bundle.zip| // template.aep|template.aepx) via a server-side MinIO copy (same backend, no // proxy through this service), then returns the stored key + md5 so the caller // can record it on the project. Replacing a template overwrites the same key, so // in-flight and future renders pick up the new bundle (md5 changes → nodes refetch). func (h *TemplateBundleHandler) Set(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 } var req setBundleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()}) return } if h.minio == nil { c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "no_storage", Message: "object storage not configured"}) return } srcBucket, srcKey, err := parseObjectURL(req.SourceURL) if err != nil { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_source", Message: err.Error()}) return } ext := strings.ToLower(path.Ext(srcKey)) isBundle := ext == ".zip" destName := "template.aep" switch ext { case ".zip": destName = "bundle.zip" case ".aepx": destName = "template.aepx" default: // .aep or unknown — treat as a raw project file destName = "template.aep" } destKey := fmt.Sprintf("templates/%s/%s", pid, destName) ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute) defer cancel() // The templates bucket may not exist on a fresh deployment — create it (private; // the node downloads via presigned URL, so no public policy is needed). if exists, berr := h.minio.BucketExists(ctx, h.templatesBucket); berr == nil && !exists { if merr := h.minio.MakeBucket(ctx, h.templatesBucket, minio.MakeBucketOptions{}); merr != nil { // A concurrent request may have created it — only fail if it's still missing. if ex2, _ := h.minio.BucketExists(ctx, h.templatesBucket); !ex2 { c.JSON(http.StatusBadGateway, models.APIError{Code: "bucket_error", Message: merr.Error()}) return } } } // Drop any stale sibling in the other format so the claim probe (which checks // bundle.zip → template.aep → template.aepx in order) can't return the old file. for _, n := range []string{"bundle.zip", "template.aep", "template.aepx"} { if n != destName { _ = h.minio.RemoveObject(ctx, h.templatesBucket, fmt.Sprintf("templates/%s/%s", pid, n), minio.RemoveObjectOptions{}) } } src := minio.CopySrcOptions{Bucket: srcBucket, Object: srcKey} dst := minio.CopyDestOptions{Bucket: h.templatesBucket, Object: destKey} if _, err := h.minio.CopyObject(ctx, dst, src); err != nil { c.JSON(http.StatusBadGateway, models.APIError{Code: "copy_failed", Message: err.Error()}) return } info, err := h.minio.StatObject(ctx, h.templatesBucket, destKey, minio.StatObjectOptions{}) if err != nil { c.JSON(http.StatusBadGateway, models.APIError{Code: "stat_failed", Message: err.Error()}) return } c.JSON(http.StatusOK, setBundleResponse{ Bucket: h.templatesBucket, Key: destKey, MD5: strings.Trim(info.ETag, "\""), Size: info.Size, IsBundle: isBundle, }) } // parseObjectURL extracts the bucket and object key from a path-style storage URL // such as http://host:9000//. Query/fragment are ignored. func parseObjectURL(raw string) (bucket, key string, err error) { u, perr := url.Parse(strings.TrimSpace(raw)) if perr != nil { return "", "", fmt.Errorf("parse url: %w", perr) } p := strings.TrimPrefix(u.Path, "/") if p == "" { return "", "", fmt.Errorf("url has no path: %q", raw) } parts := strings.SplitN(p, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "", fmt.Errorf("cannot derive bucket/key from %q", raw) } k, uerr := url.PathUnescape(parts[1]) if uerr != nil { k = parts[1] } return parts[0], k, nil }