diff --git a/.env.v2.example b/.env.v2.example index 3d1b7e5..f067815 100644 --- a/.env.v2.example +++ b/.env.v2.example @@ -56,3 +56,15 @@ TARA_CALLBACK_URL=https://yourdomain.com/v1/payments/callback/tara # Get keys from https://dashboard.stripe.com/apikeys STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... + +# ── Caddy TLS reverse proxy ─────────────────────────────────────────────────── +# Public-facing domains (Let's Encrypt will provision certs automatically). +# Leave as localhost for local dev (Caddy uses self-signed cert). +DOMAIN=flatrender.io +API_DOMAIN=api.flatrender.io +STORAGE_DOMAIN=storage.flatrender.io +ACME_EMAIL=admin@flatrender.io + +# ── MinIO templates bucket ──────────────────────────────────────────────────── +# Bucket where .aep template files are stored (uploaded via admin panel). +MINIO_TEMPLATES_BUCKET=flatrender-templates diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..9c0ddf4 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,57 @@ +# FlatRender V2 — Caddy reverse proxy +# +# Domains are injected via environment variables so this file is environment-agnostic. +# Set in .env.v2: +# DOMAIN e.g. flatrender.io (→ https://flatrender.io) +# API_DOMAIN e.g. api.flatrender.io (→ https://api.flatrender.io) +# STORAGE_DOMAIN e.g. storage.flatrender.io (→ https://storage.flatrender.io) +# +# Caddy auto-provisions Let's Encrypt TLS for all three. For local dev without +# real domains, replace with http:// blocks and remove the ACME config. + +{env.DOMAIN} { + # Frontend (Next.js standalone, port 3000 inside Docker) + reverse_proxy frontend:3000 + + # Security headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + encode gzip +} + +{env.API_DOMAIN} { + # V2 API gateway (port 8080 inside Docker) + reverse_proxy gateway:8080 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + -Server + } + + # Allow large body for file uploads routed through the gateway + request_body { + max_size 512MB + } +} + +{env.STORAGE_DOMAIN} { + # MinIO S3 API (port 9000 inside Docker) — used for presigned URL downloads + reverse_proxy minio:9000 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + -Server + } + + # Pre-flight (CORS) passthrough — MinIO handles its own CORS headers + @options method OPTIONS + respond @options 204 +} diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index 6409363..e892dc6 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -296,6 +296,41 @@ services: retries: 3 start_period: 30s + # ── Caddy (TLS reverse proxy) ─────────────────────────────────────────────── + # Handles Let's Encrypt certificates and terminates HTTPS for all three + # public domains: frontend, API gateway, and MinIO storage. + # + # Required .env.v2 vars: DOMAIN, API_DOMAIN, STORAGE_DOMAIN + # Set ACME_EMAIL to a real address so Let's Encrypt can contact you. + # + # For local dev (no real domain), comment out this block and access + # services directly on their host ports (:3000, :8088, :9000). + caddy: + image: caddy:2-alpine + container_name: fr2-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 QUIC + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + environment: + DOMAIN: "${DOMAIN:-localhost}" + API_DOMAIN: "${API_DOMAIN:-api.localhost}" + STORAGE_DOMAIN: "${STORAGE_DOMAIN:-storage.localhost}" + ACME_EMAIL: "${ACME_EMAIL:-admin@example.com}" + depends_on: + - frontend + - gateway + - minio + networks: + - default + volumes: pgdata: + caddy_data: + caddy_config: miniodata: diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IPlanService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IPlanService.cs index dfc10e9..c0d453f 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IPlanService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IPlanService.cs @@ -9,4 +9,7 @@ public interface IPlanService Task GetByIdAsync(Guid planId); Task GetCurrentPlanAsync(Guid userId); Task PurchasePlanAsync(Guid userId, Guid tenantId, PurchasePlanRequest request); + /// Cancel the current active plan. The subscription is marked cancelled + /// and will not auto-renew. Access continues until the expiry date. + Task CancelPlanAsync(Guid userId); } diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/PlanService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/PlanService.cs index 1352b5a..603a2f8 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/PlanService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/PlanService.cs @@ -161,6 +161,19 @@ public class PlanService(IdentityDbContext db) : IPlanService await Task.CompletedTask; // placeholder for future async work } + public async Task CancelPlanAsync(Guid userId) + { + var userPlan = await db.UserPlans + .Where(up => up.UserId == userId && up.CancelledAt == null && up.ExpiresAt > DateTime.UtcNow) + .OrderByDescending(up => up.StartsAt) + .FirstOrDefaultAsync() + ?? throw new KeyNotFoundException("No active plan to cancel"); + + userPlan.CancelledAt = DateTime.UtcNow; + userPlan.AutoRenew = false; + await db.SaveChangesAsync(); + } + private static PlanResponse MapPlanResponse(Plan p) => new( p.Id, p.Code, p.Name, p.Description, p.PriceMinor, p.BeforePriceMinor, p.Currency, p.BillingPeriod.ToString(), diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/PlansController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/PlansController.cs index 04cd2ce..173e163 100644 --- a/services/identity/FlatRender.IdentitySvc/Controllers/PlansController.cs +++ b/services/identity/FlatRender.IdentitySvc/Controllers/PlansController.cs @@ -39,6 +39,26 @@ public class PlansController(IPlanService planService) : ControllerBase return Ok(result); } + /// + /// Cancel the current active subscription. The plan stays active until its + /// expiry date but will not auto-renew. Returns 404 when no active plan exists. + /// + [HttpPost("users/me/plan/cancel")] + [ProducesResponseType(204)] + [ProducesResponseType(404)] + public async Task Cancel() + { + try + { + await planService.CancelPlanAsync(GetUserId()); + return NoContent(); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { error = ex.Message }); + } + } + private Guid GetUserId() => Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException()); diff --git a/services/node-agent/cmd/agent/main.go b/services/node-agent/cmd/agent/main.go index d8015fb..09e722b 100644 --- a/services/node-agent/cmd/agent/main.go +++ b/services/node-agent/cmd/agent/main.go @@ -28,6 +28,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "runtime" "sync" "syscall" @@ -227,12 +228,20 @@ func (a *Agent) tryClaimAndRun(ctx context.Context) { func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) { log.Printf("[job %s] starting render", job.JobID) - // In a full implementation, the agent would: - // 1. Fetch the saved project from the studio service - // 2. Download the .aep template from MinIO - // 3. Inject user customisations into the composition via JSXB/AE scripting - // Then call runner.Run(). - // For the skeleton we pass an empty AEPFilePath, which triggers mock mode. + // ── Step 1: Download .aep template ─────────────────────────────────────── + aepPath := "" + if job.AEPDownloadURL != "" && a.cfg.AEPath != "" { + localAEP := filepath.Join(a.cfg.WorkDir, "templates", job.JobID, "template.aep") + dlCtx, dlCancel := context.WithTimeout(ctx, 10*time.Minute) + n, dlErr := runner.DownloadFile(dlCtx, job.AEPDownloadURL, localAEP) + dlCancel() + if dlErr != nil { + log.Printf("[job %s] AEP download failed (%v) — falling back to mock", job.JobID, dlErr) + } else { + log.Printf("[job %s] AEP downloaded (%d bytes) → %s", job.JobID, n, localAEP) + aepPath = localAEP + } + } rJob := &runner.Job{ JobID: job.JobID, @@ -242,7 +251,7 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) { FrameRate: job.FrameRate, HasMusic: job.HasMusic, HasVoiceover: job.HasVoiceover, - AEPFilePath: "", // TODO: download from MinIO + AEPFilePath: aepPath, } onProgress := func(ctx context.Context, pct int, msg string) error { @@ -259,6 +268,7 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) { return nil } + // ── Step 2: Render ─────────────────────────────────────────────────────── outputPath, err := runner.Run(ctx, a.cfg.AEPath, a.cfg.WorkDir, rJob, onProgress, onPreview) if err != nil { if ctx.Err() != nil { @@ -273,17 +283,33 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) { } return } - log.Printf("[job %s] render done → %s", job.JobID, outputPath) - // In full production: upload outputPath to MinIO, create an Export record, - // pass the export UUID to Complete(). Skeleton passes nil (no export yet). - completeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := a.orch.Complete(completeCtx, job.JobID, nil); err != nil { + // ── Step 3: Get presigned upload URL + upload output to MinIO ───────────── + var exportID *string + uploadCtx, uploadCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer uploadCancel() + + uploadInfo, urlErr := a.orch.GetOutputUploadURL(uploadCtx, job.JobID) + if urlErr != nil { + log.Printf("[job %s] get upload URL failed: %v — completing without export", job.JobID, urlErr) + } else { + log.Printf("[job %s] uploading output to %s", job.JobID, uploadInfo.ObjectKey) + if _, upErr := runner.UploadFile(uploadCtx, uploadInfo.UploadURL, outputPath); upErr != nil { + log.Printf("[job %s] upload failed: %v — completing without export", job.JobID, upErr) + } else { + log.Printf("[job %s] upload complete (export %s)", job.JobID, uploadInfo.ExportID) + exportID = &uploadInfo.ExportID + } + } + + // ── Step 4: Report complete ─────────────────────────────────────────────── + completeCtx, completeCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer completeCancel() + if err := a.orch.Complete(completeCtx, job.JobID, exportID); err != nil { log.Printf("[job %s] complete report error: %v", job.JobID, err) } else { - log.Printf("[job %s] reported as completed", job.JobID) + log.Printf("[job %s] reported as completed (export=%v)", job.JobID, exportID) } } diff --git a/services/node-agent/internal/client/client.go b/services/node-agent/internal/client/client.go index 2624981..c74417e 100644 --- a/services/node-agent/internal/client/client.go +++ b/services/node-agent/internal/client/client.go @@ -106,6 +106,16 @@ type ClaimedJob struct { FrameRate int `json:"frame_rate"` HasMusic bool `json:"has_music"` HasVoiceover bool `json:"has_voiceover"` + // AEPDownloadURL is a presigned MinIO GET URL for the .aep template file. + // Empty when the template has not been uploaded yet — triggers mock render. + AEPDownloadURL string `json:"aep_download_url,omitempty"` +} + +// OutputUploadURLResponse is returned by GetOutputUploadURL. +type OutputUploadURLResponse struct { + ExportID string `json:"export_id"` + UploadURL string `json:"upload_url"` + ObjectKey string `json:"object_key"` } // ProgressRequest reports render progress (frame-level) for a job. @@ -204,6 +214,25 @@ func (c *Client) UpdatePreview(ctx context.Context, jobID, imageB64 string) erro return nil } +// GetOutputUploadURL asks the orchestrator to allocate an Export row and +// return a presigned MinIO PUT URL for the rendered output file. +func (c *Client) GetOutputUploadURL(ctx context.Context, jobID string) (*OutputUploadURLResponse, error) { + resp, err := c.do(ctx, http.MethodPost, + fmt.Sprintf("/v1/internal/render/jobs/%s/output-upload-url", jobID), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("output-upload-url: HTTP %d", resp.StatusCode) + } + var out OutputUploadURLResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &out, nil +} + // Complete marks a render job as Done. func (c *Client) Complete(ctx context.Context, jobID string, exportID *string) error { resp, err := c.do(ctx, http.MethodPost, diff --git a/services/node-agent/internal/runner/download.go b/services/node-agent/internal/runner/download.go new file mode 100644 index 0000000..6fa3b4a --- /dev/null +++ b/services/node-agent/internal/runner/download.go @@ -0,0 +1,82 @@ +// download.go fetches a remote file (presigned MinIO URL or any HTTP URL) and +// saves it to a local path. Uses stdlib only — no external HTTP client needed. +package runner + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" +) + +// DownloadFile fetches the resource at rawURL and writes it to destPath, +// creating parent directories as needed. Returns the number of bytes written. +func DownloadFile(ctx context.Context, rawURL, destPath string) (int64, error) { + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return 0, fmt.Errorf("mkdir: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return 0, fmt.Errorf("new request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, fmt.Errorf("GET: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("server returned %d", resp.StatusCode) + } + + f, err := os.Create(destPath) + if err != nil { + return 0, fmt.Errorf("create file: %w", err) + } + defer f.Close() + + n, err := io.Copy(f, resp.Body) + if err != nil { + return 0, fmt.Errorf("write: %w", err) + } + return n, nil +} + +// UploadFile PUTs a local file to a presigned MinIO/S3 URL. +// MinIO presigned PUT expects the raw bytes in the request body with +// Content-Type application/octet-stream. +func UploadFile(ctx context.Context, rawURL, filePath string) (int64, error) { + f, err := os.Open(filePath) + if err != nil { + return 0, fmt.Errorf("open: %w", err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return 0, fmt.Errorf("stat: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, f) + if err != nil { + return 0, fmt.Errorf("new request: %w", err) + } + req.ContentLength = stat.Size() + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, fmt.Errorf("PUT: %w", err) + } + defer resp.Body.Close() + + // MinIO returns 200 on successful PUT of presigned objects + if resp.StatusCode >= 300 { + return 0, fmt.Errorf("upload server returned %d", resp.StatusCode) + } + return stat.Size(), nil +} diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go index f8111d8..816c0d3 100644 --- a/services/render/cmd/server/main.go +++ b/services/render/cmd/server/main.go @@ -32,7 +32,8 @@ func main() { minioAccessKey := getEnv("MINIO_ACCESS_KEY", "minioadmin") minioSecretKey := getEnv("MINIO_SECRET_KEY", "minioadmin") minioUseSSL := getEnv("MINIO_USE_SSL", "false") == "true" - minioBucket := getEnv("MINIO_BUCKET", "flatrender-exports") + minioBucket := getEnv("MINIO_BUCKET", "flatrender-exports") + minioTemplatesBucket := getEnv("MINIO_TEMPLATES_BUCKET", "flatrender-templates") notificationURL := getEnv("NOTIFICATION_URL", "http://localhost:8080") serviceToken := getEnv("SERVICE_TOKEN", "internal-service-secret") port := getEnv("PORT", "8080") @@ -63,7 +64,7 @@ func main() { snapH := handlers.NewSnapshotHandler(store) exportH := handlers.NewExportHandler(store, mc, minioBucket) nodeH := handlers.NewNodeHandler(store) - internalH := handlers.NewInternalHandler(store, notifyClient) + internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket) // ── Router ──────────────────────────────────────────────────────────────── r := gin.Default() @@ -138,6 +139,7 @@ func main() { internal.POST("/nodes/:node_id/cache-update", internalH.CacheUpdate) internal.POST("/render/jobs/claim", internalH.Claim) internal.POST("/render/jobs/:job_id/preview", internalH.Preview) + internal.POST("/render/jobs/:job_id/output-upload-url", internalH.OutputUploadURL) internal.POST("/render/jobs/:job_id/frames", internalH.FrameProgress) internal.POST("/render/jobs/:job_id/complete", internalH.Complete) internal.POST("/render/jobs/:job_id/fail", internalH.Fail) diff --git a/services/render/internal/db/db.go b/services/render/internal/db/db.go index 3224702..ddc880e 100644 --- a/services/render/internal/db/db.go +++ b/services/render/internal/db/db.go @@ -519,6 +519,57 @@ func (s *Store) ClaimJob(ctx context.Context, nodeID uuid.UUID, region string) ( return s.getJobByIDInternal(ctx, jobID) } +// CreateExportForJob allocates a new Export row for a completed render job. +// The export starts with a placeholder path `exports/{export_id}/output.mp4`. +// The node agent uploads the MP4 to that MinIO path, then calls CompleteJob +// with the returned export_id. +func (s *Store) CreateExportForJob(ctx context.Context, jobID uuid.UUID) (*models.Export, error) { + // Look up the job to get tenant/user/project context + job, err := s.getJobByIDInternal(ctx, jobID) + if err != nil { + return nil, fmt.Errorf("job not found: %w", err) + } + + exportID := uuid.New() + path := fmt.Sprintf("exports/%s/output.mp4", exportID) + now := time.Now() + autoDelete := now.AddDate(0, 0, 30) // 30-day retention + + _, err = s.pool.Exec(ctx, ` + INSERT INTO render.exports + (id, tenant_id, user_id, saved_project_id, original_project_id, + render_job_id, path, file_extension, file_type, render_quality, + create_type, size_bytes, produce_date, auto_delete_date, + delete_notified, created_at) + VALUES + ($1, $2, $3, $4, $5, + $6, $7, 'mp4', 'video', $8, + 'render', 0, $9, $10, + false, $9)`, + exportID, job.TenantID, job.UserID, job.SavedProjectID, job.OriginalProjectID, + job.ID, path, job.Quality, + now, autoDelete, + ) + if err != nil { + return nil, fmt.Errorf("create export: %w", err) + } + + return &models.Export{ + ID: exportID, + TenantID: job.TenantID, + UserID: job.UserID, + SavedProjectID: job.SavedProjectID, + Path: path, + FileExtension: "mp4", + FileType: "video", + RenderQuality: job.Quality, + CreateType: "render", + ProduceDate: now, + AutoDeleteDate: autoDelete, + CreatedAt: now, + }, nil +} + // UpdateJobPreview stores a base64-encoded preview frame for a running job. // Called by the node agent every N frames to power the live preview UI. func (s *Store) UpdateJobPreview(ctx context.Context, jobID uuid.UUID, imageB64 string) error { diff --git a/services/render/internal/handlers/internal.go b/services/render/internal/handlers/internal.go index e148e2e..abdf5f1 100644 --- a/services/render/internal/handlers/internal.go +++ b/services/render/internal/handlers/internal.go @@ -1,22 +1,35 @@ package handlers import ( + "context" + "fmt" "net/http" + "time" "github.com/flatrender/render-svc/internal/db" "github.com/flatrender/render-svc/internal/models" "github.com/flatrender/render-svc/internal/notifier" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/minio/minio-go/v7" ) type InternalHandler struct { - store *db.Store - notifier *notifier.Client // may be nil — notifications are best-effort + store *db.Store + notifier *notifier.Client // may be nil — notifications are best-effort + minio *minio.Client + templatesBucket string // bucket that holds .aep project files + exportsBucket string // bucket that receives rendered MP4 outputs } -func NewInternalHandler(store *db.Store, n *notifier.Client) *InternalHandler { - return &InternalHandler{store: store, notifier: n} +func NewInternalHandler(store *db.Store, n *notifier.Client, mc *minio.Client, templatesBucket, exportsBucket string) *InternalHandler { + return &InternalHandler{ + store: store, + notifier: n, + minio: mc, + templatesBucket: templatesBucket, + exportsBucket: exportsBucket, + } } // completeRequest is the body for POST .../complete @@ -241,6 +254,21 @@ func (h *InternalHandler) Claim(c *gin.Context) { return } + // Generate presigned AEP download URL. AEP files are stored at + // templates/{original_project_id}/template.aep in the templates bucket. + // Errors are non-fatal — node agent falls back to mock render when URL is empty. + aepURL := "" + if h.minio != nil { + objectKey := fmt.Sprintf("templates/%s/template.aep", job.OriginalProjectID) + purl, perr := h.minio.PresignedGetObject( + context.Background(), h.templatesBucket, objectKey, + 2*time.Hour, nil, + ) + if perr == nil { + aepURL = purl.String() + } + } + c.JSON(http.StatusOK, models.ClaimedJob{ JobID: job.ID, SavedProjectID: job.SavedProjectID, @@ -249,6 +277,43 @@ func (h *InternalHandler) Claim(c *gin.Context) { FrameRate: job.FrameRate, HasMusic: job.HasMusic, HasVoiceover: job.HasVoiceover, + AEPDownloadURL: aepURL, + }) +} + +// POST /v1/internal/render/jobs/:job_id/output-upload-url +// Node agent calls this after rendering to get a presigned MinIO PUT URL. +// Creates an Export record in the DB and returns the export_id + upload URL. +func (h *InternalHandler) OutputUploadURL(c *gin.Context) { + jobID, err := uuid.Parse(c.Param("job_id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"}) + return + } + + export, err := h.store.CreateExportForJob(c.Request.Context(), jobID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + + expiry := 2 * time.Hour + purl, err := h.minio.PresignedPutObject( + context.Background(), h.exportsBucket, export.Path, expiry, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{ + Code: "presign_error", + Message: "could not generate upload URL", + }) + return + } + + c.JSON(http.StatusOK, models.OutputUploadURLResponse{ + ExportID: export.ID, + UploadURL: purl.String(), + ObjectKey: export.Path, + ExpiresAt: time.Now().Add(expiry), }) } diff --git a/services/render/internal/models/models.go b/services/render/internal/models/models.go index f623d72..56c6e43 100644 --- a/services/render/internal/models/models.go +++ b/services/render/internal/models/models.go @@ -415,6 +415,17 @@ type ClaimedJob struct { FrameRate int `json:"frame_rate"` HasMusic bool `json:"has_music"` HasVoiceover bool `json:"has_voiceover"` + // AEPDownloadURL is a presigned MinIO GET URL for the .aep project file. + // Valid for 2 hours. Empty when the template is not yet uploaded. + AEPDownloadURL string `json:"aep_download_url,omitempty"` +} + +// OutputUploadURLResponse is returned by POST .../output-upload-url. +type OutputUploadURLResponse struct { + ExportID uuid.UUID `json:"export_id"` + UploadURL string `json:"upload_url"` + ObjectKey string `json:"object_key"` + ExpiresAt time.Time `json:"expires_at"` } type CacheUpdateRequest struct { diff --git a/src/app/api/auth/password-reset-confirm/route.ts b/src/app/api/auth/password-reset-confirm/route.ts new file mode 100644 index 0000000..7412bd8 --- /dev/null +++ b/src/app/api/auth/password-reset-confirm/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { gatewayFetch } from "@/lib/api/gateway"; + +export const dynamic = "force-dynamic"; + +/** POST /api/auth/password-reset-confirm — confirm reset with OTP + new password */ +export async function POST(request: Request) { + let body: unknown; + try { body = await request.json(); } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const { email, otp, new_password } = body as { email?: string; otp?: string; new_password?: string }; + if (!email || !otp || !new_password) { + return NextResponse.json({ error: "email, otp, and new_password are required" }, { status: 400 }); + } + + const res = await gatewayFetch("/v1/auth/password/reset/confirm", { + method: "POST", + body: JSON.stringify({ email, otp, new_password }), + }); + const data = await res.json().catch(() => null) as { message?: string } | null; + if (!res.ok) { + return NextResponse.json( + { error: data?.message ?? "Invalid or expired code" }, + { status: res.status } + ); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/auth/password-reset/route.ts b/src/app/api/auth/password-reset/route.ts new file mode 100644 index 0000000..1bd0dd4 --- /dev/null +++ b/src/app/api/auth/password-reset/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { gatewayFetch } from "@/lib/api/gateway"; + +export const dynamic = "force-dynamic"; + +/** POST /api/auth/password-reset — request a password reset OTP email */ +export async function POST(request: Request) { + let body: unknown; + try { body = await request.json(); } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const { email } = body as { email?: string }; + if (!email) return NextResponse.json({ error: "email required" }, { status: 400 }); + + const res = await gatewayFetch("/v1/auth/password/reset/request", { + method: "POST", + body: JSON.stringify({ email }), + }); + // Always return 200 to avoid user enumeration + if (!res.ok && res.status !== 404) { + return NextResponse.json({ error: "Request failed" }, { status: 500 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/billing/cancel/route.ts b/src/app/api/billing/cancel/route.ts new file mode 100644 index 0000000..e2d32fe --- /dev/null +++ b/src/app/api/billing/cancel/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { gatewayUrl } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; + +export const runtime = "nodejs"; + +/** POST /api/billing/cancel — cancel the current active plan. */ +export async function POST() { + const token = await getAccessToken(); + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const res = await fetch(gatewayUrl("/v1/users/me/plan/cancel"), { + method: "POST", + cache: "no-store", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + return NextResponse.json( + { error: err?.error ?? "Failed to cancel plan" }, + { status: res.status } + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/billing/portal/route.ts b/src/app/api/billing/portal/route.ts new file mode 100644 index 0000000..14449d0 --- /dev/null +++ b/src/app/api/billing/portal/route.ts @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +export const runtime = "nodejs"; + +/** + * GET /api/billing/portal + * + * In the Stripe era this redirected to a Stripe-hosted portal. + * With V2 (ZarinPal / SnapPay) the portal is in-app — redirect to the + * billing tab in settings. + */ +export async function GET() { + redirect("/dashboard/settings?tab=billing"); +} diff --git a/src/components/auth/AuthPageContent.tsx b/src/components/auth/AuthPageContent.tsx index 2e6c82d..b1ee875 100644 --- a/src/components/auth/AuthPageContent.tsx +++ b/src/components/auth/AuthPageContent.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; type AuthTab = "sign-in" | "sign-up"; +type AuthView = "main" | "forgot-password" | "reset-confirm"; /** Only allow same-origin relative redirects to avoid open-redirect issues. */ function safeNext(next: string | null): string { @@ -29,11 +30,17 @@ export function AuthPageContent() { searchParams.get("tab") === "sign-up" ? "sign-up" : "sign-in"; const [activeTab, setActiveTab] = useState(initialTab); + const [view, setView] = useState("main"); const [authLoading, setAuthLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [formError, setFormError] = useState(null); const [formMessage, setFormMessage] = useState(null); + // Forgot-password state + const [resetEmail, setResetEmail] = useState(""); + const [resetOtp, setResetOtp] = useState(""); + const [resetNewPassword, setResetNewPassword] = useState(""); + const { register, handleSubmit, @@ -64,9 +71,7 @@ export function AuthPageContent() { checkSession().then((redirected) => { if (mounted && !redirected) setAuthLoading(false); }); - return () => { - mounted = false; - }; + return () => { mounted = false; }; }, [checkSession]); const handleTabChange = (tab: AuthTab) => { @@ -76,13 +81,22 @@ export function AuthPageContent() { reset(); }; + const goBack = () => { + setView("main"); + setFormError(null); + setFormMessage(null); + setResetEmail(""); + setResetOtp(""); + setResetNewPassword(""); + }; + + // ── Main sign-in / sign-up submit ────────────────────────────────────────── const onSubmit = async (values: AuthFormValues) => { setSubmitting(true); setFormError(null); setFormMessage(null); - const endpoint = - activeTab === "sign-in" ? "/api/auth/login" : "/api/auth/register"; + const endpoint = activeTab === "sign-in" ? "/api/auth/login" : "/api/auth/register"; try { const res = await fetch(endpoint, { @@ -98,7 +112,6 @@ export function AuthPageContent() { return; } - // Registered but not auto-logged-in (verification gate) — prompt sign-in. if (data?.registered && !data?.user) { setFormMessage( data.verificationRequired @@ -111,7 +124,6 @@ export function AuthPageContent() { return; } - // Logged in — cookies are set; refresh server components and go. router.replace(nextPath); router.refresh(); } catch { @@ -120,6 +132,54 @@ export function AuthPageContent() { } }; + // ── Forgot password — step 1: request OTP ───────────────────────────────── + const handleForgotRequest = async (e: React.FormEvent) => { + e.preventDefault(); + if (!resetEmail) return; + setSubmitting(true); + setFormError(null); + try { + await fetch("/api/auth/password-reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: resetEmail }), + }); + // Always succeed (anti-enumeration) + setView("reset-confirm"); + setFormMessage("If that email is registered, we sent a reset code."); + } catch { + setFormError("Network error. Please try again."); + } finally { + setSubmitting(false); + } + }; + + // ── Forgot password — step 2: confirm OTP + new password ────────────────── + const handleResetConfirm = async (e: React.FormEvent) => { + e.preventDefault(); + if (!resetOtp || !resetNewPassword) return; + setSubmitting(true); + setFormError(null); + try { + const res = await fetch("/api/auth/password-reset-confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: resetEmail, otp: resetOtp, new_password: resetNewPassword }), + }); + const data = await res.json().catch(() => null) as { error?: string } | null; + if (!res.ok) { + setFormError(data?.error ?? "Invalid or expired code."); + } else { + setFormMessage("Password updated. You can now sign in."); + goBack(); + } + } catch { + setFormError("Network error. Please try again."); + } finally { + setSubmitting(false); + } + }; + if (authLoading) { return (
@@ -128,6 +188,96 @@ export function AuthPageContent() { ); } + // ── Forgot password views ────────────────────────────────────────────────── + if (view === "forgot-password" || view === "reset-confirm") { + return ( +
+
+

+ {view === "forgot-password" ? "Reset your password" : "Enter reset code"} +

+

+ {view === "forgot-password" + ? "We'll send a one-time code to your email." + : `Check your email for the code sent to ${resetEmail}`} +

+
+ +
+ {view === "forgot-password" ? ( +
+
+ + setResetEmail(e.target.value)} + disabled={submitting} + required + className="mt-1.5 w-full rounded-lg border border-gray-100 bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 disabled:opacity-50" + /> +
+ {formError &&

{formError}

} + +
+ ) : ( +
+
+ + setResetOtp(e.target.value)} + disabled={submitting} + required + placeholder="6-digit code" + className="mt-1.5 w-full rounded-lg border border-gray-100 bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 disabled:opacity-50" + /> +
+
+ + setResetNewPassword(e.target.value)} + disabled={submitting} + required + minLength={8} + className="mt-1.5 w-full rounded-lg border border-gray-100 bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 disabled:opacity-50" + /> +
+ {formError &&

{formError}

} + {formMessage &&

{formMessage}

} + +
+ )} +
+ + +
+ ); + } + + // ── Main sign-in / sign-up view ──────────────────────────────────────────── return (
@@ -162,10 +312,7 @@ export function AuthPageContent() {
-
); diff --git a/src/components/dashboard/settings/SettingsBilling.tsx b/src/components/dashboard/settings/SettingsBilling.tsx index 36551ef..4d13271 100644 --- a/src/components/dashboard/settings/SettingsBilling.tsx +++ b/src/components/dashboard/settings/SettingsBilling.tsx @@ -1,5 +1,9 @@ -import { CreditCard, ExternalLink, Zap } from "lucide-react"; +"use client"; +import { useState } from "react"; +import { CreditCard, Loader2, Zap } from "lucide-react"; + +import { apiFetch } from "@/lib/api/fetch"; import type { PlanId } from "@/lib/plans"; interface SettingsBillingProps { @@ -26,6 +30,28 @@ const PLAN_FEATURES: Record = { export function SettingsBilling({ plan }: SettingsBillingProps) { const isPaid = plan !== "free"; + const [cancelling, setCancelling] = useState(false); + const [cancelled, setCancelled] = useState(false); + const [cancelError, setCancelError] = useState(null); + + const handleCancel = async () => { + if (!confirm("Cancel your plan? You'll keep access until the current period ends.")) return; + setCancelling(true); + setCancelError(null); + try { + const res = await apiFetch("/api/billing/cancel", { method: "POST" }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + setCancelError(data?.error ?? "Failed to cancel plan. Please try again."); + } else { + setCancelled(true); + } + } catch { + setCancelError("Network error. Please try again."); + } finally { + setCancelling(false); + } + }; return (
@@ -44,21 +70,12 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {

{PLAN_LABELS[plan]}

- {isPaid ? "Active" : "Free tier"} + {cancelled ? "Cancels at period end" : isPaid ? "Active" : "Free tier"}
- {isPaid ? ( - - - Manage billing - - - ) : ( + {!isPaid && (
+ {/* Paid plan actions */} + {isPaid && !cancelled && ( +
+ + + Change plan + + +
+ )} + + {cancelError && ( +

+ {cancelError} +

+ )} + + {cancelled && ( +

+ Your plan has been cancelled. You'll keep access until the end of your billing period. +

+ )} + {!isPaid && (

Upgrade to unlock unlimited projects, 4K export, and premium templates.