diff --git a/apps/web/app/_components/recording/index.tsx b/apps/web/app/_components/recording/index.tsx index a7ac2bad..60ecfbe3 100644 --- a/apps/web/app/_components/recording/index.tsx +++ b/apps/web/app/_components/recording/index.tsx @@ -11,7 +11,6 @@ export { hydrateRecordingConfig, isRecordingGradientKey, isRecordingHexColor, - recordingConfigKeyFor, recordingGradientCss, } from "@/lib/recording-config"; export type { diff --git a/apps/web/app/actions.ts b/apps/web/app/actions.ts index b1e901f0..49213c7b 100644 --- a/apps/web/app/actions.ts +++ b/apps/web/app/actions.ts @@ -5,10 +5,9 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { getAuth } from "@/lib/auth"; import { - deleteReactionsForRecording, deleteRecordingForAdmin, + updateRecordingConfigForUser, getRecordingForAdmin, - getRecordingForUser, updateRecordingTitleForUser, updateRecordingVisibilityForAdmin, type RecordingVisibility, @@ -28,12 +27,16 @@ import { objectExists, putObject, } from "@/lib/r2"; -import { sourceKeyFor, stateKeyFor } from "@/lib/screenshot-keys"; +import { + screenshotObjectKeysFor, + sourceKeyFor, + stateKeyFor, +} from "@/lib/screenshot-keys"; import { hydrateRecordingConfig, - recordingConfigKeyFor, type RecordingConfig, } from "@/lib/recording-config"; +import { objectKeysFor } from "@/lib/recording/object-keys"; import { revokeDeviceToken } from "@/lib/device-tokens"; // Middleware only guards page requests; a forged/replayed direct action @@ -112,9 +115,6 @@ export async function deleteRecordingAction(slug: string): Promise<{ // at worst a row pointing at a missing object (delete again to clean up). try { await deleteObject(row.storageKey); - if (row.posterKey) { - await deleteObject(row.posterKey); - } } catch (err) { return { error: `Could not delete the video file: ${ @@ -122,7 +122,13 @@ export async function deleteRecordingAction(slug: string): Promise<{ }`, }; } - await deleteReactionsForRecording(cleanSlug); + // Poster/webcam/sidecars are best-effort: the video is already gone, so + // failing here would only block a delete the user can no longer retry. + await Promise.allSettled( + objectKeysFor(row) + .filter((key) => key !== row.storageKey) + .map((key) => deleteObject(key)), + ); await deleteRecordingForAdmin(userId, cleanSlug); revalidatePath("/"); return { error: null }; @@ -137,27 +143,13 @@ export async function saveRecordingConfigAction( const userId = await requireUserId(); const cleanSlug = typeof slug === "string" ? slug.trim() : ""; if (!cleanSlug) return { error: "Missing slug" }; - const recording = await getRecordingForUser(userId, cleanSlug); - if (!recording) return { error: "Recording not found" }; const config: RecordingConfig = hydrateRecordingConfig(raw); - const json = JSON.stringify(config); - const bytes = new TextEncoder().encode(json); - try { - await putObject( - recordingConfigKeyFor(recording.storageKey), - bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer, - "application/json", - ); - } catch (err) { - return { - error: `Could not save recording config: ${ - err instanceof Error ? err.message : String(err) - }`, - }; - } + const ok = await updateRecordingConfigForUser( + userId, + cleanSlug, + JSON.stringify(config), + ); + if (!ok) return { error: "Recording not found" }; revalidatePath(`/recordings/${cleanSlug}/edit`); revalidatePath("/"); return { error: null }; @@ -187,11 +179,11 @@ export async function deleteScreenshotAction(screenshotId: string): Promise<{ if (!ok) return { error: "Screenshot not found" }; // Best-effort R2 cleanup; row is already soft-deleted and the retention // cron sweeps any stranded bytes. - await Promise.allSettled([ - deleteObject(screenshot.storageKey), - deleteObject(sourceKeyFor(screenshot.storageKey)), - deleteObject(stateKeyFor(screenshot.storageKey)), - ]); + await Promise.allSettled( + screenshotObjectKeysFor(screenshot.storageKey).map((key) => + deleteObject(key), + ), + ); revalidatePath("/screenshots"); return { error: null }; } diff --git a/apps/web/app/api/r/[id]/route.ts b/apps/web/app/api/r/[id]/route.ts index b381fc97..cdf8ba57 100644 --- a/apps/web/app/api/r/[id]/route.ts +++ b/apps/web/app/api/r/[id]/route.ts @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { deleteRecording, getRecording } from "@/lib/recording/db"; import { isValidSlug } from "@/lib/recording/slug"; import { abortMultipartUpload, deleteObject } from "@/lib/recording/r2"; +import { objectKeysFor } from "@/lib/recording/object-keys"; import { verifySessionOrNull } from "@/lib/recording/verify-session"; import { optionsResponse, withCors, jsonError } from "@/lib/recording/cors"; @@ -46,12 +47,16 @@ export async function DELETE( } } await deleteObject(row.storageKey); - if (row.posterKey) await deleteObject(row.posterKey); - if (row.webcamStorageKey) { - try { - await deleteObject(row.webcamStorageKey); - } catch (err) { - console.warn(`[delete] webcam r2 delete failed for ${id}:`, err); + // Poster/webcam/sidecars are best-effort: the video is already gone, so a + // failure here must not leave the caller with an undeletable recording. + const stranded = await Promise.allSettled( + objectKeysFor(row) + .filter((key) => key !== row.storageKey) + .map((key) => deleteObject(key)), + ); + for (const result of stranded) { + if (result.status === "rejected") { + console.warn(`[delete] r2 delete failed for ${id}:`, result.reason); } } await deleteRecording(id); diff --git a/apps/web/app/api/r/finalize/route.ts b/apps/web/app/api/r/finalize/route.ts index f5d7c40f..69b93e6c 100644 --- a/apps/web/app/api/r/finalize/route.ts +++ b/apps/web/app/api/r/finalize/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getRecording, updateRecording } from "@/lib/recording/db"; import { isValidSlug } from "@/lib/recording/slug"; -import { completeMultipartUpload, headObject } from "@/lib/recording/r2"; +import { + completeMultipartUpload, + deleteObject, + objectSize, +} from "@/lib/recording/r2"; import { optionsResponse, withCors, jsonError } from "@/lib/recording/cors"; import { exceedsStorage } from "@/lib/recording/quota"; import { viewUrlForRequest } from "@/lib/site"; @@ -90,12 +94,26 @@ export async function POST(req: NextRequest) { return jsonError(`R2 complete: ${reason}`, 502, "r2_complete_failed"); } - const exists = await headObject(row.storageKey); - if (!exists) { + const actualBytes = await objectSize(row.storageKey); + if (actualBytes === null) { await updateRecording(row.slug, { state: "failed", uploadId: null }); return jsonError("Object missing after complete", 502, "object_missing"); } + /* + * Re-check with what R2 actually stored: the pre-complete check ran on the + * client's claimed size, and only a larger truth can flip the verdict. The + * object is already committed, so an over-cap upload is removed, not kept. + */ + if ( + actualBytes > sizeBytes && + (await exceedsStorage(deviceId, row, actualBytes)) + ) { + await deleteObject(row.storageKey); + await updateRecording(row.slug, { state: "failed", uploadId: null }); + return jsonError("Storage cap reached", 413, "storage_limit"); + } + // Bad duration is dropped rather than rejected: the bytes are already in R2, // and a missing badge beats an upload that reports failure. const durationMs = @@ -109,7 +127,7 @@ export async function POST(req: NextRequest) { await updateRecording(row.slug, { state: "ready", uploadId: null, - sizeBytes, + sizeBytes: actualBytes, lastViewedAt: Date.now(), ...(durationMs === null ? {} : { durationMs }), }); diff --git a/apps/web/app/api/r/init/route.ts b/apps/web/app/api/r/init/route.ts index f0227824..02ee914d 100644 --- a/apps/web/app/api/r/init/route.ts +++ b/apps/web/app/api/r/init/route.ts @@ -198,6 +198,8 @@ export async function POST(req: NextRequest) { webcamUploadId, webcamSizeBytes: 0, webcamState, + configJson: null, + summaryChaptersJson: null, }); const res: InitResponse = { diff --git a/apps/web/app/api/r/summary-chapters/[id]/route.ts b/apps/web/app/api/r/summary-chapters/[id]/route.ts index a407d4bc..42afeca5 100644 --- a/apps/web/app/api/r/summary-chapters/[id]/route.ts +++ b/apps/web/app/api/r/summary-chapters/[id]/route.ts @@ -1,12 +1,11 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; -import { getRecording } from "@/lib/recording/db"; +import { getRecording, updateRecording } from "@/lib/recording/db"; import { isValidSlug } from "@/lib/recording/slug"; import { verifySessionOrNull } from "@/lib/recording/verify-session"; import { hydrateSummaryChapters, - loadSummaryChapters, - saveSummaryChapters, + parseSummaryChaptersJson, type RecordingSummaryChapters, } from "@/lib/recording/summary-chapters"; @@ -23,8 +22,7 @@ export async function GET(_req: Request, { params }: Params) { if (!row || row.state !== "ready") { return NextResponse.json({ error: "not_found" }, { status: 404 }); } - const payload = await loadSummaryChapters(row.storageKey); - return NextResponse.json(payload); + return NextResponse.json(parseSummaryChaptersJson(row.summaryChaptersJson)); } export async function PUT(req: Request, { params }: Params) { @@ -48,6 +46,8 @@ export async function PUT(req: Request, { params }: Params) { return NextResponse.json({ error: "bad_request" }, { status: 400 }); } const normalized: RecordingSummaryChapters = hydrateSummaryChapters(body); - await saveSummaryChapters(row.storageKey, normalized); + await updateRecording(row.slug, { + summaryChaptersJson: JSON.stringify(normalized), + }); return NextResponse.json(normalized); } diff --git a/apps/web/app/api/r/webcam-finalize/route.ts b/apps/web/app/api/r/webcam-finalize/route.ts index 3328cf21..b213bc25 100644 --- a/apps/web/app/api/r/webcam-finalize/route.ts +++ b/apps/web/app/api/r/webcam-finalize/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getRecording, updateRecording } from "@/lib/recording/db"; import { isValidSlug } from "@/lib/recording/slug"; -import { completeMultipartUpload, headObject } from "@/lib/recording/r2"; +import { + completeMultipartUpload, + deleteObject, + objectSize, +} from "@/lib/recording/r2"; import { optionsResponse, withCors, jsonError } from "@/lib/recording/cors"; import { exceedsStorage } from "@/lib/recording/quota"; import type { FinalizeRequest } from "@/lib/recording/types"; @@ -76,8 +80,8 @@ export async function POST(req: NextRequest) { body.parts, ); - const exists = await headObject(row.webcamStorageKey); - if (!exists) { + const actualWebcamBytes = await objectSize(row.webcamStorageKey); + if (actualWebcamBytes === null) { await updateRecording(row.slug, { webcamState: "failed", webcamUploadId: null, @@ -85,10 +89,23 @@ export async function POST(req: NextRequest) { return jsonError("Object missing after complete", 502, "object_missing"); } + // Same post-complete re-check as /api/r/finalize, against R2's actual size. + if ( + actualWebcamBytes > sizeBytes && + (await exceedsStorage(deviceId, row, actualWebcamBytes)) + ) { + await deleteObject(row.webcamStorageKey); + await updateRecording(row.slug, { + webcamState: "failed", + webcamUploadId: null, + }); + return jsonError("Storage cap reached", 413, "storage_limit"); + } + await updateRecording(row.slug, { webcamState: "ready", webcamUploadId: null, - webcamSizeBytes: sizeBytes, + webcamSizeBytes: actualWebcamBytes, }); return withCors(NextResponse.json({ ok: true })); diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx index 18a28a05..cab1815b 100644 --- a/apps/web/app/r/[id]/page.tsx +++ b/apps/web/app/r/[id]/page.tsx @@ -11,13 +11,9 @@ import { } from "@/lib/recording/db"; import { verifySession } from "@/lib/recording/verify-session"; import { canViewResource } from "@/lib/visibility"; -import { getObjectJson, publicUrlFor } from "@/lib/recording/r2"; -import { - DEFAULT_RECORDING_CONFIG, - hydrateRecordingConfig, - recordingConfigKeyFor, -} from "@/lib/recording-config"; -import { loadSummaryChapters } from "@/lib/recording/summary-chapters"; +import { publicUrlFor } from "@/lib/recording/r2"; +import { parseRecordingConfigJson } from "@/lib/recording-config"; +import { parseSummaryChaptersJson } from "@/lib/recording/summary-chapters"; import { isValidSlug } from "@/lib/recording/slug"; import { APP_WEB_SITE_URL, PRODUCT_NAME, viewUrlFor } from "@/lib/site"; import { ViewerNav } from "../../_components/screenshot"; @@ -230,8 +226,6 @@ export default async function RecordingPage({ params }: { params: Params }) { comments, ownerName, webcamUrlRaw, - configRaw, - summaryChapters, ] = await Promise.all([ publicUrlFor(row.storageKey), row.posterKey ? publicUrlFor(row.posterKey) : Promise.resolve(undefined), @@ -241,14 +235,9 @@ export default async function RecordingPage({ params }: { params: Params }) { row.webcamStorageKey && row.webcamState === "ready" ? publicUrlFor(row.webcamStorageKey) : Promise.resolve(undefined), - getObjectJson(recordingConfigKeyFor(row.storageKey)).catch( - () => null, - ), - loadSummaryChapters(row.storageKey), ]); - const recordingConfig = configRaw - ? hydrateRecordingConfig(configRaw) - : DEFAULT_RECORDING_CONFIG; + const recordingConfig = parseRecordingConfigJson(row.configJson); + const summaryChapters = parseSummaryChaptersJson(row.summaryChaptersJson); const videoUrl = withVersion(videoUrlRaw, row.sizeBytes); const posterUrl = posterUrlRaw ? withVersion(posterUrlRaw, row.sizeBytes) diff --git a/apps/web/app/recordings/[id]/edit/page.tsx b/apps/web/app/recordings/[id]/edit/page.tsx index 2d8f7778..374894d5 100644 --- a/apps/web/app/recordings/[id]/edit/page.tsx +++ b/apps/web/app/recordings/[id]/edit/page.tsx @@ -5,11 +5,7 @@ import { readThemeFromCookieHeader } from "@captureflow/ui"; import { getAppWebEnv } from "@/lib/cf-env"; import { requireSession } from "@/lib/session-guard"; import { getRecordingForUser } from "@/lib/recordings-db"; -import { getObjectJson } from "@/lib/r2"; -import { - recordingConfigKeyFor, - hydrateRecordingConfig, -} from "@/lib/recording-config"; +import { parseRecordingConfigJson } from "@/lib/recording-config"; import { CDN_BASE_URL, viewUrlFor } from "@/lib/site"; import { RecordingEditor } from "./RecordingEditor"; @@ -25,15 +21,7 @@ export default async function RecordingEditPage({ const recording = await getRecordingForUser(session.user.id, id); if (!recording) notFound(); - let savedConfig: unknown = null; - try { - savedConfig = await getObjectJson( - recordingConfigKeyFor(recording.storageKey), - ); - } catch (err) { - console.error("[recording-edit-page] config sidecar read failed:", err); - } - const initialConfig = hydrateRecordingConfig(savedConfig); + const initialConfig = parseRecordingConfigJson(recording.configJson); const env = await getAppWebEnv(); const workspaceRow = diff --git a/apps/web/app/s/[id]/not-found.tsx b/apps/web/app/s/[id]/not-found.tsx new file mode 100644 index 00000000..780fd755 --- /dev/null +++ b/apps/web/app/s/[id]/not-found.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; +import { MARKETING_SITE_URL, PRODUCT_NAME } from "@/lib/site"; + +export default function ScreenshotNotFound() { + return ( +
+

Screenshot not found

+

+ This link has expired or never existed. Screenshots are kept for 30 days + from the last time they were viewed. +

+ + Made with {PRODUCT_NAME} + +
+ ); +} diff --git a/apps/web/db/schema.snapshot.sql b/apps/web/db/schema.snapshot.sql index 8c6f7119..8b68b434 100644 --- a/apps/web/db/schema.snapshot.sql +++ b/apps/web/db/schema.snapshot.sql @@ -15,7 +15,7 @@ CREATE TABLE recordings ( last_viewed_at INTEGER NOT NULL, state TEXT NOT NULL, view_count INTEGER NOT NULL DEFAULT 0, title TEXT, user_id TEXT, visibility TEXT NOT NULL DEFAULT 'public', bake_status TEXT NOT NULL DEFAULT 'none' CHECK (bake_status IN ('none', 'expected', 'done')), webcam_storage_key TEXT, webcam_upload_id TEXT, webcam_size_bytes INTEGER NOT NULL DEFAULT 0, webcam_state TEXT NOT NULL DEFAULT 'none' - CHECK (webcam_state IN ('none', 'pending', 'ready', 'failed')), workspace_id TEXT REFERENCES workspace(id), + CHECK (webcam_state IN ('none', 'pending', 'ready', 'failed')), workspace_id TEXT REFERENCES workspace(id), config_json TEXT, summary_chapters_json TEXT, CHECK (state IN ('pending', 'ready', 'failed')), CHECK (source IN ('instant', 'edited')), CHECK (preset IN ('recording')) @@ -254,7 +254,7 @@ CREATE TABLE recording_comments ( , timestamp_ms INTEGER) STRICT; CREATE TABLE recording_activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, - slug TEXT NOT NULL, + slug TEXT NOT NULL REFERENCES recordings(slug) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('reaction', 'comment')), diff --git a/apps/web/lib/recording-config.ts b/apps/web/lib/recording-config.ts index 219a0270..3c5a9dbc 100644 --- a/apps/web/lib/recording-config.ts +++ b/apps/web/lib/recording-config.ts @@ -139,6 +139,15 @@ export function recordingGradientCss( return `linear-gradient(135deg, ${parts.join(", ")})`; } +export function parseRecordingConfigJson(json: string | null): RecordingConfig { + if (!json) return DEFAULT_RECORDING_CONFIG; + try { + return hydrateRecordingConfig(JSON.parse(json)); + } catch { + return DEFAULT_RECORDING_CONFIG; + } +} + export function hydrateRecordingConfig(raw: unknown): RecordingConfig { if (!raw || typeof raw !== "object") return DEFAULT_RECORDING_CONFIG; const obj = raw as Record; diff --git a/apps/web/lib/recording/cron.ts b/apps/web/lib/recording/cron.ts index 9c89048f..41baff3b 100644 --- a/apps/web/lib/recording/cron.ts +++ b/apps/web/lib/recording/cron.ts @@ -2,12 +2,14 @@ import { ACCOUNT_LIMITS } from "@captureflow/quota"; import { createD1Db } from "./db-d1"; +import { objectKeysFor } from "./object-keys"; +import { screenshotObjectKeysFor } from "../screenshot-keys"; /* * Cron handlers, invoked by the wrapper worker's `scheduled()` entry: * runHourlyMultipartGc on `0 * * * *`, runDailyRetentionSweep on `0 4 * * *`. - * The R2 lifecycle rule (delete-objects-after-30d) is the safety net if cron - * stays silent. + * The bucket's only lifecycle rule aborts incomplete multipart uploads after + * 7 days — there is no object-expiry rule, so nothing catches a silent cron. */ type CronEnv = { @@ -20,60 +22,71 @@ const FAILED_RETENTION_MS = 24 * 60 * 60 * 1000; const RETENTION_MS = ACCOUNT_LIMITS.retentionDaysFromLastView * 24 * 60 * 60 * 1000; const BATCH_SIZE = 100; +/* + * Each sweep drains its backlog in BATCH_SIZE pages instead of taking one + * page per invocation — a single sample silently falls behind forever once + * more rows expire per day than one page holds. The page cap bounds a run + * against rows that refuse to clear (every iteration re-reads them). + */ +const MAX_BATCHES = 50; export async function runHourlyMultipartGc(env: CronEnv): Promise { const cutoff = Date.now() - STALE_PENDING_WINDOW_MS; - const stale = await env.DB.prepare( - `SELECT slug, storage_key AS storageKey, upload_id AS uploadId, - webcam_storage_key AS webcamStorageKey, - webcam_upload_id AS webcamUploadId - FROM recordings - WHERE state = 'pending' - AND upload_id IS NOT NULL - AND created_at < ? - LIMIT ?`, - ) - .bind(cutoff, BATCH_SIZE) - .all<{ - slug: string; - storageKey: string; - uploadId: string; - webcamStorageKey: string | null; - webcamUploadId: string | null; - }>(); + for (let batch = 0; batch < MAX_BATCHES; batch++) { + const stale = await env.DB.prepare( + `SELECT slug, storage_key AS storageKey, upload_id AS uploadId, + webcam_storage_key AS webcamStorageKey, + webcam_upload_id AS webcamUploadId + FROM recordings + WHERE state = 'pending' + AND upload_id IS NOT NULL + AND created_at < ? + LIMIT ?`, + ) + .bind(cutoff, BATCH_SIZE) + .all<{ + slug: string; + storageKey: string; + uploadId: string; + webcamStorageKey: string | null; + webcamUploadId: string | null; + }>(); - for (const row of stale.results ?? []) { - try { - const upload = env.BUCKET.resumeMultipartUpload( - row.storageKey, - row.uploadId, - ); - await upload.abort(); - } catch (err) { - console.warn(`[cron] abort failed for ${row.slug}:`, err); - } - if (row.webcamUploadId && row.webcamStorageKey) { + const rows = stale.results ?? []; + for (const row of rows) { try { - const wc = env.BUCKET.resumeMultipartUpload( - row.webcamStorageKey, - row.webcamUploadId, + const upload = env.BUCKET.resumeMultipartUpload( + row.storageKey, + row.uploadId, ); - await wc.abort(); + await upload.abort(); } catch (err) { - console.warn(`[cron] webcam abort failed for ${row.slug}:`, err); + console.warn(`[cron] abort failed for ${row.slug}:`, err); + } + if (row.webcamUploadId && row.webcamStorageKey) { + try { + const wc = env.BUCKET.resumeMultipartUpload( + row.webcamStorageKey, + row.webcamUploadId, + ); + await wc.abort(); + } catch (err) { + console.warn(`[cron] webcam abort failed for ${row.slug}:`, err); + } } + await env.DB.prepare( + `UPDATE recordings + SET state = 'failed', + upload_id = NULL, + webcam_state = CASE WHEN webcam_state = 'pending' THEN 'failed' ELSE webcam_state END, + webcam_upload_id = NULL + WHERE slug = ?`, + ) + .bind(row.slug) + .run(); } - await env.DB.prepare( - `UPDATE recordings - SET state = 'failed', - upload_id = NULL, - webcam_state = CASE WHEN webcam_state = 'pending' THEN 'failed' ELSE webcam_state END, - webcam_upload_id = NULL - WHERE slug = ?`, - ) - .bind(row.slug) - .run(); + if (rows.length < BATCH_SIZE) break; } } @@ -82,36 +95,81 @@ export async function runDailyRetentionSweep(env: CronEnv): Promise { const retentionCutoff = now - RETENTION_MS; const failedCutoff = now - FAILED_RETENTION_MS; - const expiring = await env.DB.prepare( - `SELECT slug, storage_key AS storageKey, poster_key AS posterKey, - webcam_storage_key AS webcamStorageKey, state - FROM recordings - WHERE (state = 'ready' AND last_viewed_at < ?) - OR (state = 'failed' AND created_at < ?) - LIMIT ?`, - ) - .bind(retentionCutoff, failedCutoff, BATCH_SIZE) - .all<{ - slug: string; - storageKey: string; - posterKey: string | null; - webcamStorageKey: string | null; - state: string; - }>(); - const db = createD1Db(env.DB); - for (const row of expiring.results ?? []) { - try { - await env.BUCKET.delete(row.storageKey); - if (row.posterKey) { - await env.BUCKET.delete(row.posterKey); + for (let batch = 0; batch < MAX_BATCHES; batch++) { + const expiring = await env.DB.prepare( + `SELECT slug, storage_key AS storageKey, poster_key AS posterKey, + webcam_storage_key AS webcamStorageKey, state + FROM recordings + WHERE (state = 'ready' AND last_viewed_at < ?) + OR (state = 'failed' AND created_at < ?) + LIMIT ?`, + ) + .bind(retentionCutoff, failedCutoff, BATCH_SIZE) + .all<{ + slug: string; + storageKey: string; + posterKey: string | null; + webcamStorageKey: string | null; + state: string; + }>(); + + const rows = expiring.results ?? []; + for (const row of rows) { + try { + await env.BUCKET.delete(objectKeysFor(row)); + } catch (err) { + console.warn(`[cron] r2 delete failed for ${row.slug}:`, err); } - if (row.webcamStorageKey) { - await env.BUCKET.delete(row.webcamStorageKey); + await db.deleteRecording(row.slug); + } + if (rows.length < BATCH_SIZE) break; + } + + await sweepScreenshots(env, retentionCutoff, failedCutoff); +} + +/* + * The screenshot arm of the same sweep. `idx_screenshots_gc` was created for + * it but nothing ever called it, so screenshots never expired and soft-deleted + * rows (whose objects the delete action already removed) accumulated forever. + * last_viewed_at is nullable here, unlike recordings, so idle age falls back to + * created_at — which costs the index, but a NULL would otherwise never expire. + */ +async function sweepScreenshots( + env: CronEnv, + retentionCutoff: number, + deletedCutoff: number, +): Promise { + for (let batch = 0; batch < MAX_BATCHES; batch++) { + const expiring = await env.DB.prepare( + `SELECT id, storage_key AS storageKey, state + FROM screenshots + WHERE (state = 'ready' AND COALESCE(last_viewed_at, created_at) < ?) + OR (state = 'deleted' AND updated_at < ?) + LIMIT ?`, + ) + .bind(retentionCutoff, deletedCutoff, BATCH_SIZE) + .all<{ id: string; storageKey: string; state: string }>(); + + const rows = expiring.results ?? []; + for (const row of rows) { + /* + * Objects are deleted for soft-deleted rows too: the delete action's R2 + * cleanup is best-effort and its comment relies on this sweep to catch + * what it missed. Purging the row first would strand those bytes with + * nothing left pointing at them. Re-deleting absent keys is a no-op. + */ + try { + await env.BUCKET.delete(screenshotObjectKeysFor(row.storageKey)); + } catch (err) { + console.warn(`[cron] screenshot r2 delete failed for ${row.id}:`, err); + continue; } - } catch (err) { - console.warn(`[cron] r2 delete failed for ${row.slug}:`, err); + await env.DB.prepare(`DELETE FROM screenshots WHERE id = ?`) + .bind(row.id) + .run(); } - await db.deleteRecording(row.slug); + if (rows.length < BATCH_SIZE) break; } } diff --git a/apps/web/lib/recording/db-d1.ts b/apps/web/lib/recording/db-d1.ts index 44b362c8..132cd36d 100644 --- a/apps/web/lib/recording/db-d1.ts +++ b/apps/web/lib/recording/db-d1.ts @@ -38,6 +38,8 @@ type D1Row = { webcam_upload_id: string | null; webcam_size_bytes: number; webcam_state: string; + config_json: string | null; + summary_chapters_json: string | null; }; type D1ReactionRow = { @@ -87,6 +89,8 @@ function rowFromD1(r: D1Row): RecordingRow { webcamUploadId: r.webcam_upload_id ?? null, webcamSizeBytes: r.webcam_size_bytes ?? 0, webcamState: (r.webcam_state as WebcamState) ?? "none", + configJson: r.config_json ?? null, + summaryChaptersJson: r.summary_chapters_json ?? null, }; } @@ -120,7 +124,8 @@ const COLUMNS_SELECT = "slug, device_id, storage_key, poster_key, upload_id, " + "size_bytes, duration_ms, width, height, source, preset, " + "created_at, last_viewed_at, view_count, title, state, user_id, workspace_id, visibility, " + - "webcam_storage_key, webcam_upload_id, webcam_size_bytes, webcam_state"; + "webcam_storage_key, webcam_upload_id, webcam_size_bytes, webcam_state, " + + "config_json, summary_chapters_json"; export function createD1Db(d1: D1Database): RecordingDb { /* @@ -134,16 +139,19 @@ export function createD1Db(d1: D1Database): RecordingDb { size_bytes, duration_ms, width, height, source, preset, created_at, last_viewed_at, view_count, title, state, user_id, workspace_id, visibility, - webcam_storage_key, webcam_upload_id, webcam_size_bytes, webcam_state + webcam_storage_key, webcam_upload_id, webcam_size_bytes, webcam_state, + config_json, summary_chapters_json ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, - ?19, ?20, ?21, ?22, ?23)`, + ?19, ?20, ?21, ?22, ?23, ?24, ?25)`, ); const stmtGet = d1.prepare( `SELECT ${COLUMNS_SELECT} FROM recordings WHERE slug = ?1 LIMIT 1`, ); + // recording_activity rows go with the recording via ON DELETE CASCADE + // (migration 0008); no delete path touches that table directly. const stmtDelete = d1.prepare(`DELETE FROM recordings WHERE slug = ?1`); const stmtListForDevice = d1.prepare( @@ -254,6 +262,8 @@ export function createD1Db(d1: D1Database): RecordingDb { row.webcamUploadId, row.webcamSizeBytes, row.webcamState, + row.configJson, + row.summaryChaptersJson, ) .run(); }, @@ -290,6 +300,8 @@ export function createD1Db(d1: D1Database): RecordingDb { webcamUploadId: "webcam_upload_id", webcamSizeBytes: "webcam_size_bytes", webcamState: "webcam_state", + configJson: "config_json", + summaryChaptersJson: "summary_chapters_json", }; let placeholder = 1; for (const key of Object.keys(patch) as (keyof RecordingRow)[]) { diff --git a/apps/web/lib/recording/object-keys.test.ts b/apps/web/lib/recording/object-keys.test.ts new file mode 100644 index 00000000..519f761a --- /dev/null +++ b/apps/web/lib/recording/object-keys.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { objectKeysFor, summaryChaptersKeyFor } from "./object-keys"; +import { screenshotObjectKeysFor } from "../screenshot-keys"; + +const full = { + storageKey: "videos/abc12345.mp4", + posterKey: "posters/abc12345.jpg", + webcamStorageKey: "videos/abc12345-webcam.webm", +}; + +describe("objectKeysFor", () => { + it("covers every object a recording owns", () => { + expect(objectKeysFor(full)).toEqual([ + "videos/abc12345.mp4", + "posters/abc12345.jpg", + "videos/abc12345-webcam.webm", + "videos/abc12345.config.json", + "videos/abc12345.mp4.summary-chapters.json", + ]); + }); + + it("drops absent poster and webcam but keeps the sidecars", () => { + expect( + objectKeysFor({ ...full, posterKey: null, webcamStorageKey: null }), + ).toEqual([ + "videos/abc12345.mp4", + "videos/abc12345.config.json", + "videos/abc12345.mp4.summary-chapters.json", + ]); + }); + + it("keys a webm recording off its own extension", () => { + const keys = objectKeysFor({ + storageKey: "videos/abc12345.webm", + posterKey: null, + webcamStorageKey: null, + }); + expect(keys).toContain("videos/abc12345.webm.config.json"); + expect(keys).toContain("videos/abc12345.webm.summary-chapters.json"); + }); + + it("never returns a null key", () => { + for (const key of objectKeysFor({ ...full, posterKey: null })) { + expect(typeof key).toBe("string"); + } + }); +}); + +describe("summaryChaptersKeyFor", () => { + it("suffixes the full storage key", () => { + expect(summaryChaptersKeyFor("videos/x.mp4")).toBe( + "videos/x.mp4.summary-chapters.json", + ); + }); +}); + +describe("screenshotObjectKeysFor", () => { + it("covers the image plus both sidecars", () => { + expect(screenshotObjectKeysFor("screenshots/abc.png")).toEqual([ + "screenshots/abc.png", + "screenshots/abc.source.png", + "screenshots/abc.state.json", + ]); + }); +}); diff --git a/apps/web/lib/recording/object-keys.ts b/apps/web/lib/recording/object-keys.ts new file mode 100644 index 00000000..cf23c00c --- /dev/null +++ b/apps/web/lib/recording/object-keys.ts @@ -0,0 +1,33 @@ +import { recordingConfigKeyFor } from "../recording-config"; + +export function summaryChaptersKeyFor(videoStorageKey: string): string { + return `${videoStorageKey}.summary-chapters.json`; +} + +type RecordingObjects = { + storageKey: string; + posterKey: string | null; + webcamStorageKey: string | null; +}; + +/* + * Every R2 object a recording owns. The three delete paths (dashboard action, + * DELETE /api/r/[id], retention sweep) each drifted to their own list and + * stranded a different subset; they all read this now. Keep it free of + * binding/runtime imports — lib/recording/cron.ts is bundled into worker.ts by + * wrangler, which can't resolve @opennextjs/cloudflare. + * + * The config/summary sidecar keys outlive migration 0007 (which moved those + * payloads into the row) so deleting a pre-migration recording still clears + * its legacy R2 objects; for newer recordings they delete as no-ops. + */ +export function objectKeysFor(row: RecordingObjects): string[] { + const keys = [ + row.storageKey, + row.posterKey, + row.webcamStorageKey, + recordingConfigKeyFor(row.storageKey), + summaryChaptersKeyFor(row.storageKey), + ]; + return keys.filter((k): k is string => k !== null); +} diff --git a/apps/web/lib/recording/r2.ts b/apps/web/lib/recording/r2.ts index cb50eb12..5280aba0 100644 --- a/apps/web/lib/recording/r2.ts +++ b/apps/web/lib/recording/r2.ts @@ -89,10 +89,12 @@ export async function putObject( }); } -export async function headObject(storageKey: string): Promise { +// Null when the object is absent. R2's size is authoritative; the byte count +// the client reports at finalize is not. +export async function objectSize(storageKey: string): Promise { const bucket = await getBucket(); const head = await bucket.head(storageKey); - return head !== null; + return head?.size ?? null; } export async function putObjectJson( diff --git a/apps/web/lib/recording/summary-chapters.ts b/apps/web/lib/recording/summary-chapters.ts index 3fade626..634df1bb 100644 --- a/apps/web/lib/recording/summary-chapters.ts +++ b/apps/web/lib/recording/summary-chapters.ts @@ -1,5 +1,3 @@ -import { getObjectJson, putObjectJson } from "./r2"; - export type RecordingChapter = { id: string; // Offset into the video, in milliseconds. @@ -17,10 +15,6 @@ export const EMPTY_SUMMARY_CHAPTERS: RecordingSummaryChapters = { chapters: [], }; -export function summaryChaptersKeyFor(videoStorageKey: string): string { - return `${videoStorageKey}.summary-chapters.json`; -} - // Caps bound the sidecar size so a malicious owner can't grow it unboundedly. const MAX_SUMMARY_LENGTH = 4_000; const MAX_CHAPTERS = 100; @@ -47,21 +41,13 @@ export function hydrateSummaryChapters(raw: unknown): RecordingSummaryChapters { return { summary, chapters }; } -export async function loadSummaryChapters( - videoStorageKey: string, -): Promise { - const raw = await getObjectJson( - summaryChaptersKeyFor(videoStorageKey), - ).catch(() => null); - return raw ? hydrateSummaryChapters(raw) : EMPTY_SUMMARY_CHAPTERS; -} - -export async function saveSummaryChapters( - videoStorageKey: string, - payload: RecordingSummaryChapters, -): Promise { - await putObjectJson( - summaryChaptersKeyFor(videoStorageKey), - hydrateSummaryChapters(payload), - ); +export function parseSummaryChaptersJson( + json: string | null, +): RecordingSummaryChapters { + if (!json) return EMPTY_SUMMARY_CHAPTERS; + try { + return hydrateSummaryChapters(JSON.parse(json)); + } catch { + return EMPTY_SUMMARY_CHAPTERS; + } } diff --git a/apps/web/lib/recording/types.ts b/apps/web/lib/recording/types.ts index c2bf7839..0ae43bc7 100644 --- a/apps/web/lib/recording/types.ts +++ b/apps/web/lib/recording/types.ts @@ -37,6 +37,8 @@ export type RecordingRow = { webcamUploadId: string | null; webcamSizeBytes: number; webcamState: WebcamState; + configJson: string | null; + summaryChaptersJson: string | null; }; export type InitRequest = { diff --git a/apps/web/lib/recordings-db.ts b/apps/web/lib/recordings-db.ts index 03736b02..3fd5ade8 100644 --- a/apps/web/lib/recordings-db.ts +++ b/apps/web/lib/recordings-db.ts @@ -31,6 +31,8 @@ export type DashboardRecordingRow = { webcamSizeBytes: number; webcamState: WebcamState; workspaceId: string | null; + configJson: string | null; + summaryChaptersJson: string | null; }; type D1Row = { @@ -56,12 +58,15 @@ type D1Row = { webcam_size_bytes: number; webcam_state: string; workspace_id: string | null; + config_json: string | null; + summary_chapters_json: string | null; }; const COLUMNS_SELECT = "slug, user_id, storage_key, poster_key, size_bytes, duration_ms, width, height, " + "source, preset, created_at, last_viewed_at, view_count, title, state, " + "visibility, webcam_storage_key, webcam_size_bytes, webcam_state, workspace_id, " + + "config_json, summary_chapters_json, " + "(SELECT COUNT(*) FROM recording_activity WHERE recording_activity.slug = recordings.slug AND recording_activity.kind = 'comment') AS comment_count, " + "(SELECT COUNT(*) FROM recording_activity WHERE recording_activity.slug = recordings.slug AND recording_activity.kind = 'reaction') AS reaction_count"; @@ -89,6 +94,8 @@ function rowFromD1(r: D1Row): DashboardRecordingRow { webcamSizeBytes: r.webcam_size_bytes ?? 0, webcamState: (r.webcam_state as WebcamState) ?? "none", workspaceId: r.workspace_id ?? null, + configJson: r.config_json ?? null, + summaryChaptersJson: r.summary_chapters_json ?? null, }; } @@ -169,6 +176,22 @@ export async function updateRecordingTitleForUser( return (res.meta?.changes ?? 0) > 0; } +export async function updateRecordingConfigForUser( + userId: string, + slug: string, + configJson: string, +): Promise { + const db = await getDb(); + const res = await db + .prepare( + `UPDATE recordings SET config_json = ?3 + WHERE slug = ?1 AND user_id = ?2`, + ) + .bind(slug, userId, configJson) + .run(); + return (res.meta?.changes ?? 0) > 0; +} + export async function updateRecordingVisibilityForUser( userId: string, slug: string, @@ -264,10 +287,4 @@ export async function deleteRecordingForAdmin( } // Despite the name, removes all recording_activity (reactions + comments). -export async function deleteReactionsForRecording(slug: string): Promise { - const db = await getDb(); - await db - .prepare(`DELETE FROM recording_activity WHERE slug = ?1`) - .bind(slug) - .run(); -} +// recording_activity rows cascade with the recording (migration 0008). diff --git a/apps/web/lib/screenshot-keys.ts b/apps/web/lib/screenshot-keys.ts index fb1ab82b..9356f6e6 100644 --- a/apps/web/lib/screenshot-keys.ts +++ b/apps/web/lib/screenshot-keys.ts @@ -13,3 +13,8 @@ export function stateKeyFor(storageKey: string): string { } return `${storageKey}.state.json`; } + +// Every R2 object a screenshot owns, for the delete action and retention sweep. +export function screenshotObjectKeysFor(storageKey: string): string[] { + return [storageKey, sourceKeyFor(storageKey), stateKeyFor(storageKey)]; +} diff --git a/apps/web/migrations/0007_add_recording_sidecar_columns.sql b/apps/web/migrations/0007_add_recording_sidecar_columns.sql new file mode 100644 index 00000000..22bb079c --- /dev/null +++ b/apps/web/migrations/0007_add_recording_sidecar_columns.sql @@ -0,0 +1,9 @@ +-- Recording config + AI summary/chapters move from R2 JSON sidecars into the +-- row itself. They are tiny (config ~112 B; summary capped at 4 KB by +-- hydrateSummaryChapters), every read already has the row in hand, and R2 +-- sidecars were the one object class the delete paths kept stranding. +-- NULL means "never customized" / "no summary yet"; readers hydrate through +-- parseRecordingConfigJson / parseSummaryChaptersJson, so bad blobs degrade +-- to defaults instead of breaking the page. +ALTER TABLE recordings ADD COLUMN config_json TEXT; +ALTER TABLE recordings ADD COLUMN summary_chapters_json TEXT; diff --git a/apps/web/migrations/0008_recording_activity_fk.sql b/apps/web/migrations/0008_recording_activity_fk.sql new file mode 100644 index 00000000..e641b2a5 --- /dev/null +++ b/apps/web/migrations/0008_recording_activity_fk.sql @@ -0,0 +1,33 @@ +-- recording_activity previously had no FK, so comment/reaction rows outlived +-- their recording unless every delete path remembered to clean them (two of +-- three forgot). SQLite can't ADD CONSTRAINT, hence the rebuild. The copy +-- filters any already-orphaned rows; ids are preserved so existing anchors +-- keep working. ON DELETE CASCADE is now the single cleanup mechanism — +-- delete paths no longer touch this table directly. +CREATE TABLE recording_activity_fk ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL REFERENCES recordings(slug) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('reaction', 'comment')), + user_id TEXT, + user_name TEXT, + emoji TEXT, + body TEXT, + timestamp_ms INTEGER, + created_at INTEGER NOT NULL +) STRICT; + +INSERT INTO recording_activity_fk + (id, slug, kind, user_id, user_name, emoji, body, timestamp_ms, created_at) + SELECT id, slug, kind, user_id, user_name, emoji, body, timestamp_ms, created_at + FROM recording_activity + WHERE slug IN (SELECT slug FROM recordings); + +DROP TABLE recording_activity; +ALTER TABLE recording_activity_fk RENAME TO recording_activity; + +CREATE INDEX idx_activity_slug_created + ON recording_activity (slug, created_at DESC); +CREATE INDEX idx_activity_slug_ts + ON recording_activity (slug, timestamp_ms); +CREATE INDEX idx_activity_slug_kind + ON recording_activity (slug, kind); diff --git a/packages/quota/src/totals.ts b/packages/quota/src/totals.ts index 9a9f0b65..747d97a1 100644 --- a/packages/quota/src/totals.ts +++ b/packages/quota/src/totals.ts @@ -14,7 +14,7 @@ export async function totalStorageForUser( const r = await db .prepare( `SELECT - COALESCE((SELECT SUM(s.size_bytes) FROM recordings s + COALESCE((SELECT SUM(s.size_bytes + s.webcam_size_bytes) FROM recordings s JOIN workspace w ON w.id = s.workspace_id WHERE w.owner_user_id = ?1 AND s.state = 'ready'), 0) + COALESCE((SELECT SUM(s.size_bytes) FROM screenshots s diff --git a/scripts/backfill-recording-sidecars.py b/scripts/backfill-recording-sidecars.py new file mode 100755 index 00000000..7279815b --- /dev/null +++ b/scripts/backfill-recording-sidecars.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""One-off backfill for migration 0008 (sidecars move into the row). + +Copies each recording's legacy R2 sidecar JSON — .config.json +and .summary-chapters.json — into recordings.config_json / +summary_chapters_json. Probes every recording (one `wrangler r2 object get` +per candidate key, a few seconds each), so expect a few minutes. + +Idempotent: re-run after deploying the cutover to catch sidecars the old +worker wrote in between. Requires wrangler auth. Run the pending D1 +migrations first: + + cd apps/web && npx wrangler d1 migrations apply captureflow --remote + python3 scripts/backfill-recording-sidecars.py +""" + +import json +import pathlib +import subprocess +import tempfile + +WEB = pathlib.Path(__file__).resolve().parent.parent / "apps" / "web" +BUCKET = "captureflow-recordings" +DB = "captureflow" + + +def wrangler(*args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["npx", "wrangler", *args], cwd=WEB, capture_output=True, text=True + ) + + +def d1_json(sql: str): + r = wrangler("d1", "execute", DB, "--remote", "--json", "--command", sql) + if r.returncode != 0: + raise SystemExit(f"d1 execute failed:\n{r.stderr}") + return json.loads(r.stdout)[0]["results"] + + +def config_key_for(storage_key: str) -> str: + # Mirrors recordingConfigKeyFor in apps/web/lib/recording-config.ts. + base = storage_key[: -len(".mp4")] if storage_key.endswith(".mp4") else storage_key + return f"{base}.config.json" + + +def fetch(key: str) -> str | None: + with tempfile.NamedTemporaryFile(suffix=".json") as f: + r = wrangler("r2", "object", "get", f"{BUCKET}/{key}", "--file", f.name) + if r.returncode != 0: + return None + body = pathlib.Path(f.name).read_text() + try: + json.loads(body) + except ValueError: + print(f" skipping {key}: not valid JSON") + return None + return body + + +def sql_quote(s: str) -> str: + return "'" + s.replace("'", "''") + "'" + + +def main() -> None: + rows = d1_json("SELECT slug, storage_key FROM recordings") + print(f"probing {len(rows)} recordings for legacy sidecars…") + updates = 0 + for row in rows: + slug, key = row["slug"], row["storage_key"] + for column, sidecar in ( + ("config_json", config_key_for(key)), + ("summary_chapters_json", f"{key}.summary-chapters.json"), + ): + body = fetch(sidecar) + if body is None: + continue + d1_json( + f"UPDATE recordings SET {column} = {sql_quote(body)} " + f"WHERE slug = {sql_quote(slug)}" + ) + print(f" {slug}: {sidecar} -> {column}") + updates += 1 + print(f"done: {updates} column(s) backfilled") + + +if __name__ == "__main__": + main()