From d981d98cf5fbf25f797be7abd94cd6ed1a076e21 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 22 Aug 2026 07:51:46 +0300 Subject: [PATCH 1/2] feat(player): let editors upload subtitles for a version Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the same path: both already play through our own video element, so a track element is all it takes. Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through. That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused. A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the video had them. Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the delivered cut, not a comment attachment. --- README.md | 4 +- .../[videoId]/versions/[versionId]/route.ts | 23 +- app/api/upload/subtitle/[filename]/route.ts | 91 ++++ .../[videoId]/subtitles/[subtitleId]/route.ts | 48 ++ app/api/videos/[videoId]/subtitles/route.ts | 276 ++++++++++ components/video-page-content.tsx | 51 ++ .../video-page/hooks/subtitle-preference.ts | 32 ++ components/video-page/hooks/use-subtitles.ts | 270 +++++++++ .../video-page/hooks/use-video-player.ts | 8 + .../video-page/hooks/use-youtube-captions.ts | 177 ++++++ components/video-page/player-core.tsx | 59 +- components/video-page/subtitle-controls.tsx | 303 +++++++++++ components/video-page/types.ts | 21 + lib/project-download.ts | 6 + lib/r2-cleanup.ts | 30 +- lib/r2-media-proxy.ts | 2 +- lib/rate-limit.ts | 3 + lib/storage-quota.ts | 33 +- lib/subtitle-validation.ts | 272 +++++++++ .../migration.sql | 39 ++ prisma/schema.prisma | 31 ++ tests/api/auth-matrix.test.ts | 49 +- tests/api/subtitles.test.ts | 514 ++++++++++++++++++ tests/setup/db-global.ts | 1 + tests/unit/lib/subtitle-validation.test.ts | 201 +++++++ types/youtube.d.ts | 10 + 26 files changed, 2529 insertions(+), 25 deletions(-) create mode 100644 app/api/upload/subtitle/[filename]/route.ts create mode 100644 app/api/videos/[videoId]/subtitles/[subtitleId]/route.ts create mode 100644 app/api/videos/[videoId]/subtitles/route.ts create mode 100644 components/video-page/hooks/subtitle-preference.ts create mode 100644 components/video-page/hooks/use-subtitles.ts create mode 100644 components/video-page/hooks/use-youtube-captions.ts create mode 100644 components/video-page/subtitle-controls.tsx create mode 100644 lib/subtitle-validation.ts create mode 100644 prisma/migrations/20260822120000_add_video_subtitles/migration.sql create mode 100644 tests/api/subtitles.test.ts create mode 100644 tests/unit/lib/subtitle-validation.test.ts diff --git a/README.md b/README.md index f6a4bd6..94e9758 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap - Timestamped comments directly on the video timeline - Voice notes, image attachments, and frame annotations -- Version history with side-by-side compare +- Version history with side-by-side compare and per-version subtitle tracks - Approval requests and sign-off tracking - Share links for client review with optional guest commenting - Workspaces, projects, member roles, and invitation flows @@ -41,7 +41,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap ### Versioning And Comparison -- Videos support multiple versions inside the same review thread. +- Videos support multiple versions inside the same review thread, each with its own subtitle tracks uploaded as SRT or WebVTT. - Teams can switch between versions without losing review context. - Compare mode lets reviewers inspect two versions side by side. diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts index 91f12c3..f0136f7 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts @@ -130,6 +130,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { videoId: result.version.videoId, }; + // Read before the delete: the rows cascade away with the version, and their stored + // objects would then have nothing pointing at them. Subtitles live in our own storage + // whatever hosts the video, so this runs for a Bunny-hosted cut too. + const subtitles = await db.videoSubtitle.findMany({ + where: { versionId }, + select: { sourceUrl: true }, + }); + await db.$transaction(async (tx) => { // Delete the version (cascades to comments). await tx.videoVersion.delete({ where: { id: versionId } }); @@ -149,15 +157,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { } }); + const versionMediaUrls = [ + ...subtitles.map((subtitle) => subtitle.sourceUrl), + ...(result.version.providerId === 'r2' + ? [result.version.originalUrl, result.version.thumbnailUrl] + : []), + ].filter((url): url is string => Boolean(url)); + const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([ cleanupBunnyStreamVideosBestEffort([bunnyRef]), - result.version.providerId === 'r2' - ? deleteMediaFilesBestEffort( - [result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string => - Boolean(url) - ) - ) - : Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }), + deleteMediaFilesBestEffort(versionMediaUrls), ]); const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult }; const cleanupWarnings = buildCleanupWarnings(cleanupInput); diff --git a/app/api/upload/subtitle/[filename]/route.ts b/app/api/upload/subtitle/[filename]/route.ts new file mode 100644 index 0000000..66b9c18 --- /dev/null +++ b/app/api/upload/subtitle/[filename]/route.ts @@ -0,0 +1,91 @@ +import { NextRequest } from 'next/server'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; +import { apiErrors } from '@/lib/api-response'; +import { proxyR2MediaObject } from '@/lib/r2-media-proxy'; +import { logError } from '@/lib/logger'; +import { + SAFE_SUBTITLE_FILENAME, + SUBTITLE_CONTENT_TYPE, + SUBTITLE_OBJECT_KEY_PREFIX, + subtitleFileNameToProxyUrl, +} from '@/lib/subtitle-validation'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ filename: string }> } +) { + try { + const { filename } = await params; + + // Validate filename to prevent path traversal + if (!SAFE_SUBTITLE_FILENAME.test(filename)) { + return apiErrors.badRequest('Invalid filename'); + } + + // Parallelize the DB lookup and session check to narrow the timing delta + // between "subtitle not found" and "subtitle found, access denied" responses. + const [subtitle, session] = await Promise.all([ + db.videoSubtitle.findUnique({ + where: { sourceUrl: subtitleFileNameToProxyUrl(filename) }, + select: { + version: { + select: { + video: { + select: { + id: true, + projectId: true, + project: { + select: { id: true, ownerId: true, workspaceId: true, visibility: true }, + }, + }, + }, + }, + }, + }, + }), + auth(), + ]); + + const video = subtitle?.version?.video ?? null; + if (!video) { + return apiErrors.forbidden('Access denied'); + } + + const access = await checkProjectAccess(video.project, session?.user?.id); + + if (!access.hasAccess) { + const shareSession = getShareSessionFromRequest(request, video.id); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: video.projectId, + videoId: video.id, + requiredPermission: 'VIEW', + passwordVerified: shareSession.passwordVerified, + }) + : null; + + if (!shareAccess?.hasAccess) { + return apiErrors.forbidden('Access denied'); + } + } + + return proxyR2MediaObject({ + request, + key: `${SUBTITLE_OBJECT_KEY_PREFIX}${filename}`, + fallbackContentType: SUBTITLE_CONTENT_TYPE, + cacheControl: 'private, no-store', + extraHeaders: { + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + }, + internalErrorMessage: 'Failed to retrieve subtitle', + }); + } catch (error: unknown) { + logError('Error serving subtitle:', error); + return apiErrors.internalError('Failed to retrieve subtitle'); + } +} diff --git a/app/api/videos/[videoId]/subtitles/[subtitleId]/route.ts b/app/api/videos/[videoId]/subtitles/[subtitleId]/route.ts new file mode 100644 index 0000000..d28c95c --- /dev/null +++ b/app/api/videos/[videoId]/subtitles/[subtitleId]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest } from 'next/server'; +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { db } from '@/lib/db'; +import { logError } from '@/lib/logger'; +import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; +import { rateLimit } from '@/lib/rate-limit'; +import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation'; +import { getVideoAssetAccessContext } from '@/lib/video-assets'; + +type RouteParams = { params: Promise<{ videoId: string; subtitleId: string }> }; + +// DELETE /api/videos/[videoId]/subtitles/[subtitleId] +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'subtitle-delete'); + if (limited) return limited; + + const { videoId, subtitleId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'VIEW'); + if (!context) return apiErrors.notFound('Video'); + if (!context.viewerUserId || !context.canManageAssets) { + return apiErrors.forbidden('Access denied'); + } + + const subtitle = await db.videoSubtitle.findFirst({ + where: { id: subtitleId, version: { videoParentId: videoId } }, + select: { id: true, sourceUrl: true }, + }); + if (!subtitle) return apiErrors.notFound('Subtitle'); + + // Storage first, row second, for the same reason video deletion does it in that + // order: a refused delete leaves the row in place so the operation can be retried, + // rather than orphaning an object nothing points at any more. + const objectKey = subtitleProxyPathToObjectKey(subtitle.sourceUrl); + if (objectKey) { + await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: objectKey })); + } + + await db.videoSubtitle.delete({ where: { id: subtitle.id } }); + + const response = successResponse({ id: subtitle.id, deleted: true }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + logError('Error deleting subtitle:', error); + return apiErrors.internalError('Failed to delete subtitle'); + } +} diff --git a/app/api/videos/[videoId]/subtitles/route.ts b/app/api/videos/[videoId]/subtitles/route.ts new file mode 100644 index 0000000..635f242 --- /dev/null +++ b/app/api/videos/[videoId]/subtitles/route.ts @@ -0,0 +1,276 @@ +import { NextRequest } from 'next/server'; +import { DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { randomUUID } from 'crypto'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { db } from '@/lib/db'; +import { logError } from '@/lib/logger'; +import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; +import { rateLimit } from '@/lib/rate-limit'; +import { + releaseStorageReservation, + reserveStorageQuota, + UPLOAD_RESERVATION_PURPOSES, +} from '@/lib/storage-quota'; +import { + getSubtitleExtension, + MAX_SUBTITLE_FILE_SIZE, + normalizeSubtitleFile, + normalizeSubtitleLanguage, + sanitizeSubtitleLabel, + subtitleFileNameToProxyUrl, + SUBTITLE_CONTENT_TYPE, + SUBTITLE_OBJECT_KEY_PREFIX, + subtitleProxyPathToObjectKey, +} from '@/lib/subtitle-validation'; +import { getVideoAssetAccessContext } from '@/lib/video-assets'; + +type RouteParams = { params: Promise<{ videoId: string }> }; + +const MAX_MULTIPART_BODY_SIZE = MAX_SUBTITLE_FILE_SIZE + 64 * 1024; + +/** A cut with more tracks than this is not being subtitled, it is being used as storage. */ +const MAX_SUBTITLES_PER_VERSION = 20; + +type SubtitleRow = { + id: string; + versionId: string; + language: string; + label: string; + sourceUrl: string; + sizeBytes: bigint; + createdAt: Date; + updatedAt: Date; + uploadedByUser: { id: string; name: string | null; image: string | null } | null; +}; + +function shapeSubtitle(subtitle: SubtitleRow, canManage: boolean) { + return { + id: subtitle.id, + versionId: subtitle.versionId, + language: subtitle.language, + label: subtitle.label, + url: subtitle.sourceUrl, + sizeBytes: Number(subtitle.sizeBytes), + createdAt: subtitle.createdAt, + updatedAt: subtitle.updatedAt, + uploadedByUser: subtitle.uploadedByUser, + canDelete: canManage, + }; +} + +const SUBTITLE_SELECT = { + id: true, + versionId: true, + language: true, + label: true, + sourceUrl: true, + sizeBytes: true, + createdAt: true, + updatedAt: true, + uploadedByUser: { select: { id: true, name: true, image: true } }, +} as const; + +// GET /api/videos/[videoId]/subtitles?versionId=... +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'subtitle-list'); + if (limited) return limited; + + const { videoId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'VIEW'); + if (!context) return apiErrors.notFound('Video'); + if (!context.hasViewAccess) return apiErrors.forbidden('Access denied'); + + const versionId = request.nextUrl.searchParams.get('versionId')?.trim() || null; + + const subtitles = await db.videoSubtitle.findMany({ + where: { + version: { + videoParentId: videoId, + ...(versionId ? { id: versionId } : {}), + }, + }, + orderBy: [{ language: 'asc' }], + select: SUBTITLE_SELECT, + }); + + const response = successResponse({ + subtitles: subtitles.map((subtitle) => shapeSubtitle(subtitle, context.canManageAssets)), + canManageSubtitles: context.canManageAssets, + }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + logError('Error listing subtitles:', error); + return apiErrors.internalError('Failed to load subtitles'); + } +} + +// POST /api/videos/[videoId]/subtitles +export async function POST(request: NextRequest, { params }: RouteParams) { + let reservationId: string | null = null; + let billedUserId: string | null = null; + let storedObjectKey: string | null = null; + + try { + const contentLength = request.headers.get('content-length'); + if (!contentLength) { + return apiErrors.badRequest('Missing Content-Length header'); + } + const bodySize = Number.parseInt(contentLength, 10); + if (!Number.isFinite(bodySize) || bodySize <= 0) { + return apiErrors.badRequest('Invalid Content-Length header'); + } + if (bodySize > MAX_MULTIPART_BODY_SIZE) { + return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.'); + } + + const limited = await rateLimit(request, 'subtitle-create'); + if (limited) return limited; + + const { videoId } = await params; + const context = await getVideoAssetAccessContext(request, videoId, 'VIEW'); + if (!context) return apiErrors.notFound('Video'); + // A subtitle is part of the delivered cut rather than a comment attachment, so it + // takes the editor permission and never the commenter one. Guests and share-link + // viewers can read the tracks but cannot add them. + if (!context.viewerUserId || !context.canManageAssets) { + return apiErrors.forbidden('Access denied'); + } + + const formData = await request.formData(); + const files = formData.getAll('subtitle'); + if (files.length !== 1 || !(files[0] instanceof File)) { + return apiErrors.badRequest('No subtitle file provided'); + } + const file = files[0]; + + if (file.size > MAX_SUBTITLE_FILE_SIZE) { + return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.'); + } + if (!getSubtitleExtension(file.name)) { + return apiErrors.badRequest('Subtitle must be a .srt or .vtt file'); + } + + const versionIdValue = formData.get('versionId'); + if (typeof versionIdValue !== 'string' || !versionIdValue.trim()) { + return apiErrors.badRequest('versionId is required'); + } + const versionId = versionIdValue.trim(); + + const language = normalizeSubtitleLanguage(formData.get('language')); + if (!language) { + return apiErrors.badRequest('language must be a BCP-47 tag such as "tr" or "en-US"'); + } + const label = sanitizeSubtitleLabel(formData.get('label'), language.toUpperCase()); + + const version = await db.videoVersion.findFirst({ + where: { id: versionId, videoParentId: videoId }, + select: { id: true }, + }); + if (!version) return apiErrors.notFound('Version'); + + const existing = await db.videoSubtitle.findUnique({ + where: { versionId_language: { versionId, language } }, + select: { id: true, sourceUrl: true }, + }); + + if (!existing) { + const trackCount = await db.videoSubtitle.count({ where: { versionId } }); + if (trackCount >= MAX_SUBTITLES_PER_VERSION) { + return apiErrors.badRequest( + `A version can hold at most ${MAX_SUBTITLES_PER_VERSION} subtitle tracks` + ); + } + } + + const normalized = normalizeSubtitleFile(new Uint8Array(await file.arrayBuffer())); + if (!normalized.ok) { + return apiErrors.badRequest(normalized.error); + } + + const body = Buffer.from(normalized.vtt, 'utf8'); + const sizeBytes = BigInt(body.byteLength); + + billedUserId = context.video.project.workspace.ownerId; + const reserveResult = await reserveStorageQuota( + billedUserId, + sizeBytes, + UPLOAD_RESERVATION_PURPOSES.SUBTITLE + ); + if ('error' in reserveResult) return reserveResult.error; + reservationId = reserveResult.reservationId; + + const fileName = `${randomUUID()}.vtt`; + const objectKey = `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`; + await r2Client.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: objectKey, + Body: body, + ContentType: SUBTITLE_CONTENT_TYPE, + }) + ); + storedObjectKey = objectKey; + + const created = await db.$transaction(async (tx) => { + if (existing) { + await tx.videoSubtitle.delete({ where: { id: existing.id } }); + } + return tx.videoSubtitle.create({ + data: { + versionId, + language, + label, + sourceUrl: subtitleFileNameToProxyUrl(fileName), + sizeBytes, + billedUserId: billedUserId as string, + uploadedByUserId: context.viewerUserId, + }, + select: SUBTITLE_SELECT, + }); + }); + + // The row is committed, so the bytes are counted by the usage sum and the hold that + // stood in for them until now is no longer needed. + await releaseStorageReservation( + reservationId, + billedUserId, + UPLOAD_RESERVATION_PURPOSES.SUBTITLE + ); + reservationId = null; + storedObjectKey = null; + + if (existing) { + // Best effort: the replaced track is already unreachable, and a stranded object is + // a cleanup problem rather than a reason to fail an upload that succeeded. + const staleKey = subtitleProxyPathToObjectKey(existing.sourceUrl); + if (staleKey) { + try { + await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: staleKey })); + } catch (deleteError) { + logError('Failed to delete replaced subtitle object:', deleteError); + } + } + } + + const response = successResponse(shapeSubtitle(created, true), 201); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + if (storedObjectKey) { + try { + await r2Client.send( + new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: storedObjectKey }) + ); + } catch (cleanupError) { + logError('Failed to clean up subtitle object after a failed upload:', cleanupError); + } + } + await releaseStorageReservation( + reservationId, + billedUserId, + UPLOAD_RESERVATION_PURPOSES.SUBTITLE + ); + logError('Error uploading subtitle:', error); + return apiErrors.internalError('Failed to upload subtitle'); + } +} diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index bc37c96..75a943b 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -38,6 +38,8 @@ import type { } from '@/components/video-page/types'; import { useApprovals } from '@/components/video-page/hooks/use-approvals'; import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets'; +import { useSubtitles } from '@/components/video-page/hooks/use-subtitles'; +import { useYoutubeCaptions } from '@/components/video-page/hooks/use-youtube-captions'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils'; @@ -275,6 +277,24 @@ export function VideoPageContent({ }, [video?.versions, activeVersionId]); const activeProviderId = activeVersion?.providerId; const speedOptions = getSpeedOptionsForProvider(activeProviderId); + // Only the providers that play through our own ) : ( @@ -442,6 +485,18 @@ export const PlayerCore = memo(function PlayerCore({ )} + {activeProviderId && activeProviderId !== 'direct' && ( + + )} + + + + onSelectSubtitleLanguage(null)} + className={cn(!activeSubtitleLanguage && 'font-bold text-primary')} + > + Off + + {subtitles.map((subtitle) => ( + onSelectSubtitleLanguage(subtitle.language)} + className={cn( + 'flex items-center justify-between gap-2', + subtitle.language === activeSubtitleLanguage && 'font-bold text-primary' + )} + > + {subtitle.label} + {subtitle.canDelete && ( + + )} + + ))} + {canManageSubtitles && ( + <> + {subtitles.length > 0 && } + fileInputRef.current?.click()} + disabled={isUploadingSubtitle} + > + + Add subtitle + + + )} + + + + + + { + if (!open && !isUploadingSubtitle) setPendingFile(null); + }} + > + + + Add subtitle + + {pendingFile?.name} is attached to this version only, because cue timings belong to + one cut. SRT files are converted to WebVTT on upload. + + + +
+
+ + + {languageChoice === OTHER_LANGUAGE && ( + setCustomLanguage(event.target.value)} + placeholder="Language tag, e.g. en-US" + maxLength={20} + /> + )} +
+ +
+ + setLabel(event.target.value)} + placeholder={resolvedLanguage ? describeLanguage(resolvedLanguage) : 'Türkçe'} + maxLength={60} + /> +
+ + {replacesExisting && ( +

+ This version already has a track in that language. Uploading replaces it. +

+ )} +
+ + + + + +
+
+ + ); +}); diff --git a/components/video-page/types.ts b/components/video-page/types.ts index 77ec7bb..fc0552d 100644 --- a/components/video-page/types.ts +++ b/components/video-page/types.ts @@ -35,6 +35,27 @@ export interface VideoAsset { canDelete: boolean; } +/** + * What the player's CC menu needs to know about one track. Our own uploaded tracks and + * the ones a YouTube video brings with it are different things underneath, and the menu + * is the one place that does not have to care. + */ +export interface SubtitleTrackOption { + id: string; + language: string; + label: string; + canDelete: boolean; +} + +export interface Subtitle extends SubtitleTrackOption { + versionId: string; + url: string; + sizeBytes: number; + createdAt: string; + updatedAt: string; + uploadedByUser: { id: string; name: string | null; image: string | null } | null; +} + export interface ApprovalDecision { id: string; approverId: string; diff --git a/lib/project-download.ts b/lib/project-download.ts index 4d11caf..9022ba4 100644 --- a/lib/project-download.ts +++ b/lib/project-download.ts @@ -235,6 +235,12 @@ export type BuildProjectDownloadManifestOptions = { includeAssets?: boolean; }; +/** + * Subtitle tracks are deliberately not in the manifest. They belong to a version rather + * than to a video, and a zip that carried them would need a naming scheme that pairs each + * .vtt with the cut it was timed against. Add them the day that pairing is designed, not + * as a loose file next to the videos. + */ export function buildProjectDownloadManifest( projectName: string, videos: VideoRow[], diff --git a/lib/r2-cleanup.ts b/lib/r2-cleanup.ts index 2cb7df4..8dc604e 100644 --- a/lib/r2-cleanup.ts +++ b/lib/r2-cleanup.ts @@ -3,6 +3,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { db } from '@/lib/db'; import { runWithConcurrency } from '@/lib/async-pool'; import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation'; +import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation'; import { logError } from '@/lib/logger'; /** The path prefix for images served by the upload API. */ @@ -34,7 +35,7 @@ export function mediaUrlToKey(url: string): string | null { return filename ? `images/${filename}` : null; } - return videoProxyPathToObjectKey(url); + return subtitleProxyPathToObjectKey(url) ?? videoProxyPathToObjectKey(url); } /** @@ -82,7 +83,7 @@ export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise { - const [comments, assets, versions] = await Promise.all([ + const [comments, assets, versions, subtitles] = await Promise.all([ db.comment.findMany({ where: { OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }], @@ -101,6 +102,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise where: { videoParentId: videoId, providerId: 'r2' }, select: { originalUrl: true, thumbnailUrl: true }, }), + db.videoSubtitle.findMany({ + where: { version: { videoParentId: videoId } }, + select: { sourceUrl: true }, + }), ]); const urls: string[] = []; comments.forEach((c) => { @@ -114,6 +119,9 @@ export async function collectVideoMediaUrls(videoId: string): Promise if (version.originalUrl) urls.push(version.originalUrl); if (version.thumbnailUrl) urls.push(version.thumbnailUrl); }); + subtitles.forEach((subtitle) => { + if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl); + }); return urls; } @@ -121,7 +129,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise * Collect all media URLs from comments under all videos in a project. */ export async function collectProjectMediaUrls(projectId: string): Promise { - const [comments, assets, versions] = await Promise.all([ + const [comments, assets, versions, subtitles] = await Promise.all([ db.comment.findMany({ where: { OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }], @@ -140,6 +148,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise { @@ -153,6 +165,9 @@ export async function collectProjectMediaUrls(projectId: string): Promise { + if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl); + }); return urls; } @@ -160,7 +175,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise { - const [comments, assets, versions] = await Promise.all([ + const [comments, assets, versions, subtitles] = await Promise.all([ db.comment.findMany({ where: { OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }], @@ -179,6 +194,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise { @@ -192,6 +211,9 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise { + if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl); + }); return urls; } diff --git a/lib/r2-media-proxy.ts b/lib/r2-media-proxy.ts index e27e4f9..8fb020f 100644 --- a/lib/r2-media-proxy.ts +++ b/lib/r2-media-proxy.ts @@ -24,7 +24,7 @@ type ProxyR2MediaOptions = { // call sites gate the file name on a strict pattern first, and a fourth that forgot would // otherwise hand a traversal straight to GetObject. const SAFE_MEDIA_OBJECT_KEY = - /^(?:images|voice|videos)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; + /^(?:images|voice|videos|subtitles)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; export function isSafeR2MediaKey(key: string): boolean { return SAFE_MEDIA_OBJECT_KEY.test(key); diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index b8e7bda..2e29fa6 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -71,6 +71,9 @@ export const RATE_LIMIT_CONFIGS: Record = { 'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute 'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute 'asset-r2-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute + 'subtitle-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute + 'subtitle-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute + 'subtitle-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute // Search — debounced on client but protect against scripted callers search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts index 6888415..f9bbba3 100644 --- a/lib/storage-quota.ts +++ b/lib/storage-quota.ts @@ -132,6 +132,8 @@ export const UPLOAD_RESERVATION_PURPOSES = { R2_VIDEO: 'R2_VIDEO', /** A direct upload to Bunny, where the bytes never pass through us. */ BUNNY: 'BUNNY', + /** A subtitle track, which lands in our own S3-compatible storage whatever hosts the video. */ + SUBTITLE: 'SUBTITLE', } as const; export type UploadReservationPurpose = @@ -147,14 +149,15 @@ class QuotaExceededError extends Error {} * every upload. */ export async function getUserTotalStorageBytes(userId: string): Promise { - const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([ - db.$queryRaw<[{ total: bigint }]>` + const [r2AssetRows, r2VideoRows, subtitleRows, bunnyUserBytes, reservationRows] = + await Promise.all([ + db.$queryRaw<[{ total: bigint }]>` SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total FROM video_assets WHERE "billedUserId" = ${userId} AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO') `, - db.$queryRaw<[{ total: bigint }]>` + db.$queryRaw<[{ total: bigint }]>` SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total FROM video_versions vv INNER JOIN videos v ON v.id = vv."videoParentId" @@ -163,21 +166,27 @@ export async function getUserTotalStorageBytes(userId: string): Promise WHERE w."ownerId" = ${userId} AND vv."providerId" = 'r2' `, - getUserBunnyStorageBytes(userId), - db.$queryRaw<[{ total: bigint }]>` + db.$queryRaw<[{ total: bigint }]>` + SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total + FROM video_subtitles + WHERE "billedUserId" = ${userId} + `, + getUserBunnyStorageBytes(userId), + db.$queryRaw<[{ total: bigint }]>` SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total FROM upload_reservations WHERE "billedUserId" = ${userId} AND "expiresAt" > NOW() `, - ]); + ]); const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0); const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0); + const subtitleBytes = subtitleRows[0]?.total ?? BigInt(0); const bunnyBytes = BigInt(bunnyUserBytes); const reservedBytes = reservationRows[0]?.total ?? BigInt(0); - return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes; + return r2AssetBytes + r2VideoBytes + subtitleBytes + bunnyBytes + reservedBytes; } /** @@ -291,7 +300,15 @@ export async function reserveStorageQuota( WHERE w."ownerId" = ${userId} AND vv."providerId" = 'r2' `; - const r2Bytes = (r2AssetRow?.total ?? BigInt(0)) + (r2VideoRow?.total ?? BigInt(0)); + const [subtitleRow] = await tx.$queryRaw<[{ total: bigint }]>` + SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total + FROM video_subtitles + WHERE "billedUserId" = ${userId} + `; + const r2Bytes = + (r2AssetRow?.total ?? BigInt(0)) + + (r2VideoRow?.total ?? BigInt(0)) + + (subtitleRow?.total ?? BigInt(0)); // Read active (non-expired) reservations under the same lock const [resRow] = await tx.$queryRaw<[{ total: bigint }]>` diff --git a/lib/subtitle-validation.ts b/lib/subtitle-validation.ts new file mode 100644 index 0000000..e9ecdaf --- /dev/null +++ b/lib/subtitle-validation.ts @@ -0,0 +1,272 @@ +/** + * Subtitle uploads are normalised before they are stored: whatever the user hands us, + * SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file. + * Anything we did not understand is dropped rather than passed through, so the file the + * player fetches contains cues and nothing else. That is what makes it safe to serve a + * user-supplied text file from our own origin. + */ + +/** Uploaded subtitle files are text. Two megabytes is a feature-length film with room to spare. */ +export const MAX_SUBTITLE_FILE_SIZE = 2 * 1024 * 1024; + +/** Ceiling on the normalised output, so a pathological input cannot be stored. */ +export const MAX_NORMALIZED_SUBTITLE_SIZE = 1024 * 1024; + +export const MAX_SUBTITLE_CUES = 5000; + +/** Longer than this and it is not a subtitle, it is a document being smuggled in. */ +const MAX_CUE_TEXT_LENGTH = 500; + +export const ALLOWED_SUBTITLE_EXTENSIONS = ['vtt', 'srt'] as const; + +export const SUBTITLE_OBJECT_KEY_PREFIX = 'subtitles/'; + +export const SUBTITLE_PROXY_PREFIX = '/api/upload/subtitle/'; + +/** The only shape a subtitle URL may take once it has been through our upload API. */ +export const SAFE_SUBTITLE_PROXY_PATH = + /^\/api\/upload\/subtitle\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i; + +export const SAFE_SUBTITLE_FILENAME = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i; + +export const SUBTITLE_CONTENT_TYPE = 'text/vtt; charset=utf-8'; + +/** Room for a label a human typed, not for a paragraph. */ +const MAX_SUBTITLE_LABEL_LENGTH = 60; + +/** + * BCP-47, narrowed: a primary subtag plus optional subtags. Wide enough for `tr`, + * `en-US` and `zh-Hant-TW`, narrow enough that the value is safe in an HTML attribute + * and in a unique index. + */ +const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,3}$/i; + +export type SubtitleCue = { + /** Seconds from the start of the video. */ + start: number; + end: number; + text: string; +}; + +export type SubtitleNormalizeResult = + | { ok: true; vtt: string; cueCount: number } + | { ok: false; error: string }; + +/** + * Cue text may carry a small amount of WebVTT markup. Everything outside this list is + * removed: the browser's VTT parser does not execute scripts, but a file that only ever + * contains tags we recognise is one less thing to reason about. + */ +const ALLOWED_CUE_TAGS = [ + /^<\/?[biu]>$/i, + /^<\/?ruby>$/i, + /^<\/?rt>$/i, + /^<\/?c(?:\.[\w-]+)*>$/i, + /^]{1,80})?>$/i, + /^<\/v>$/i, + /^<\d{1,3}:\d{2}(?::\d{2})?\.\d{3}>$/, +]; + +export function getSubtitleExtension(fileName: string): 'vtt' | 'srt' | null { + const ext = fileName.split('.').pop()?.toLowerCase(); + if (ext === 'vtt' || ext === 'srt') return ext; + return null; +} + +/** + * Normalise a language tag for storage. Kept lowercase so the unique index on + * (version, language) treats `TR` and `tr` as the same track. + */ +export function normalizeSubtitleLanguage(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed || !LANGUAGE_TAG.test(trimmed)) return null; + return trimmed.toLowerCase(); +} + +export function sanitizeSubtitleLabel(value: unknown, fallback: string): string { + const raw = typeof value === 'string' ? value : ''; + const normalized = raw + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!normalized) return fallback; + return normalized.slice(0, MAX_SUBTITLE_LABEL_LENGTH); +} + +/** + * Subtitle files written by desktop editors are routinely not UTF-8. A Turkish SRT saved + * out of a Windows tool is usually windows-1254, and rejecting it outright would send the + * user off to convert a file we can decode ourselves. UTF-8 is tried strictly first so a + * valid file is never mangled by a legacy codepage. + */ +export function decodeSubtitleBuffer(buffer: Uint8Array): string | null { + for (const encoding of ['utf-8', 'windows-1254', 'windows-1252']) { + try { + const decoded = new TextDecoder(encoding, { fatal: true }).decode(buffer); + return decoded.replace(/^\uFEFF/, ''); + } catch { + // Wrong encoding, or one this runtime's ICU build does not carry. Try the next. + } + } + return null; +} + +function parseTimestamp(value: string): number | null { + const match = /^(?:(\d{1,3}):)?([0-5]?\d):([0-5]?\d)[.,](\d{1,3})$/.exec(value.trim()); + if (!match) return null; + const hours = match[1] ? Number(match[1]) : 0; + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const millis = Number(match[4].padEnd(3, '0')); + return hours * 3600 + minutes * 60 + seconds + millis / 1000; +} + +function formatTimestamp(seconds: number): string { + const clamped = Math.max(0, seconds); + const totalMillis = Math.round(clamped * 1000); + const hours = Math.floor(totalMillis / 3_600_000); + const minutes = Math.floor((totalMillis % 3_600_000) / 60_000); + const secs = Math.floor((totalMillis % 60_000) / 1000); + const millis = totalMillis % 1000; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; +} + +function parseTimingLine(line: string): { start: number; end: number } | null { + const separatorIndex = line.indexOf('-->'); + if (separatorIndex === -1) return null; + const start = parseTimestamp(line.slice(0, separatorIndex)); + // Anything after the end timestamp is a cue setting (position, align, line). They are + // dropped: the player positions cues itself so its own control bar does not cover them. + const rest = line.slice(separatorIndex + 3).trim(); + const end = parseTimestamp(rest.split(/\s+/)[0] ?? ''); + if (start === null || end === null) return null; + return { start, end }; +} + +function sanitizeCueLine(line: string): string { + return ( + line + // ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT + // parser renders them as literal text, which is never what the author meant. + .replace(/\{\\[^}]*\}/g, '') + .replace(/<[^<>]*>/g, (tag) => (ALLOWED_CUE_TAGS.some((re) => re.test(tag)) ? tag : '')) + // A cue text line containing an arrow would be read back as a timing line and split + // the cue in two. The entity is what the WebVTT parser expects for a literal `>`. + .replace(/-->/g, '-->') + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') + .trimEnd() + ); +} + +/** + * Parse SRT or WebVTT into cues. Unknown blocks (NOTE, STYLE, REGION, cue identifiers, + * SRT sequence numbers) are skipped rather than carried over. + */ +export function parseSubtitleCues(input: string): SubtitleCue[] { + const lines = input.replace(/\r\n?/g, '\n').split('\n'); + const cues: SubtitleCue[] = []; + + let index = 0; + // A STYLE or REGION block runs until the next blank line and may itself contain no + // timing, so it is skipped wholesale rather than line by line. + while (index < lines.length) { + const line = lines[index]; + const trimmed = line.trim(); + + if (!trimmed) { + index += 1; + continue; + } + + if (/^(?:WEBVTT|NOTE|STYLE|REGION)\b/.test(trimmed)) { + index += 1; + while (index < lines.length && lines[index].trim()) index += 1; + continue; + } + + // A cue may be preceded by an identifier line (an SRT sequence number, or a VTT cue + // id). The timing is then on the following line. + let timing = parseTimingLine(trimmed); + if (!timing) { + const next = lines[index + 1]?.trim(); + if (!next) { + index += 1; + continue; + } + timing = parseTimingLine(next); + if (!timing) { + index += 1; + continue; + } + index += 1; + } + index += 1; + + const textLines: string[] = []; + while (index < lines.length && lines[index].trim()) { + const sanitized = sanitizeCueLine(lines[index]); + if (sanitized.trim()) textLines.push(sanitized); + index += 1; + } + + if (timing.end <= timing.start) continue; + const text = textLines.join('\n').slice(0, MAX_CUE_TEXT_LENGTH).trim(); + if (!text) continue; + + cues.push({ start: timing.start, end: timing.end, text }); + if (cues.length >= MAX_SUBTITLE_CUES) break; + } + + return cues; +} + +export function serializeWebVtt(cues: SubtitleCue[]): string { + const body = cues + .map((cue) => `${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}`) + .join('\n\n'); + return `WEBVTT\n\n${body}\n`; +} + +/** + * The whole pipeline: bytes in, a canonical WebVTT string out, or a message explaining + * what is wrong with the file in terms the person who uploaded it can act on. + */ +export function normalizeSubtitleFile(buffer: Uint8Array): SubtitleNormalizeResult { + if (buffer.byteLength === 0) { + return { ok: false, error: 'Subtitle file is empty' }; + } + + const decoded = decodeSubtitleBuffer(buffer); + if (decoded === null) { + return { ok: false, error: 'Could not read the subtitle file. Save it as UTF-8 and retry.' }; + } + + const cues = parseSubtitleCues(decoded); + if (cues.length === 0) { + return { ok: false, error: 'No subtitle cues found. Upload a valid .srt or .vtt file.' }; + } + + const vtt = serializeWebVtt(cues); + if (Buffer.byteLength(vtt, 'utf8') > MAX_NORMALIZED_SUBTITLE_SIZE) { + return { ok: false, error: 'Subtitle file is too large after conversion' }; + } + + return { ok: true, vtt, cueCount: cues.length }; +} + +export function subtitleFileNameToProxyUrl(fileName: string): string { + return `${SUBTITLE_PROXY_PREFIX}${fileName}`; +} + +export function extractSubtitleFileNameFromProxyUrl(url: string): string | null { + if (!SAFE_SUBTITLE_PROXY_PATH.test(url)) return null; + return url.slice(SUBTITLE_PROXY_PREFIX.length) || null; +} + +export function subtitleProxyPathToObjectKey(url: string): string | null { + const fileName = extractSubtitleFileNameFromProxyUrl(url); + if (!fileName) return null; + return `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`; +} diff --git a/prisma/migrations/20260822120000_add_video_subtitles/migration.sql b/prisma/migrations/20260822120000_add_video_subtitles/migration.sql new file mode 100644 index 0000000..d4b52ea --- /dev/null +++ b/prisma/migrations/20260822120000_add_video_subtitles/migration.sql @@ -0,0 +1,39 @@ +-- Subtitle tracks hang off a version, not off the video: re-editing a cut shifts +-- every cue, so a track attached to the parent would be wrong for every version +-- but the one it was written against. +CREATE TABLE "video_subtitles" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "language" TEXT NOT NULL, + "label" TEXT NOT NULL, + "sourceUrl" TEXT NOT NULL, + "size_bytes" BIGINT NOT NULL DEFAULT 0, + "billedUserId" TEXT NOT NULL, + "uploadedByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "video_subtitles_pkey" PRIMARY KEY ("id") +); + +-- One stored object belongs to exactly one row, so the reference check that runs +-- before an object delete cannot be fooled by a second row pointing at the file. +CREATE UNIQUE INDEX "video_subtitles_sourceUrl_key" ON "video_subtitles"("sourceUrl"); + +-- Re-uploading a language replaces the track rather than stacking a second one, +-- which would leave the player with two tracks labelled the same. +CREATE UNIQUE INDEX "video_subtitles_versionId_language_key" ON "video_subtitles"("versionId", "language"); + +CREATE INDEX "video_subtitles_versionId_idx" ON "video_subtitles"("versionId"); + +-- The storage quota sums this column per billed user on every upload. +CREATE INDEX "video_subtitles_billedUserId_idx" ON "video_subtitles"("billedUserId"); + +ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_versionId_fkey" + FOREIGN KEY ("versionId") REFERENCES "video_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_billedUserId_fkey" + FOREIGN KEY ("billedUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_uploadedByUserId_fkey" + FOREIGN KEY ("uploadedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c6021b..adc4f2d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,8 @@ model User { comments Comment[] uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy") billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo") + uploadedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleUploadedBy") + billedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleBilledTo") projectMemberships ProjectMember[] notificationSetting NotificationSetting? watchProgress WatchProgress[] @@ -383,6 +385,7 @@ model VideoVersion { comments Comment[] watchProgress WatchProgress[] approvalRequests ApprovalRequest[] + subtitles VideoSubtitle[] @@unique([videoParentId, versionNumber]) @@index([videoParentId]) @@ -418,6 +421,34 @@ model VideoAsset { @@map("video_assets") } +/// A subtitle track for one cut. Timings belong to a version rather than to the +/// video: re-editing shifts every cue, so a track attached to the parent would be +/// wrong for every version but the one it was written against. +model VideoSubtitle { + id String @id @default(cuid()) + versionId String + version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + /// BCP-47 tag, lowercased primary subtag, e.g. `tr`, `en-US`. + language String + label String + /// Always an /api/upload/subtitle/.vtt path. The file itself lives in + /// S3-compatible storage whatever the video's own provider is, so a Bunny-hosted + /// video and an R2-hosted one take the same path through the player. + sourceUrl String @unique + sizeBytes BigInt @default(0) @map("size_bytes") + billedUserId String + billedUser User @relation("VideoSubtitleBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade) + uploadedByUserId String? + uploadedByUser User? @relation("VideoSubtitleUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([versionId, language]) + @@index([versionId]) + @@index([billedUserId]) + @@map("video_subtitles") +} + model Comment { id String @id @default(cuid()) diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts index 6be06e2..e9f9f78 100644 --- a/tests/api/auth-matrix.test.ts +++ b/tests/api/auth-matrix.test.ts @@ -82,6 +82,7 @@ import * as uploadAudioFileRoute from '@/app/api/upload/audio/[filename]/route'; import * as uploadAudioRoute from '@/app/api/upload/audio/route'; import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route'; import * as uploadImageRoute from '@/app/api/upload/image/route'; +import * as uploadSubtitleFileRoute from '@/app/api/upload/subtitle/[filename]/route'; import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route'; import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route'; import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route'; @@ -92,6 +93,8 @@ import * as assetRoute from '@/app/api/videos/[videoId]/assets/[assetId]/route'; import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route'; import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route'; import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route'; +import * as subtitleRoute from '@/app/api/videos/[videoId]/subtitles/[subtitleId]/route'; +import * as subtitlesRoute from '@/app/api/videos/[videoId]/subtitles/route'; import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route'; import * as watchRoute from '@/app/api/watch/[videoId]/route'; import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route'; @@ -145,7 +148,7 @@ vi.mock('@/lib/r2', async (importOriginal) => { // The count guard // --------------------------------------------------------------------------- // Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES. -const EXPECTED_ROUTE_MODULE_COUNT = 63; +const EXPECTED_ROUTE_MODULE_COUNT = 66; /** * Routes that are public by design, and why. Everything else must reject an @@ -203,6 +206,7 @@ const PUBLIC_ROUTES: ReadonlyMap = new Map([ const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png'; const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm'; const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4'; +const SUBTITLE_FILENAME = '44444444-4444-4444-8444-444444444444.vtt'; interface Fixtures { userId: string; @@ -217,6 +221,7 @@ interface Fixtures { versionId: string; commentId: string; assetId: string; + subtitleId: string; approvalRequestId: string; feedbackId: string; } @@ -280,6 +285,20 @@ async function seedFixtures(): Promise { sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`, }); + // A real track, so /api/upload/subtitle/[filename] resolves to a row and its + // refusal comes from the access check rather than from the reverse lookup. + const subtitle = await db.videoSubtitle.create({ + data: { + versionId: version.id, + language: 'tr', + label: 'Türkçe', + sourceUrl: `/api/upload/subtitle/${SUBTITLE_FILENAME}`, + sizeBytes: BigInt(64), + billedUserId: owner.id, + uploadedByUserId: owner.id, + }, + }); + await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' }); const approvalRequest = await createApprovalRequest({ @@ -310,6 +329,7 @@ async function seedFixtures(): Promise { versionId: version.id, commentId: comment.id, assetId: asset.id, + subtitleId: subtitle.id, approvalRequestId: approvalRequest.id, feedbackId: feedback.id, }; @@ -595,6 +615,12 @@ const ROUTE_CASES: readonly RouteCase[] = [ // and constructing a Request from a FormData does not set one. headers: { 'content-length': '2048' }, }, + { + file: 'upload/subtitle/[filename]/route.ts', + module: uploadSubtitleFileRoute, + url: () => `/api/upload/subtitle/${SUBTITLE_FILENAME}`, + params: () => ({ filename: SUBTITLE_FILENAME }), + }, { file: 'upload/video/[filename]/route.ts', module: uploadVideoFileRoute, @@ -669,6 +695,27 @@ const ROUTE_CASES: readonly RouteCase[] = [ // exact-status coverage lives in tests/api/assets-authz.test.ts. body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` }, }, + { + file: 'videos/[videoId]/subtitles/[subtitleId]/route.ts', + module: subtitleRoute, + url: (f) => `/api/videos/${f.videoId}/subtitles/${f.subtitleId}`, + params: (f) => ({ videoId: f.videoId, subtitleId: f.subtitleId }), + }, + { + file: 'videos/[videoId]/subtitles/route.ts', + module: subtitlesRoute, + url: (f) => `/api/videos/${f.videoId}/subtitles`, + params: (f) => ({ videoId: f.videoId }), + // POST sizes the body before it does anything else, and a Request built from + // a FormData carries no Content-Length, so without this the anonymous call + // would stop on a 400 above the guard rather than on the guard. + headers: { 'content-length': '4096' }, + rawBody: () => { + const form = new FormData(); + form.append('subtitle', new File(['WEBVTT'], 'anon.vtt', { type: 'text/vtt' })); + return form; + }, + }, { file: 'watch/[videoId]/progress/route.ts', module: watchProgressRoute, diff --git a/tests/api/subtitles.test.ts b/tests/api/subtitles.test.ts new file mode 100644 index 0000000..7333e7f --- /dev/null +++ b/tests/api/subtitles.test.ts @@ -0,0 +1,514 @@ +// The subtitle family: list, upload, delete, and the proxy that serves the stored +// WebVTT back to the player. +// +// Two properties are worth pinning down here rather than in the unit suite. +// +// - The upload path is editor-only. Every other write under /api/videos/[videoId] +// is open to anyone who may comment, guests included, so a subtitle route that +// reached for `canUploadAssets` instead of `canManageAssets` would look correct +// next to its neighbours and would let a share-link viewer rewrite the captions +// on a delivered cut. +// +// - What lands in storage is the normalised file, never the bytes that were +// uploaded. The assertions below read the PutObject command rather than trusting +// the 201. +// +// tests/setup/api.ts stubs the named helpers in `@/lib/r2` but leaves `r2Client` +// real, and the real one throws on first use, so it is replaced with a recorder. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '@/lib/db'; +import { + GET as listSubtitles, + POST as uploadSubtitle, +} from '@/app/api/videos/[videoId]/subtitles/route'; +import { DELETE as deleteSubtitle } from '@/app/api/videos/[videoId]/subtitles/[subtitleId]/route'; +import { GET as serveSubtitle } from '@/app/api/upload/subtitle/[filename]/route'; +import { apiRequest, callRoute, readData, readError } from '../helpers/request'; +import { signedInAs, signedOut } from '../helpers/session'; +import { addProjectMember, createUser, seedVersion } from '../factories'; + +const r2 = vi.hoisted(() => ({ + bucket: 'openframe-subtitle-test-bucket', + puts: [] as Array<{ key: string; body: string; contentType: string }>, + deletedKeys: [] as string[], + gets: [] as string[], +})); + +vi.mock('@/lib/r2', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + R2_BUCKET_NAME: r2.bucket, + r2Client: { + send: async (command: { + constructor: { name: string }; + input?: { Key?: string; Body?: Buffer; ContentType?: string }; + }) => { + const key = command.input?.Key ?? ''; + switch (command.constructor.name) { + case 'PutObjectCommand': + r2.puts.push({ + key, + body: Buffer.from(command.input?.Body ?? Buffer.alloc(0)).toString('utf8'), + contentType: command.input?.ContentType ?? '', + }); + return {}; + case 'DeleteObjectCommand': + r2.deletedKeys.push(key); + return {}; + case 'GetObjectCommand': { + r2.gets.push(key); + const stored = r2.puts.find((put) => put.key === key); + if (!stored) { + const error = new Error('NoSuchKey'); + error.name = 'NoSuchKey'; + throw error; + } + return { + Body: new Response(stored.body).body, + ContentType: stored.contentType, + ContentLength: Buffer.byteLength(stored.body), + }; + } + default: + return {}; + } + }, + }, + }; +}); + +const SRT_FILE = ['1', '00:00:01,000 --> 00:00:02,500', 'Merhaba', '', ''].join('\n'); +const NORMALIZED_VTT = 'WEBVTT\n\n00:00:01.000 --> 00:00:02.500\nMerhaba\n'; +const SUBTITLE_KEY = /^subtitles\/[0-9a-f-]{36}\.vtt$/; + +beforeEach(() => { + r2.puts.length = 0; + r2.deletedKeys.length = 0; + r2.gets.length = 0; +}); + +function subtitlesUrl(videoId: string): string { + return `/api/videos/${videoId}/subtitles`; +} + +function subtitleForm(input: { + content?: string; + fileName?: string; + versionId: string; + language?: string; + label?: string; +}): FormData { + const form = new FormData(); + form.append( + 'subtitle', + new File([input.content ?? SRT_FILE], input.fileName ?? 'cut.tr.srt', { type: 'text/plain' }) + ); + form.append('versionId', input.versionId); + if (input.language !== undefined) form.append('language', input.language); + if (input.label !== undefined) form.append('label', input.label); + return form; +} + +function uploadRequest(videoId: string, form: FormData) { + return apiRequest(subtitlesUrl(videoId), { + rawBody: form, + // Constructing a Request from a FormData sets no Content-Length, and the route + // refuses a body it cannot size before it reads one. + headers: { 'content-length': '4096' }, + }); +} + +/** An editor-owned bunny version with one Turkish track already uploaded. */ +async function seedSubtitledVersion() { + const scenario = await seedVersion({ providerId: 'bunny' }); + signedInAs(scenario.owner); + const response = await callRoute( + uploadSubtitle, + uploadRequest( + scenario.video.id, + subtitleForm({ versionId: scenario.version.id, language: 'tr', label: 'Türkçe' }) + ), + { videoId: scenario.video.id } + ); + expect(response.status).toBe(201); + const subtitle = await readData<{ id: string; url: string }>(response); + return { ...scenario, subtitle }; +} + +// --------------------------------------------------------------------------- +// POST /api/videos/[videoId]/subtitles +// --------------------------------------------------------------------------- +describe('POST /api/videos/[videoId]/subtitles', () => { + it('stores the normalised WebVTT rather than the uploaded SRT', async () => { + const scenario = await seedVersion({ providerId: 'bunny' }); + signedInAs(scenario.owner); + + const response = await callRoute( + uploadSubtitle, + uploadRequest( + scenario.video.id, + subtitleForm({ versionId: scenario.version.id, language: 'TR', label: ' Türkçe ' }) + ), + { videoId: scenario.video.id } + ); + + expect(response.status).toBe(201); + const created = await readData<{ language: string; label: string; url: string }>(response); + expect(created.language).toBe('tr'); + expect(created.label).toBe('Türkçe'); + expect(created.url).toMatch(/^\/api\/upload\/subtitle\/[0-9a-f-]{36}\.vtt$/); + + expect(r2.puts).toHaveLength(1); + expect(r2.puts[0].key).toMatch(SUBTITLE_KEY); + expect(r2.puts[0].body).toBe(NORMALIZED_VTT); + expect(r2.puts[0].contentType).toBe('text/vtt; charset=utf-8'); + + const row = await db.videoSubtitle.findFirstOrThrow({ + where: { versionId: scenario.version.id }, + }); + expect(row.billedUserId).toBe(scenario.owner.id); + expect(row.uploadedByUserId).toBe(scenario.owner.id); + expect(Number(row.sizeBytes)).toBe(Buffer.byteLength(NORMALIZED_VTT)); + }); + + it('leaves no upload reservation behind once the row is committed', async () => { + const scenario = await seedVersion({ providerId: 'bunny' }); + signedInAs(scenario.owner); + + await callRoute( + uploadSubtitle, + uploadRequest( + scenario.video.id, + subtitleForm({ versionId: scenario.version.id, language: 'tr' }) + ), + { videoId: scenario.video.id } + ); + + expect(await db.uploadReservation.count()).toBe(0); + }); + + it('replaces the track for a language instead of stacking a second one', async () => { + const scenario = await seedSubtitledVersion(); + const firstKey = r2.puts[0].key; + + const response = await callRoute( + uploadSubtitle, + uploadRequest( + scenario.video.id, + subtitleForm({ + versionId: scenario.version.id, + language: 'tr', + label: 'Türkçe düzeltme', + content: ['1', '00:00:04,000 --> 00:00:05,000', 'Düzeltildi', '', ''].join('\n'), + }) + ), + { videoId: scenario.video.id } + ); + + expect(response.status).toBe(201); + const rows = await db.videoSubtitle.findMany({ where: { versionId: scenario.version.id } }); + expect(rows).toHaveLength(1); + expect(rows[0].label).toBe('Türkçe düzeltme'); + // The object the replaced row pointed at is gone, so it cannot outlive its row. + expect(r2.deletedKeys).toEqual([firstKey]); + }); + + it('keeps a second language alongside the first', async () => { + const scenario = await seedSubtitledVersion(); + + await callRoute( + uploadSubtitle, + uploadRequest( + scenario.video.id, + subtitleForm({ versionId: scenario.version.id, language: 'en', fileName: 'cut.en.srt' }) + ), + { videoId: scenario.video.id } + ); + + const rows = await db.videoSubtitle.findMany({ + where: { versionId: scenario.version.id }, + orderBy: { language: 'asc' }, + }); + expect(rows.map((row) => row.language)).toEqual(['en', 'tr']); + }); + + it('refuses a file with no cues and stores nothing', async () => { + const scenario = await seedVersion({ providerId: 'bunny' }); + signedInAs(scenario.owner); + + const response = await callRoute( + uploadSubtitle, + uploadRequest( + scenario.video.id, + subtitleForm({ + versionId: scenario.version.id, + language: 'tr', + content: 'just some prose\nand more of it\n', + }) + ), + { videoId: scenario.video.id } + ); + + expect(response.status).toBe(400); + expect(r2.puts).toHaveLength(0); + expect(await db.videoSubtitle.count()).toBe(0); + }); + + it('refuses a file that is not a subtitle by extension', async () => { + const scenario = await seedVersion({ providerId: 'bunny' }); + signedInAs(scenario.owner); + + const response = await callRoute( + uploadSubtitle, + uploadRequest( + scenario.video.id, + subtitleForm({ versionId: scenario.version.id, language: 'tr', fileName: 'payload.html' }) + ), + { videoId: scenario.video.id } + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toBe('Subtitle must be a .srt or .vtt file'); + }); + + it('refuses a language that is not a tag', async () => { + const scenario = await seedVersion({ providerId: 'bunny' }); + signedInAs(scenario.owner); + + const response = await callRoute( + uploadSubtitle, + uploadRequest( + scenario.video.id, + subtitleForm({ versionId: scenario.version.id, language: '{\\an8}', ''].join( + '\n' + ) + ); + expect(cues[0].text).toBe('tiltalert(1)'); + }); + + it('neutralises an arrow in cue text so the file cannot be re-split', () => { + const cues = parseSubtitleCues(['00:00:01,000 --> 00:00:02,000', 'a --> b', ''].join('\n')); + expect(cues[0].text).toBe('a --> b'); + expect(parseSubtitleCues(serializeWebVtt(cues))).toHaveLength(1); + }); + + it('stops at the cue ceiling', () => { + const lines: string[] = []; + for (let index = 0; index < MAX_SUBTITLE_CUES + 10; index += 1) { + lines.push(`00:00:0${index % 9}.000 --> 00:00:0${(index % 9) + 1}.000`, `line ${index}`, ''); + } + expect(parseSubtitleCues(lines.join('\n'))).toHaveLength(MAX_SUBTITLE_CUES); + }); +}); + +describe('normalizeSubtitleFile', () => { + it('converts SRT to a canonical WebVTT document', () => { + const result = normalizeSubtitleFile( + encode('1\r\n00:00:01,500 --> 00:00:02,000\r\nMerhaba\r\n\r\n') + ); + expect(result).toEqual({ + ok: true, + cueCount: 1, + vtt: 'WEBVTT\n\n00:00:01.500 --> 00:00:02.000\nMerhaba\n', + }); + }); + + it('refuses an empty file', () => { + const result = normalizeSubtitleFile(new Uint8Array()); + expect(result.ok).toBe(false); + }); + + it('refuses a file with no cues rather than storing an empty track', () => { + const result = normalizeSubtitleFile(encode('this is just prose\nand more prose\n')); + expect(result).toEqual({ + ok: false, + error: 'No subtitle cues found. Upload a valid .srt or .vtt file.', + }); + }); +}); + +describe('subtitle proxy paths', () => { + it('only recognises a uuid .vtt path', () => { + expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.vtt`)).toBe(true); + expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.srt`)).toBe(false); + expect(SAFE_SUBTITLE_PROXY_PATH.test('/api/upload/subtitle/../../etc/passwd')).toBe(false); + }); + + it('maps a proxy path to its object key and refuses anything else', () => { + expect(subtitleProxyPathToObjectKey(`/api/upload/subtitle/${UUID}.vtt`)).toBe( + `subtitles/${UUID}.vtt` + ); + expect(subtitleProxyPathToObjectKey(`/api/upload/image/${UUID}.png`)).toBeNull(); + }); +}); diff --git a/types/youtube.d.ts b/types/youtube.d.ts index fb84c0c..6e56151 100644 --- a/types/youtube.d.ts +++ b/types/youtube.d.ts @@ -25,6 +25,16 @@ declare namespace YT { getPlaybackRate(): number; getAvailablePlaybackRates(): number[]; destroy(): void; + + // The module API is undocumented but is the only way to drive captions on a + // player embedded with controls=0, where YouTube's own CC button is hidden. + // `loadModule('captions')` turns them on, `unloadModule` turns them off, and + // `getOption('captions', 'tracklist')` answers only once the module has loaded + // and announced itself through onApiChange. + loadModule(moduleName: string): void; + unloadModule(moduleName: string): void; + setOption(module: string, option: string, value: unknown): void; + getOption(module: string, option: string): T | undefined; } interface PlayerOptions { From a709ca8544ae00deea21845b721cb1086da63a96 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 22 Aug 2026 08:01:12 +0300 Subject: [PATCH 2/2] fix(subtitles): escape a rejected cue tag instead of deleting it Deleting a tag whole is what lets a filter like this be reassembled around: strip the `` out of `ipt>` and the two halves close up into a tag nobody wrote. The leftovers are escaped one character at a time instead, which also covers `-->` in cue text without a second multi-character replacement. Both are what CodeQL flagged on the branch, js/incomplete-multi-character-sanitization and js/bad-tag-filter. Neither was reachable as an injection, because the file is served as text/vtt and a cue is parsed by the WebVTT cue-text parser rather than as HTML, but a sanitiser that cannot be reassembled around is the cheaper thing to own. --- lib/subtitle-validation.ts | 52 ++++++++++++++++------ tests/unit/lib/subtitle-validation.test.ts | 10 +++++ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/lib/subtitle-validation.ts b/lib/subtitle-validation.ts index e9ecdaf..f74c080 100644 --- a/lib/subtitle-validation.ts +++ b/lib/subtitle-validation.ts @@ -145,19 +145,39 @@ function parseTimingLine(line: string): { start: number; end: number } | null { return { start, end }; } +const CUE_TAG = /<[^<>]*>/g; + +/** + * Angle brackets outside a recognised tag are escaped one character at a time rather than + * the offending tag being deleted whole. Deleting is what lets a filter like this be + * reassembled around: strip the `` out of `ipt>` and the two halves close up + * into a tag that was never written. Nothing closes up when the leftovers are escaped + * instead, and the same escaping takes care of `-->`, which would otherwise be read back + * as a timing line and split the cue in two. `&` is left alone so a file that already + * spells its entities properly keeps them. + */ +function escapeCueText(text: string): string { + return text.replace(//g, '>'); +} + function sanitizeCueLine(line: string): string { - return ( - line - // ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT - // parser renders them as literal text, which is never what the author meant. - .replace(/\{\\[^}]*\}/g, '') - .replace(/<[^<>]*>/g, (tag) => (ALLOWED_CUE_TAGS.some((re) => re.test(tag)) ? tag : '')) - // A cue text line containing an arrow would be read back as a timing line and split - // the cue in two. The entity is what the WebVTT parser expects for a literal `>`. - .replace(/-->/g, '-->') - .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') - .trimEnd() - ); + // ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT parser + // renders them as literal text, which is never what the author meant. + const withoutOverrides = line.replace(/\{\\[^}]*\}/g, ''); + + let sanitized = ''; + let cursor = 0; + CUE_TAG.lastIndex = 0; + for (let match = CUE_TAG.exec(withoutOverrides); match; match = CUE_TAG.exec(withoutOverrides)) { + sanitized += escapeCueText(withoutOverrides.slice(cursor, match.index)); + if (ALLOWED_CUE_TAGS.some((allowed) => allowed.test(match[0]))) { + sanitized += match[0]; + } + cursor = match.index + match[0].length; + } + sanitized += escapeCueText(withoutOverrides.slice(cursor)); + + return sanitized.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trimEnd(); } /** @@ -212,7 +232,13 @@ export function parseSubtitleCues(input: string): SubtitleCue[] { } if (timing.end <= timing.start) continue; - const text = textLines.join('\n').slice(0, MAX_CUE_TEXT_LENGTH).trim(); + // The cap can land inside an escape the sanitiser wrote, so a dangling `<` tail is + // trimmed rather than left for the parser to render as text. + const text = textLines + .join('\n') + .slice(0, MAX_CUE_TEXT_LENGTH) + .replace(/&[a-z]{0,5}$/i, '') + .trim(); if (!text) continue; cues.push({ start: timing.start, end: timing.end, text }); diff --git a/tests/unit/lib/subtitle-validation.test.ts b/tests/unit/lib/subtitle-validation.test.ts index c1600bf..0f26892 100644 --- a/tests/unit/lib/subtitle-validation.test.ts +++ b/tests/unit/lib/subtitle-validation.test.ts @@ -144,6 +144,16 @@ describe('parseSubtitleCues', () => { expect(cues[0].text).toBe('tiltalert(1)'); }); + it('escapes the leftovers of a rejected tag so it cannot be reassembled', () => { + // Deleting `` out of the middle would close the two halves into a `