rebase : betterauth + devenv + r2#183
Conversation
| async (req: Request, res: Response): Promise<void> => { | ||
| const userId = await getAuthenticatedUserId(req); | ||
| if (!userId) { | ||
| res.status(401).json({ error: "Unauthorized" }); | ||
| return; | ||
| } | ||
|
|
||
| if (!filename) { | ||
| res.status(400).json({ error: "Filename is required" }); | ||
| const { assetId } = req.params; | ||
| const body = req.body as Buffer; | ||
| if (!assetId) { | ||
| res.status(400).json({ error: "assetId is required" }); | ||
| return; | ||
| } | ||
| if (!Buffer.isBuffer(body) || body.length === 0) { | ||
| res.status(400).json({ error: "File body is required" }); | ||
| return; | ||
| } | ||
|
|
||
| const decodedFilename = decodeURIComponent(filename); | ||
| const sourcePath = path.resolve("out", decodedFilename); | ||
| try { | ||
| const { rows } = await db.query<{ r2_key: string; mime_type: string; content_hash: string | null }>( | ||
| `SELECT r2_key, mime_type, content_hash | ||
| FROM assets | ||
| WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, | ||
| [assetId, userId], | ||
| ); | ||
|
|
||
| if (rows.length === 0) { | ||
| res.status(404).json({ error: "Asset not found" }); | ||
| return; | ||
| } | ||
|
|
||
| // Security check - ensure source file is in the out directory | ||
| if (!sourcePath.startsWith(path.resolve("out"))) { | ||
| res.status(403).json({ error: "Access denied" }); | ||
| return; | ||
| const r2Key = rows[0].r2_key; | ||
| const fallbackMimeType = rows[0].mime_type || "application/octet-stream"; | ||
| const reqMimeType = req.headers["content-type"]; | ||
| const contentType = typeof reqMimeType === "string" ? reqMimeType : fallbackMimeType; | ||
|
|
||
| await r2.send( | ||
| new PutObjectCommand({ | ||
| Bucket: ASSETS_BUCKET, | ||
| Key: r2Key, | ||
| Body: body, | ||
| ContentType: contentType, | ||
| }), | ||
| ); | ||
|
|
||
| const actualFileSize = body.length; | ||
| await db.query( | ||
| `UPDATE assets | ||
| SET file_size = $3 | ||
| WHERE id = $1 AND user_id = $2`, | ||
| [assetId, userId, actualFileSize], | ||
| ); | ||
| if (rows[0].content_hash) { | ||
| await db.query( | ||
| `UPDATE r2_objects | ||
| SET file_size = $2 | ||
| WHERE content_hash = $1`, | ||
| [rows[0].content_hash, actualFileSize], | ||
| ); | ||
| } | ||
|
|
||
| res.json({ success: true }); | ||
| } catch (err) { | ||
| console.error("upload-bytes error:", err); | ||
| res.status(500).json({ error: "Failed to upload file" }); | ||
| } | ||
| }, |
| app.post("/assets/complete-upload", async (req: Request, res: Response): Promise<void> => { | ||
| const userId = await getAuthenticatedUserId(req); | ||
| if (!userId) { | ||
| res.status(401).json({ error: "Unauthorized" }); | ||
| return; | ||
| } | ||
|
|
||
| const { assetId, width, height, durationInSeconds } = req.body as { | ||
| assetId: string; | ||
| width?: number; | ||
| height?: number; | ||
| durationInSeconds?: number; | ||
| }; | ||
|
|
||
| if (!assetId) { | ||
| res.status(400).json({ error: "assetId is required" }); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const { rows: lookup } = await db.query( | ||
| "SELECT r2_key, content_hash FROM assets WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL", | ||
| [assetId, userId], | ||
| ); | ||
|
|
||
| if (lookup.length === 0) { | ||
| res.status(404).json({ error: "Asset not found" }); | ||
| return; | ||
| } | ||
|
|
||
| // Generate new filename with timestamp and suffix | ||
| const timestamp = Date.now(); | ||
| const sourceExtension = path.extname(decodedFilename); | ||
| const sourceNameWithoutExt = path.basename(decodedFilename, sourceExtension); | ||
| const newFilename = `${sourceNameWithoutExt}_${suffix}_${timestamp}${sourceExtension}`; | ||
| const destPath = path.resolve("out", newFilename); | ||
| const contentHash: string = lookup[0].content_hash; | ||
|
|
||
| // Copy the file | ||
| fs.copyFileSync(sourcePath, destPath); | ||
| // Mark the shared R2 object as confirmed (idempotent — no-op if already ready) | ||
| await db.query("UPDATE r2_objects SET status='ready' WHERE content_hash=$1 AND status='pending'", [contentHash]); | ||
|
|
||
| const fileStats = fs.statSync(destPath); | ||
| const fileUrl = `/media/${encodeURIComponent(newFilename)}`; | ||
| const fullUrl = `http://localhost:${port}${fileUrl}`; | ||
| const { rows } = await db.query( | ||
| `UPDATE assets | ||
| SET status='ready', width=$3, height=$4, duration_seconds=$5 | ||
| WHERE id=$1 AND user_id=$2 | ||
| RETURNING *`, | ||
| [assetId, userId, width ?? null, height ?? null, durationInSeconds ?? null], | ||
| ); | ||
|
|
||
| console.log(`📋 File cloned: ${decodedFilename} -> ${newFilename}`); | ||
| console.log(`✅ Upload complete: ${assetId}`); | ||
| res.json({ asset: { ...rows[0], assetUrl: getAssetUrl(assetId) } }); | ||
| } catch (err) { | ||
| console.error("complete-upload error:", err); | ||
| res.status(500).json({ error: "Failed to complete upload" }); | ||
| } | ||
| }); |
| app.delete("/projects/:projectId", async (req: Request, res: Response): Promise<void> => { | ||
| const userId = await getAuthenticatedUserId(req); | ||
| if (!userId) { | ||
| res.status(401).json({ error: "Unauthorized" }); | ||
| return; | ||
| } | ||
|
|
||
| const { projectId } = req.params; | ||
| const client = await db.connect(); | ||
| const r2KeysToDelete = new Set<string>(); | ||
|
|
||
| try { | ||
| await client.query("BEGIN"); | ||
|
|
||
| const { rows: projectRows } = await client.query("SELECT id FROM projects WHERE id=$1 AND user_id=$2 FOR UPDATE", [ | ||
| projectId, | ||
| userId, | ||
| ]); | ||
| if (projectRows.length === 0) { | ||
| await client.query("ROLLBACK"); | ||
| res.status(404).json({ error: "Project not found" }); | ||
| return; | ||
| } | ||
|
|
||
| if (!fs.existsSync(filePath)) { | ||
| res.status(404).json({ error: "File not found" }); | ||
| const { rows: assetRows } = await client.query<AssetDeleteRow>( | ||
| `SELECT r2_key, content_hash, status | ||
| FROM assets | ||
| WHERE project_id=$1 AND user_id=$2 AND deleted_at IS NULL | ||
| FOR UPDATE`, | ||
| [projectId, userId], | ||
| ); | ||
|
|
||
| await client.query( | ||
| `UPDATE assets | ||
| SET deleted_at = now() | ||
| WHERE project_id=$1 AND user_id=$2 AND deleted_at IS NULL`, | ||
| [projectId, userId], | ||
| ); | ||
|
|
||
| const handledHashes = new Set<string>(); | ||
| const handledDirectKeys = new Set<string>(); | ||
|
|
||
| for (const assetRow of assetRows) { | ||
| if (!assetRow.r2_key || assetRow.status !== "ready") continue; | ||
|
|
||
| if (assetRow.content_hash) { | ||
| if (handledHashes.has(assetRow.content_hash)) continue; | ||
| handledHashes.add(assetRow.content_hash); | ||
| } else { | ||
| if (handledDirectKeys.has(assetRow.r2_key)) continue; | ||
| handledDirectKeys.add(assetRow.r2_key); | ||
| } | ||
|
|
||
| const deleteObject = await shouldDeleteR2Object(client, assetRow); | ||
| if (deleteObject) { | ||
| r2KeysToDelete.add(assetRow.r2_key); | ||
| } | ||
| } | ||
|
|
||
| await client.query("DELETE FROM projects WHERE id=$1 AND user_id=$2", [projectId, userId]); | ||
| await client.query("COMMIT"); | ||
| } catch (err) { | ||
| await client.query("ROLLBACK").catch(() => {}); | ||
| console.error("delete project error:", err); | ||
| res.status(500).json({ error: "Failed to delete project" }); | ||
| return; | ||
| } finally { | ||
| client.release(); | ||
| } | ||
|
|
||
| for (const r2Key of r2KeysToDelete) { | ||
| try { | ||
| await r2.send(new DeleteObjectCommand({ Bucket: ASSETS_BUCKET, Key: r2Key })); | ||
| console.log(`🗑️ R2 object removed after project delete: ${r2Key}`); | ||
| } catch (r2Err) { | ||
| console.warn(`⚠️ Failed to delete R2 object ${r2Key} after project delete:`, r2Err); | ||
| } | ||
| } | ||
|
|
||
| res.status(204).send(); | ||
| }); |
There was a problem hiding this comment.
Code Review
This pull request significantly upgrades the application's infrastructure by migrating to better-auth, implementing Cloudflare R2 storage with content-addressed deduplication, and enhancing the AI chat with multi-tab history and timeline snapshots. It also introduces stack-wide Zod schema validation and optimized Docker configurations. Feedback highlights critical improvements for production stability, including streaming large file uploads to prevent OOM errors, configuring SSL for the renderer's database pool, and adopting distributed rate limiting. Additionally, suggestions were made to use structuredClone for state snapshots and to ensure transition effects dynamically scale with composition resolution.
|
|
||
| app.put( | ||
| "/assets/upload/:assetId", | ||
| express.raw({ type: "*/*", limit: "500mb" }), |
There was a problem hiding this comment.
Buffering large file uploads (up to 500MB) in memory using express.raw poses a significant risk of Out-Of-Memory (OOM) crashes, especially under concurrent load on a server with limited RAM (8GB). It is highly recommended to remove this middleware and stream the request body directly to Cloudflare R2 using the @aws-sdk/lib-storage Upload utility, similar to how renders are handled later in this file.
| ]; | ||
| } | ||
|
|
||
| const captureSnapshot = (): TimelineState => JSON.parse(JSON.stringify(latestTimelineRef.current)); |
There was a problem hiding this comment.
Use structuredClone for deep cloning the timeline state. It is a native, more efficient way to perform deep clones compared to the JSON.parse(JSON.stringify()) pattern, and it correctly handles more complex data types if they are added to the state in the future.
| const captureSnapshot = (): TimelineState => JSON.parse(JSON.stringify(latestTimelineRef.current)); | |
| const captureSnapshot = (): TimelineState => structuredClone(latestTimelineRef.current); |
| return cast(flip()); | ||
| case "iris": | ||
| return iris({ width: 1000, height: 1000 }); | ||
| return cast(iris({ width: 1920, height: 1080 })); |
There was a problem hiding this comment.
The iris transition dimensions are hardcoded to 1920x1080. This may result in incorrect visual effects if the composition resolution is different (e.g., vertical video). Use the compositionWidth and compositionHeight props to ensure the effect scales correctly with the project resolution.
| return cast(iris({ width: 1920, height: 1080 })); | |
| return cast(iris({ width: compositionWidth ?? 1920, height: compositionHeight ?? 1080 })); |
|
|
||
| // ─── Database pool ──────────────────────────────────────────────────────────── | ||
|
|
||
| const db = new Pool({ connectionString: process.env.DATABASE_URL!.trim() }); |
There was a problem hiding this comment.
The database pool configuration in the renderer does not account for SSL requirements, which are handled in the main backend and auth server. This will cause connection failures when connecting to remote databases (like Supabase) that require SSL.
const db = new Pool({
connectionString: process.env.DATABASE_URL!.trim(),
ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: false } : false,
});| # iterate at ~1 req/sec during a session. Revisit with Redis once we move to multi-worker. | ||
| _RATE_LIMIT_WINDOW_SECONDS = 60 | ||
| _RATE_LIMIT_MAX_REQUESTS = 60 | ||
| _recent_requests: dict[str, deque[float]] = defaultdict(deque) |
There was a problem hiding this comment.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Security | 2 high 20 critical 6 medium |
🟢 Metrics 1099 complexity · 36 duplication
Metric Results Complexity 1099 Duplication 36
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
| app.post("/assets/initiate-upload", async (req: Request, res: Response): Promise<void> => { | ||
| const userId = await getAuthenticatedUserId(req); | ||
| if (!userId) { | ||
| res.status(401).json({ error: "Unauthorized" }); | ||
| return; | ||
| } | ||
|
|
||
| const { assetId, filename, fileSize, mimeType, mediaType, contentHash, projectId } = req.body as { | ||
| assetId: string; | ||
| filename: string; | ||
| fileSize: number; | ||
| mimeType: string; | ||
| mediaType: string; | ||
| contentHash: string; // SHA-256 hex from browser | ||
| projectId?: string | null; | ||
| }; | ||
|
|
||
| if (!assetId || !filename || !mimeType || !contentHash) { | ||
| res.status(400).json({ error: "assetId, filename, mimeType, and contentHash are required" }); | ||
| return; | ||
| } | ||
|
|
||
| const ext = path.extname(filename).toLowerCase(); | ||
| // Content-addressed key — shared across all users with the same file | ||
| const r2Key = `objects/${contentHash}${ext}`; | ||
| try { | ||
| const { filename, originalName, suffix } = req.body; | ||
| // ── Dedup check ────────────────────────────────────────────────────────── | ||
| // Upsert into r2_objects. If a row already exists with this hash, the | ||
| // no-op UPDATE still triggers RETURNING so we get back the current status. | ||
| const { rows: objRows } = await db.query<{ status: string }>( | ||
| `INSERT INTO r2_objects (content_hash, r2_key, file_size, mime_type, status) | ||
| VALUES ($1, $2, $3, $4, 'pending') | ||
| ON CONFLICT (content_hash) | ||
| DO UPDATE SET content_hash = EXCLUDED.content_hash | ||
| RETURNING status`, | ||
| [contentHash, r2Key, fileSize || 0, mimeType], | ||
| ); | ||
|
|
||
| const objectStatus = objRows[0]?.status ?? "pending"; | ||
|
|
||
| if (!filename) { | ||
| res.status(400).json({ error: "Filename is required" }); | ||
| if (objectStatus === "ready") { | ||
| // ── Fast-path: file already in R2 — create per-user asset record only ── | ||
| await db.query( | ||
| `INSERT INTO assets | ||
| (id, user_id, project_id, content_hash, r2_key, filename, file_size, | ||
| mime_type, media_type, status) | ||
| VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'ready') | ||
| ON CONFLICT (id) DO NOTHING`, | ||
| [ | ||
| assetId, | ||
| userId, | ||
| projectId ?? null, | ||
| contentHash, | ||
| r2Key, | ||
| filename, | ||
| fileSize || null, | ||
| mimeType, | ||
| mediaType || null, | ||
| ], | ||
| ); | ||
|
|
||
| console.log(`⚡ Dedup hit: ${filename} (${contentHash.slice(0, 8)}…)`); | ||
| res.json({ alreadyExists: true, assetId, assetUrl: getAssetUrl(assetId) }); | ||
| return; | ||
| } | ||
|
|
||
| const decodedFilename = decodeURIComponent(filename); | ||
| const sourcePath = path.resolve("out", decodedFilename); | ||
| // ── Normal path: create asset record in 'uploading' state ─────────────── | ||
| await db.query( | ||
| `INSERT INTO assets | ||
| (id, user_id, project_id, content_hash, r2_key, filename, file_size, | ||
| mime_type, media_type, status) | ||
| VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'uploading') | ||
| ON CONFLICT (id) DO UPDATE | ||
| SET content_hash=$4, r2_key=$5, filename=$6, file_size=$7, | ||
| mime_type=$8, media_type=$9, status='uploading'`, | ||
| [assetId, userId, projectId ?? null, contentHash, r2Key, filename, fileSize || null, mimeType, mediaType || null], | ||
| ); | ||
|
|
||
| console.log(`📤 Upload initiated: ${filename} → ${r2Key}`); | ||
| res.json({ assetId, r2Key, assetUrl: getAssetUrl(assetId) }); | ||
| } catch (err) { | ||
| console.error("initiate-upload error:", err); | ||
| res.status(500).json({ error: "Failed to initiate upload" }); | ||
| } | ||
| }); |
| app.post("/assets/:assetId/clone", async (req: Request, res: Response): Promise<void> => { | ||
| const userId = await getAuthenticatedUserId(req); | ||
| if (!userId) { | ||
| res.status(401).json({ error: "Unauthorized" }); | ||
| return; | ||
| } | ||
|
|
||
| const { assetId } = req.params; | ||
| const { suffix } = req.body as { suffix?: string }; | ||
|
|
||
| app.post("/render", async (req, res) => { | ||
| try { | ||
| // Get input props from POST body | ||
| const inputProps = { | ||
| timelineData: req.body.timelineData, | ||
| durationInFrames: req.body.durationInFrames, | ||
| compositionWidth: req.body.compositionWidth, | ||
| compositionHeight: req.body.compositionHeight, | ||
| getPixelsPerSecond: req.body.getPixelsPerSecond, | ||
| isRendering: true, | ||
| }; | ||
|
|
||
| // console.log("Input props:", typeof inputProps.compositionWidth); | ||
| console.log("Input props:", JSON.stringify(inputProps, null, 2)); | ||
| // Get the composition you want to render | ||
| const composition = await selectComposition({ | ||
| serveUrl: bundleLocation, | ||
| id: compositionId, | ||
| inputProps, | ||
| }); | ||
| const { rows } = await db.query("SELECT * FROM assets WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL", [ | ||
| assetId, | ||
| userId, | ||
| ]); | ||
|
|
||
| // const maxFrames = Math.min(composition.durationInFrames, 150); // Max 5 seconds at 30fps | ||
| // console.log(`Starting ULTRA low-resource render. Limiting to ${maxFrames} frames (${maxFrames / 30}s)`); | ||
| if (rows.length === 0) { | ||
| res.status(404).json({ error: "Source asset not found" }); | ||
| return; | ||
| } | ||
|
|
||
| // Render optimized for 4vCPU, 8GB RAM server | ||
| await renderMedia({ | ||
| composition, | ||
| serveUrl: bundleLocation, | ||
| codec: "h264", | ||
| outputLocation: `out/${compositionId}.mp4`, | ||
| inputProps, | ||
| // Optimized settings for server hardware | ||
| concurrency: 3, // Use 3 cores, leave 1 for system | ||
| verbose: true, | ||
| logLevel: "info", // More detailed logging for server monitoring | ||
| // Balanced encoding settings for server performance | ||
| ffmpegOverride: ({ args }) => { | ||
| return [ | ||
| ...args, | ||
| "-preset", | ||
| "fast", // Good balance of speed and quality | ||
| "-crf", | ||
| "28", // Better quality than ultrafast setting | ||
| "-threads", | ||
| "3", // Use 3 threads for encoding | ||
| "-tune", | ||
| "film", // Better quality for general content | ||
| "-x264-params", | ||
| "ref=3:me=hex:subme=6:trellis=1", // Better quality settings | ||
| "-g", | ||
| "30", // Standard keyframe interval | ||
| "-bf", | ||
| "2", // Allow some B-frames for better compression | ||
| "-maxrate", | ||
| "5M", // Limit bitrate to prevent memory issues | ||
| "-bufsize", | ||
| "10M", // Buffer size for rate control | ||
| ]; | ||
| }, | ||
| timeoutInMilliseconds: 900000, // 15 minute timeout for longer videos | ||
| }); | ||
| const source = rows[0]; | ||
| const sourceKey: string = source.r2_key; | ||
| const ext = path.extname(sourceKey); | ||
| const newAssetId = generateUUID(); | ||
| const newR2Key = `${userId}/${newAssetId}${ext}`; | ||
|
|
||
| console.log("✅ Render completed successfully"); | ||
| res.sendFile(path.resolve(`out/${compositionId}.mp4`)); | ||
| await r2.send( | ||
| new CopyObjectCommand({ | ||
| CopySource: `${ASSETS_BUCKET}/${sourceKey}`, | ||
| Bucket: ASSETS_BUCKET, | ||
| Key: newR2Key, | ||
| }), | ||
| ); | ||
|
|
||
| const newFilename = suffix ? `${path.basename(source.filename, ext)} ${suffix}${ext}` : source.filename; | ||
|
|
||
| const { rows: newRows } = await db.query( | ||
| `INSERT INTO assets | ||
| (id, user_id, project_id, r2_key, filename, file_size, mime_type, | ||
| media_type, duration_seconds, width, height, status) | ||
| VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'ready') | ||
| RETURNING *`, | ||
| [ | ||
| newAssetId, | ||
| userId, | ||
| source.project_id, | ||
| newR2Key, | ||
| newFilename, | ||
| source.file_size, | ||
| source.mime_type, | ||
| source.media_type, | ||
| source.duration_seconds, | ||
| source.width, | ||
| source.height, | ||
| ], | ||
| ); | ||
| // Note: cloned assets intentionally have no content_hash since they represent | ||
| // a new physical object in R2 (e.g. audio extracted from video). | ||
|
|
||
| console.log(`📋 Asset cloned: ${assetId} → ${newAssetId}`); | ||
| res.json({ asset: { ...newRows[0], assetUrl: getAssetUrl(newAssetId) } }); | ||
| } catch (err) { | ||
| console.error("clone asset error:", err); | ||
| res.status(500).json({ error: "Failed to clone asset" }); | ||
| } | ||
| }); |
| app.get("/renderer/assets/:assetId/file", async (req: Request, res: Response): Promise<void> => { | ||
| const { assetId } = req.params; | ||
| if (!UUID_PATTERN.test(assetId)) { | ||
| res.status(400).end(); | ||
| return; | ||
| } | ||
| try { | ||
| if (!req.file) { | ||
| res.status(400).json({ error: "No file uploaded" }); | ||
| const { rows } = await db.query<{ r2_key: string; mime_type: string }>( | ||
| `SELECT r2_key, mime_type FROM assets WHERE id = $1 AND deleted_at IS NULL AND status = 'ready' LIMIT 1`, | ||
| [assetId], | ||
| ); | ||
| if (rows.length === 0) { | ||
| res.status(404).end(); | ||
| return; | ||
| } | ||
| const object = await r2.send(new GetObjectCommand({ Bucket: ASSETS_BUCKET, Key: rows[0].r2_key })); | ||
| const body = object.Body as NodeJS.ReadableStream | undefined; | ||
| if (!body || typeof (body as { pipe?: unknown }).pipe !== "function") { | ||
| res.status(500).end(); | ||
| return; | ||
| } | ||
| res.setHeader("Content-Type", object.ContentType || rows[0].mime_type || "application/octet-stream"); | ||
| res.setHeader("Cache-Control", "private, max-age=300"); | ||
| if (typeof object.ContentLength === "number") res.setHeader("Content-Length", String(object.ContentLength)); | ||
| body.on("error", () => { if (!res.headersSent) res.status(500).end(); else res.end(); }); | ||
| body.pipe(res); | ||
| } catch (err) { | ||
| console.error("renderer asset proxy error:", err); | ||
| res.status(500).end(); | ||
| } | ||
| }); |
… and keyframe management
| export function sanitizeExportFileName(name: string, ext: string): string { | ||
| const dotExt = ext.startsWith(".") ? ext : `.${ext}`; | ||
| let base = name.trim().replace(/\.(mp4|webm|mov|mkv)$/i, ""); | ||
| base = base.replace(/[^\w.\- ]+/g, "_").replace(/\s+/g, "-").replace(/^-+|-+$/g, ""); |
| app.get("/projects/:projectId/renders", async (req: Request, res: Response): Promise<void> => { | ||
| const userId = await getAuthenticatedUserId(req); | ||
| if (!userId) { | ||
| res.status(401).json({ error: "Unauthorized" }); | ||
| return; | ||
| } | ||
|
|
||
| console.log("✅ Render completed successfully"); | ||
| res.sendFile(path.resolve(`out/${compositionId}.mp4`)); | ||
| const { projectId } = req.params; | ||
| if (!UUID_PATTERN.test(projectId)) { | ||
| res.status(400).json({ error: "Invalid project id" }); | ||
| return; | ||
| } | ||
| if (!(await assertProjectOwned(userId, projectId))) { | ||
| res.status(404).json({ error: "Project not found" }); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const { rows } = await db.query( | ||
| `SELECT id, file_name, codec, width, height, r2_video_key, r2_thumb_key, created_at | ||
| FROM project_renders | ||
| WHERE project_id = $1::uuid AND user_id = $2 | ||
| ORDER BY created_at DESC | ||
| LIMIT 50`, | ||
| [projectId, userId], | ||
| ); | ||
|
|
||
| const renders = await Promise.all( | ||
| rows.map(async (row) => { | ||
| const urls = await signRenderAssetUrls({ | ||
| file_name: row.file_name as string, | ||
| r2_video_key: row.r2_video_key as string, | ||
| r2_thumb_key: (row.r2_thumb_key as string | null) ?? null, | ||
| codec: row.codec as string, | ||
| }); | ||
| return { | ||
| id: String(row.id), | ||
| fileName: row.file_name as string, | ||
| codec: row.codec as string, | ||
| width: row.width as number, | ||
| height: row.height as number, | ||
| createdAt: (row.created_at as Date).toISOString(), | ||
| thumbnailUrl: urls.thumbnailUrl, | ||
| previewUrl: urls.previewUrl, | ||
| downloadUrl: urls.downloadUrl, | ||
| }; | ||
| }), | ||
| ); | ||
|
|
||
| res.json({ renders }); | ||
| } catch (err) { | ||
| console.error("❌ Render failed:", err); | ||
| console.error("export history error:", err); | ||
| res.status(500).json({ error: "Failed to load export history" }); | ||
| } | ||
| }); |
| async (req: Request, res: Response): Promise<void> => { | ||
| const userId = await getAuthenticatedUserId(req); | ||
| if (!userId) { | ||
| res.status(401).json({ error: "Unauthorized" }); | ||
| return; | ||
| } | ||
|
|
||
| const { projectId, renderId } = req.params; | ||
| if (!UUID_PATTERN.test(projectId) || !UUID_PATTERN.test(renderId)) { | ||
| res.status(400).json({ error: "Invalid id" }); | ||
| return; | ||
| } | ||
| if (!(await assertProjectOwned(userId, projectId))) { | ||
| res.status(404).json({ error: "Project not found" }); | ||
| return; | ||
| } | ||
|
|
||
| // Clean up failed renders | ||
| try { | ||
| const outputPath = `out/${compositionId}.mp4`; | ||
| if (fs.existsSync(outputPath)) { | ||
| fs.unlinkSync(outputPath); | ||
| console.log("🧹 Cleaned up partial file"); | ||
| const { rows } = await db.query( | ||
| `SELECT r2_video_key, r2_thumb_key | ||
| FROM project_renders | ||
| WHERE id = $1::uuid AND project_id = $2::uuid AND user_id = $3`, | ||
| [renderId, projectId, userId], | ||
| ); | ||
| if (rows.length === 0) { | ||
| res.status(404).json({ error: "Export not found" }); | ||
| return; | ||
| } | ||
|
|
||
| const videoKey = rows[0].r2_video_key as string; | ||
| const thumbKey = (rows[0].r2_thumb_key as string | null) ?? null; | ||
|
|
||
| await db.query( | ||
| `DELETE FROM project_renders | ||
| WHERE id = $1::uuid AND project_id = $2::uuid AND user_id = $3`, | ||
| [renderId, projectId, userId], | ||
| ); | ||
|
|
||
| try { | ||
| await deleteRenderR2Objects(userId, videoKey, thumbKey); | ||
| console.log(`🗑️ Export deleted: ${renderId} (${videoKey})`); | ||
| } catch (r2Err) { | ||
| console.warn(`⚠️ Export removed from DB but R2 delete failed for ${renderId}:`, r2Err); | ||
| } | ||
| } catch (cleanupErr) { | ||
| console.warn("⚠️ Could not clean up:", cleanupErr); | ||
|
|
||
| res.status(204).send(); | ||
| } catch (err) { | ||
| console.error("delete export error:", err); | ||
| res.status(500).json({ error: "Failed to delete export" }); | ||
| } | ||
| }, |
|
https://diffshub.com/trykimu/videoeditor/pull/183 |
Summary