From b50f5a58b925d651be53ee9fe32edb090c371e1f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:37:06 -0500 Subject: [PATCH 01/12] feat: staff takedown switch for Cap Infra videos Stores the flag under a reserved key in developer_videos.metadata, wrapping the developer's own metadata so a restore writes it back exactly. Status is untouched, so storage billing reads the video as before. No migration. A staff-only route and scripts/infra-video-takedown.ts flip it. --- .../fixtures/developer-video-takedown.ts | 55 ++++++++ .../admin/developer-video-takedown/route.ts | 59 ++++++++ .../web/lib/developer-video-takedown-store.ts | 130 ++++++++++++++++++ apps/web/lib/developer-video-takedown.ts | 69 ++++++++++ apps/web/lib/infra-video-upload.ts | 3 +- packages/database/developer-video-takedown.ts | 30 ++++ packages/database/package.json | 1 + packages/database/types/developer-video.ts | 4 + scripts/infra-video-takedown.ts | 99 +++++++++++++ 9 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 apps/web/__tests__/fixtures/developer-video-takedown.ts create mode 100644 apps/web/app/api/admin/developer-video-takedown/route.ts create mode 100644 apps/web/lib/developer-video-takedown-store.ts create mode 100644 apps/web/lib/developer-video-takedown.ts create mode 100644 packages/database/developer-video-takedown.ts create mode 100644 scripts/infra-video-takedown.ts diff --git a/apps/web/__tests__/fixtures/developer-video-takedown.ts b/apps/web/__tests__/fixtures/developer-video-takedown.ts new file mode 100644 index 00000000000..b580466e24b --- /dev/null +++ b/apps/web/__tests__/fixtures/developer-video-takedown.ts @@ -0,0 +1,55 @@ +import type { DeveloperVideoStore } from "./developer-video"; + +// The takedown rewrites metadata in SQL. This applies the same two rewrites +// to the fake's stored row, so a test can take a video down and restore it. +// The SQL itself was run against MySQL 8 when it was written. +export async function withTakedownSql( + store: DeveloperVideoStore, + database: Awaited< + ReturnType + >, +) { + const { MySqlDialect } = await import("drizzle-orm/mysql-core"); + const { SQL } = await import("drizzle-orm"); + const dialect = new MySqlDialect(); + + const evaluate = (value: unknown, current: unknown): unknown => { + if (!(value instanceof SQL)) return value; + const query = dialect.sqlToQuery(value); + if (query.sql.startsWith("JSON_OBJECT(")) { + const [key, at] = query.params as [string, string]; + return { [key]: { at, metadata: current } }; + } + if (query.sql.startsWith("CASE WHEN JSON_TYPE(")) { + const marker = Object.values( + (current ?? {}) as Record, + )[0]; + return marker?.metadata ?? null; + } + throw new Error(`The fake cannot evaluate ${query.sql}`); + }; + + const wrapped = { + ...database, + update: () => ({ + set: (updates: Record) => ({ + where: async () => { + const current = store.video; + const applied = Object.fromEntries( + Object.entries(updates).map(([column, value]) => [ + column, + evaluate( + value, + current?.[column as keyof NonNullable], + ), + ]), + ); + store.updates.push(updates); + if (current) store.video = { ...current, ...applied }; + }, + }), + }), + transaction: async (run: (tx: unknown) => Promise) => run(wrapped), + }; + return wrapped; +} diff --git a/apps/web/app/api/admin/developer-video-takedown/route.ts b/apps/web/app/api/admin/developer-video-takedown/route.ts new file mode 100644 index 00000000000..93ce94c0c11 --- /dev/null +++ b/apps/web/app/api/admin/developer-video-takedown/route.ts @@ -0,0 +1,59 @@ +import { requireAdmin } from "@/lib/admin-access"; +import { setDeveloperVideoTakedown } from "@/lib/developer-video-takedown-store"; + +const NO_STORE = { "Cache-Control": "no-store" }; + +function answer(status: number, body: unknown) { + return Response.json(body, { status, headers: NO_STORE }); +} + +// The session cookie is SameSite=None, so a request is only taken as JSON +// from no origin (the staff script) or this one. A cross-site form cannot +// send JSON, and a cross-site fetch of it stops at the preflight. +function fromThisSite(request: Request): boolean { + const type = request.headers.get("content-type") ?? ""; + if (!type.toLowerCase().startsWith("application/json")) return false; + const origin = request.headers.get("origin"); + return origin === null || origin === new URL(request.url).origin; +} + +export async function POST(request: Request) { + if (!fromThisSite(request)) { + return answer(400, { error: "Send JSON from the staff script." }); + } + + let admin: Awaited>; + try { + admin = await requireAdmin(); + } catch { + return answer(403, { error: "Not allowed." }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return answer(400, { error: "The body is not JSON." }); + } + const { video, takenDown } = (body ?? {}) as { + video?: unknown; + takenDown?: unknown; + }; + if (typeof video !== "string" || typeof takenDown !== "boolean") { + return answer(400, { + error: + "Send { video, takenDown }: a playback id, video id or page URL, and true or false.", + }); + } + + const result = await setDeveloperVideoTakedown(video, takenDown); + if (!result) return answer(404, { error: "No video matches that." }); + + console.info("[developer-video] Takedown changed", { + by: admin.id, + videoId: result.videoId, + takenDown: result.takenDown, + changed: result.changed, + }); + return answer(200, { data: result }); +} diff --git a/apps/web/lib/developer-video-takedown-store.ts b/apps/web/lib/developer-video-takedown-store.ts new file mode 100644 index 00000000000..c785400c2e7 --- /dev/null +++ b/apps/web/lib/developer-video-takedown-store.ts @@ -0,0 +1,130 @@ +import { db } from "@cap/database"; +import { + restoredMetadataSql, + takenDownMetadataSql, +} from "@cap/database/developer-video-takedown"; +import { developerVideos } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { + DEVELOPER_VIDEO_UNAVAILABLE, + developerVideoIsTakenDown, + developerVideoReference, + developerVideoTakenDownAt, +} from "./developer-video-takedown"; + +export type DeveloperVideoTakedownResult = { + videoId: string; + appId: string; + playbackId: string | null; + takenDown: boolean; + takenDownAt: string | null; + changed: boolean; +}; + +type Executor = Pick, "select" | "update">; + +async function lockVideo( + tx: Executor, + column: "playbackId" | "id", + value: string, +) { + const [video] = await tx + .select({ + id: developerVideos.id, + appId: developerVideos.appId, + playbackId: developerVideos.playbackId, + metadata: developerVideos.metadata, + }) + .from(developerVideos) + .where( + and( + eq(developerVideos[column], value), + isNull(developerVideos.deletedAt), + ), + ) + .for("update"); + return video ?? null; +} + +export async function setDeveloperVideoTakedown( + input: string, + takenDown: boolean, + now: Date = new Date(), +): Promise { + const reference = developerVideoReference(input); + if (!reference) return null; + return db().transaction(async (tx) => { + const video = + (await lockVideo(tx, "playbackId", reference)) ?? + (await lockVideo(tx, "id", reference)); + if (!video) return null; + + const wasTakenDown = developerVideoIsTakenDown(video); + const changed = wasTakenDown !== takenDown; + if (changed) { + await tx + .update(developerVideos) + .set({ + metadata: takenDown + ? takenDownMetadataSql(now) + : restoredMetadataSql(), + }) + .where(eq(developerVideos.id, video.id)); + } + return { + videoId: video.id, + appId: video.appId, + playbackId: video.playbackId, + takenDown, + takenDownAt: takenDown + ? (developerVideoTakenDownAt(video.metadata) ?? now.toISOString()) + : null, + changed, + }; + }); +} + +export async function playbackIdIsTakenDown( + playbackId: string, +): Promise { + const [video] = await db() + .select({ metadata: developerVideos.metadata }) + .from(developerVideos) + .where( + and( + eq(developerVideos.playbackId, playbackId), + isNull(developerVideos.deletedAt), + ), + ) + .limit(1); + return video ? developerVideoIsTakenDown(video) : false; +} + +const PAGE_PATH = /^\/dev\/([A-Za-z0-9_-]{16,64})(?:\/embed)?\/?$/; + +const GONE_PAGE = `Video not available

${DEVELOPER_VIDEO_UNAVAILABLE}

`; + +// A page cannot choose a 410 status in the App Router, so the proxy answers +// for a taken down video before the page renders. +export async function developerVideoGonePage( + pathname: string, + method: string, +): Promise { + if (method !== "GET" && method !== "HEAD") return null; + const playbackId = PAGE_PATH.exec(pathname)?.[1]; + if (!playbackId) return null; + try { + if (!(await playbackIdIsTakenDown(playbackId))) return null; + } catch (error) { + console.error("[developer-video] Takedown check failed", error); + return null; + } + return new Response(method === "HEAD" ? null : GONE_PAGE, { + status: 410, + headers: { + "Cache-Control": "no-store", + "Content-Type": "text/html; charset=utf-8", + "X-Robots-Tag": "noindex", + }, + }); +} diff --git a/apps/web/lib/developer-video-takedown.ts b/apps/web/lib/developer-video-takedown.ts new file mode 100644 index 00000000000..15f8c226555 --- /dev/null +++ b/apps/web/lib/developer-video-takedown.ts @@ -0,0 +1,69 @@ +import { DEVELOPER_VIDEO_TAKEDOWN_KEY } from "@cap/database/types"; + +export const DEVELOPER_VIDEO_UNAVAILABLE = "This video is not available."; + +export const DEVELOPER_VIDEO_REPORT_EMAIL = "hello@cap.so"; + +type Marker = { at: string; metadata: unknown }; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function markerOf(metadata: unknown): Marker | null { + const marker = asRecord(asRecord(metadata)?.[DEVELOPER_VIDEO_TAKEDOWN_KEY]); + if (!marker || typeof marker.at !== "string" || !("metadata" in marker)) { + return null; + } + return { at: marker.at, metadata: marker.metadata }; +} + +export function developerVideoTakenDownAt(metadata: unknown): string | null { + return markerOf(metadata)?.at ?? null; +} + +export function developerVideoIsTakenDown(video: { + metadata: unknown; +}): boolean { + return markerOf(video.metadata) !== null; +} + +export function developerVideoPublicMetadata(metadata: unknown): unknown { + const marker = markerOf(metadata); + return marker ? marker.metadata : metadata; +} + +export function withoutReservedMetadata>( + metadata: T | undefined, +): T | undefined { + if (!metadata || !(DEVELOPER_VIDEO_TAKEDOWN_KEY in metadata)) return metadata; + const { [DEVELOPER_VIDEO_TAKEDOWN_KEY]: _reserved, ...rest } = metadata; + return rest as T; +} + +export function developerVideoReportHref(playbackId: string): string { + const subject = encodeURIComponent(`Report video ${playbackId}`); + return `mailto:${DEVELOPER_VIDEO_REPORT_EMAIL}?subject=${subject}`; +} + +const REFERENCE_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + +// Staff paste whatever the report gave them: a playback id, a video id, the +// hosted or embed page, or a playback file URL. +export function developerVideoReference(input: string): string | null { + const trimmed = input.trim(); + let candidate = trimmed; + try { + const segments = new URL(trimmed).pathname.split("/").filter(Boolean); + const dev = segments.indexOf("dev"); + const playback = segments.indexOf("playback"); + candidate = + (dev >= 0 ? segments[dev + 1] : undefined) ?? + (playback >= 0 ? segments[playback + 1] : undefined) ?? + segments[0] ?? + ""; + } catch {} + return REFERENCE_PATTERN.test(candidate) ? candidate : null; +} diff --git a/apps/web/lib/infra-video-upload.ts b/apps/web/lib/infra-video-upload.ts index 45bdf29ee00..a6180b36055 100644 --- a/apps/web/lib/infra-video-upload.ts +++ b/apps/web/lib/infra-video-upload.ts @@ -23,6 +23,7 @@ import { releaseDeveloperVideoUpload, startDeveloperVideoProcessing, } from "@/lib/developer-video-start"; +import { withoutReservedMetadata } from "@/lib/developer-video-takedown"; import { canUseInfra } from "@/lib/infra-access"; import { lockInfraAccount } from "@/lib/infra-balance"; import { @@ -357,7 +358,7 @@ export async function createInfraVideo({ externalUserId: userId?.trim() || null, name: name ?? "Untitled", s3Key, - metadata, + metadata: withoutReservedMetadata(metadata), playbackId, maxResolutionTier: maxResolutionTier ?? DEVELOPER_VIDEO_DEFAULT_TIER, }); diff --git a/packages/database/developer-video-takedown.ts b/packages/database/developer-video-takedown.ts new file mode 100644 index 00000000000..115b33b9908 --- /dev/null +++ b/packages/database/developer-video-takedown.ts @@ -0,0 +1,30 @@ +import { type SQL, sql } from "drizzle-orm"; +import { developerVideos } from "./schema"; +import { DEVELOPER_VIDEO_TAKEDOWN_KEY } from "./types/developer-video"; + +// A takedown is stored in the metadata column so it needs no migration and +// leaves status alone: storage billing groups by status, and the encoding +// workflows rewrite processing, so neither can carry it. The developer's own +// metadata is kept whole inside the marker and written back on restore. +const MARKER_PATH = `$."${DEVELOPER_VIDEO_TAKEDOWN_KEY}"`; +const AT_PATH = `${MARKER_PATH}.at`; +const KEPT_PATH = `${MARKER_PATH}.metadata`; + +// Both rewrites run in SQL so the developer's metadata never passes through +// JSON.parse, which would round integers past 2^53 on the way back. A video +// with no metadata goes back to SQL NULL rather than a JSON null. +export function takenDownMetadataSql(now: Date): SQL { + return sql`JSON_OBJECT(${DEVELOPER_VIDEO_TAKEDOWN_KEY}, JSON_OBJECT('at', ${now.toISOString()}, 'metadata', ${developerVideos.metadata}))`; +} + +export function restoredMetadataSql(): SQL { + return sql`CASE WHEN JSON_TYPE(JSON_EXTRACT(${developerVideos.metadata}, ${KEPT_PATH})) = 'NULL' THEN NULL ELSE JSON_EXTRACT(${developerVideos.metadata}, ${KEPT_PATH}) END`; +} + +// For queries that list videos without loading their metadata. It matches +// developerVideoIsTakenDown in the web app. +export function developerVideoTakenDownSql(): SQL { + return sql`(JSON_TYPE(JSON_EXTRACT(${developerVideos.metadata}, ${AT_PATH})) = 'STRING' AND JSON_CONTAINS_PATH(${developerVideos.metadata}, 'one', ${KEPT_PATH}))`.mapWith( + (value) => Number(value) === 1, + ); +} diff --git a/packages/database/package.json b/packages/database/package.json index caf5703f489..87e190b3122 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -76,6 +76,7 @@ "./emails/config": "./emails/config.ts", "./emails/*": "./emails/*.tsx", "./types": "./types/index.ts", + "./developer-video-takedown": "./developer-video-takedown.ts", "./migrate": "./migrate.ts" }, "publishConfig": { diff --git a/packages/database/types/developer-video.ts b/packages/database/types/developer-video.ts index 7efc8051b2d..646a741045a 100644 --- a/packages/database/types/developer-video.ts +++ b/packages/database/types/developer-video.ts @@ -66,3 +66,7 @@ export interface DeveloperVideoProcessingState { poster: number; }; } + +// A video Cap staff took down keeps its own metadata under this key. See +// developer-video-takedown.ts for why the flag lives in metadata. +export const DEVELOPER_VIDEO_TAKEDOWN_KEY = "__capTakedown"; diff --git a/scripts/infra-video-takedown.ts b/scripts/infra-video-takedown.ts new file mode 100644 index 00000000000..47988610a13 --- /dev/null +++ b/scripts/infra-video-takedown.ts @@ -0,0 +1,99 @@ +import { pathToFileURL } from "node:url"; + +const USAGE = `Take a Cap Infra video down, or restore it. + + bun scripts/infra-video-takedown.ts down