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..f74c080 --- /dev/null +++ b/lib/subtitle-validation.ts @@ -0,0 +1,298 @@ +/** + * 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 }; +} + +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 { + // 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(); +} + +/** + * 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; + // 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 }); + 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('escapes the leftovers of a rejected tag so it cannot be reassembled', () => { + // Deleting `` out of the middle would close the two halves into a `