16 Commits
Author SHA1 Message Date
Yusuf İpek 79bba5e7a1 Merge pull request #74 from yusufipk/claude/compress-video-landing-page-08cc57
feat(landing): replace the hero image with the flow video
2026-09-01 15:28:59 +03:00
Yusuf İpek ab03f7c378 Merge pull request #73 from yusufipk/fix/download-unload-guard
feat(billing): defer the cardless trial for invited collaborators
2026-09-01 15:24:50 +03:00
yusufipek 43cc54c0ae feat(landing): drop the toolbar overlay and gradient from the hero video 2026-09-01 15:23:17 +03:00
yusufipek 54e99cb4ab test(api): register billing/trial in the auth matrix 2026-09-01 15:17:51 +03:00
yusufipek 5f061d1b09 feat(landing): replace the hero image with the flow video 2026-09-01 15:12:40 +03:00
yusufipek 59a64141ee chore(lint): ignore .claude worktree checkouts in eslint
A worktree parked under .claude/worktrees is a separate checkout; eslint
scanning it fails bun run check on files outside this tree.
2026-09-01 15:09:53 +03:00
yusufipek 4b3c3934dd feat(billing): defer the cardless trial for invited collaborators
An account that signs up through an invitation works on the inviter's
billing, so handing it a trial at signup spent its only trial before it
owned anything. The trial is now held back for collaborators and claimed
only explicitly: a Start Free Trial button on the new-workspace and
billing screens calls the new POST /api/billing/trial endpoint, which
grants the once-per-account trial atomically. Nothing starts the clock
as a side effect, and pure collaborators no longer see a trial-ending
banner about work that is not theirs.
2026-09-01 15:07:54 +03:00
Yusuf İpek c5c9da1e30 Merge pull request #70 from yusufipk/claude/landing-page-removal-c07551
feat(landing): remove the Fair Source badge from the hero
2026-08-25 07:44:00 +03:00
yusufipek 07a6bfbef4 feat(landing): remove the Fair Source badge from the hero 2026-08-25 07:37:30 +03:00
Yusuf İpek d894eeb0e4 Merge pull request #69 from yusufipk/fix/download-unload-guard
fix(download): warn before the tab closes mid-download
2026-08-22 13:02:32 +03:00
yusufipek cba8163286 fix(download): warn before the tab closes mid-download
Bunny and direct downloads are pulled through fetch() so we can save them
under our own filename. The browser does not treat that as a download, so
closing the tab discarded everything received so far without a word.

Register a reference counted beforeunload guard while those transfers are
in flight, and while a project manifest is being pulled file by file.
Browser owned downloads (same-origin proxy, the over-10GB fallback, asset
downloads) survive a tab close on their own and stay unguarded.
2026-08-22 12:50:45 +03:00
Yusuf İpek 74e4b4353e Merge pull request #68 from yusufipk/feat/version-subtitles
feat(player): let editors upload subtitles for a version
2026-08-22 08:16:35 +03:00
yusufipek a709ca8544 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 `<b>` out of `<scr<b>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.
2026-08-22 08:01:12 +03:00
yusufipek d981d98cf5 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.
2026-08-22 07:51:46 +03:00
Yusuf İpek 1f3c6b3f1e Merge pull request #67 from yusufipk/chore/version-0-1-1
chore(release): set the package version to 0.1.1
2026-08-20 17:21:40 +03:00
yusufipek 6f575e48bf chore(release): set the package version to 0.1.1
The tag and the manifest had drifted: v0.1.1 ships the runtime Bunny CDN
config, while package.json still read 0.1.0. bun.lock records no version for
the root workspace, so a frozen install is unaffected.
2026-08-20 17:11:16 +03:00
47 changed files with 3468 additions and 106 deletions
+2 -2
View File
@@ -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.
@@ -52,6 +52,7 @@ import {
type ProjectDownloadManifest,
} from '@/lib/client/project-download';
import { downloadProgressPercent } from '@/lib/client/download-file';
import { beginUnloadGuard } from '@/lib/client/unload-guard';
import {
createDownloadProgressToast,
type DownloadProgressToastHandle,
@@ -209,6 +210,7 @@ export function ProjectContentClient({
setIsDownloading(true);
let progressToast: DownloadProgressToastHandle | null = null;
let releaseUnloadGuard: (() => void) | null = null;
try {
const response = await fetch(`/api/projects/${projectId}/download${query}`, {
cache: 'no-store',
@@ -232,6 +234,9 @@ export function ProjectContentClient({
title: `Downloading ${manifest.totalFiles} files`,
description: 'Starting…',
});
// The files are pulled one by one through this tab, so closing it drops
// everything that hasn't been saved yet. Warn before that happens.
releaseUnloadGuard = beginUnloadGuard();
await runProjectDownloadManifest(manifest, (p) => {
const percent = downloadProgressPercent({
receivedBytes: p.receivedBytes,
@@ -250,6 +255,7 @@ export function ProjectContentClient({
progressToast?.dismiss();
toast.error('Failed to start project download');
} finally {
releaseUnloadGuard?.();
setIsDownloading(false);
}
},
@@ -67,6 +67,7 @@ interface BillingOverview {
};
workspaceCreation: {
canCreateWorkspace: boolean;
canStartTrial?: boolean;
reason: string | null;
ownedWorkspaceCount: number;
invitedWorkspaceCount: number;
@@ -147,7 +148,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
const [testing, setTesting] = useState<string | null>(null);
const [billing, setBilling] = useState<BillingOverview | null>(null);
const [billingLoading, setBillingLoading] = useState(true);
const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | null>(null);
const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | 'trial' | null>(null);
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
const [storageLoading, setStorageLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -278,6 +279,29 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
[showMessage]
);
const handleStartTrial = useCallback(async () => {
setBillingAction('trial');
try {
const res = await fetch('/api/billing/trial', { method: 'POST' });
const data = await res.json();
if (!res.ok) {
showMessage('error', data.error || 'Failed to start your free trial');
return;
}
const billingRes = await fetch('/api/billing');
if (billingRes.ok) {
setBilling((await billingRes.json()).data);
}
showMessage('success', 'Your free trial has started');
} catch {
showMessage('error', 'Failed to start your free trial');
} finally {
setBillingAction(null);
}
}, [showMessage]);
if (loading) {
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
@@ -475,19 +499,34 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
)}
</Button>
) : (
<Button
onClick={() => handleBillingRedirect('/api/billing/checkout')}
disabled={!billing.checkoutAvailable || billingAction !== null}
>
{billingAction === 'checkout' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Redirecting...
</>
) : (
'Upgrade with Stripe'
)}
</Button>
<>
{billing.workspaceCreation.canStartTrial ? (
<Button onClick={handleStartTrial} disabled={billingAction !== null}>
{billingAction === 'trial' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting Trial...
</>
) : (
'Start Free Trial'
)}
</Button>
) : null}
<Button
variant={billing.workspaceCreation.canStartTrial ? 'outline' : 'default'}
onClick={() => handleBillingRedirect('/api/billing/checkout')}
disabled={!billing.checkoutAvailable || billingAction !== null}
>
{billingAction === 'checkout' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Redirecting...
</>
) : (
'Upgrade with Stripe'
)}
</Button>
</>
)}
</div>
</>
@@ -15,12 +15,35 @@ export default function NewWorkspacePage({
}: {
workspaceCreation: {
canCreateWorkspace: boolean;
canStartTrial?: boolean;
reason: string | null;
};
}) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isStartingTrial, setIsStartingTrial] = useState(false);
const [error, setError] = useState('');
const handleStartTrial = async () => {
setIsStartingTrial(true);
setError('');
try {
const response = await fetch('/api/billing/trial', { method: 'POST' });
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to start your free trial');
return;
}
router.refresh();
} catch {
setError('Something went wrong. Please try again.');
} finally {
setIsStartingTrial(false);
}
};
const [formData, setFormData] = useState({
name: '',
description: '',
@@ -76,7 +99,11 @@ export default function NewWorkspacePage({
)}
</div>
<CardTitle className="text-2xl">
{workspaceCreation.canCreateWorkspace ? 'Create New Workspace' : 'Upgrade Required'}
{workspaceCreation.canCreateWorkspace
? 'Create New Workspace'
: workspaceCreation.canStartTrial
? 'Start Your Free Trial'
: 'Upgrade Required'}
</CardTitle>
<CardDescription className="text-base">
{workspaceCreation.canCreateWorkspace
@@ -139,9 +166,27 @@ export default function NewWorkspacePage({
You can still create and manage projects inside workspaces where you are already a
member.
</p>
<Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link>
</Button>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{workspaceCreation.canStartTrial ? (
<Button className="w-full" onClick={handleStartTrial} disabled={isStartingTrial}>
{isStartingTrial ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting Trial...
</>
) : (
'Start Free Trial'
)}
</Button>
) : (
<Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link>
</Button>
)}
</div>
)}
</CardContent>
+2 -2
View File
@@ -22,7 +22,7 @@ import {
isValidEmailAddress,
normalizeEmail,
} from '@/lib/email-validation';
import { startCardlessTrial } from '@/lib/billing';
import { startCardlessTrialOnSignup } from '@/lib/billing';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { readRequestVisitor } from '@/lib/analytics/visitor';
@@ -166,7 +166,7 @@ export async function POST(request: NextRequest) {
// just lock the user out of an instance that has billing switched on.
if (!emailVerificationRequired) {
warnIfTrialsSkipVerification();
await startCardlessTrial(user.id);
await startCardlessTrialOnSignup(user.id);
}
// Send verification email if SMTP is configured
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse } from '@/lib/api-response';
import { startCardlessTrial } from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
import { db } from '@/lib/db';
/**
* The explicit claim of a deferred cardless trial.
*
* An invited collaborator has their trial held back at signup; nothing else in
* the product is allowed to start it as a side effect, because the clock spends
* the account's only trial. This endpoint is the one place the user says "start
* it now", from the workspace-creation and billing screens.
*/
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
if (!isTrustedSameOriginRequest(request)) {
return apiErrors.forbidden('Invalid request origin');
}
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!isStripeFeatureEnabled()) {
return apiErrors.badRequest('Stripe billing is disabled by this host');
}
const started = await startCardlessTrial(session.user.id);
if (!started) {
return apiErrors.conflict('Your free trial has already been used');
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { trialEndsAt: true },
});
return successResponse({ trialEndsAt: user?.trialEndsAt ?? null });
} catch (error) {
logError('billing.trial.start', error);
return apiErrors.internalError();
}
}
@@ -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);
@@ -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');
}
}
@@ -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');
}
}
+276
View File
@@ -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');
}
}
+8 -33
View File
@@ -10,7 +10,6 @@ import {
Video,
MoveRight,
Play,
Mic,
PenTool,
Keyboard,
BellRing,
@@ -231,17 +230,6 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" />
<div className="relative z-10 mx-auto max-w-[1000px] space-y-8">
<div
data-hero-copy
className="inline-flex items-center gap-2 border border-border/50 bg-secondary/30 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-md"
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary"></span>
</span>
<span className="font-mono tracking-wide uppercase">Fair Source Video Review</span>
</div>
<h1
data-hero-copy
className="text-4xl font-semibold leading-[0.95] tracking-[-0.03em] sm:text-5xl md:text-6xl lg:text-7xl"
@@ -288,28 +276,15 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<div data-hero-copy className="relative mx-auto mt-20 w-full max-w-[1200px]">
<div className="relative aspect-[16/9] w-full overflow-hidden border border-border bg-card shadow-2xl rounded-lg">
<Image
src="/landing/deep-dive-dashboard-2.webp"
alt="Product Interface Preview"
fill
className="object-cover"
priority
<video
src="/landing/hero-flow.mp4"
poster="/landing/deep-dive-dashboard-2.webp"
autoPlay
loop
muted
playsInline
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-background/90 via-transparent to-transparent pointer-events-none" />
{/* Toolbar floating UI */}
<div className="absolute bottom-6 left-1/2 z-20 flex -translate-x-1/2 items-center gap-2 border border-border/80 bg-background/90 p-2 backdrop-blur-md shadow-xl">
<button className="flex h-8 w-8 items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90">
<PenTool className="h-4 w-4" />
</button>
<div className="h-6 w-px bg-border" />
<button className="flex h-8 w-8 items-center justify-center text-muted-foreground hover:bg-secondary hover:text-foreground">
<MessageSquare className="h-4 w-4" />
</button>
<button className="flex h-8 w-8 items-center justify-center text-muted-foreground hover:bg-secondary hover:text-foreground">
<Mic className="h-4 w-4" />
</button>
</div>
</div>
</div>
</section>
+51
View File
@@ -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 <video> element can carry a <track>.
// A YouTube version is an iframe we do not control, and it brings its own captions.
const supportsSubtitles = activeProviderId === 'bunny' || activeProviderId === 'r2';
const {
subtitles,
subtitleTrackKey,
canManageSubtitles,
activeSubtitleLanguage,
selectSubtitleLanguage,
uploadSubtitle,
deleteSubtitle,
isUploadingSubtitle,
} = useSubtitles({
videoId,
versionId: activeVersionId,
videoRef,
supportsSubtitles,
});
const activeVersionDuration = activeVersion?.duration;
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const embedUrl = useMemo(() => {
@@ -305,6 +325,7 @@ export function VideoPageContent({
const {
isReady,
youtubeModuleRevision,
bunnyPlaybackState,
currentTime,
setCurrentTime,
@@ -359,6 +380,27 @@ export function VideoPageContent({
setViewingAnnotation,
});
const { youtubeCaptionTracks, activeYoutubeCaptionLanguage, selectYoutubeCaptionLanguage } =
useYoutubeCaptions({
videoId,
versionId: activeVersionId,
playerRef,
enabled: activeProviderId === 'youtube',
isReady,
moduleRevision: youtubeModuleRevision,
});
// One CC menu, two sources behind it. A YouTube version can only offer the captions the
// video already carries, so nothing there is ours to manage.
const isYoutubeVersion = activeProviderId === 'youtube';
const subtitleTracks = isYoutubeVersion ? youtubeCaptionTracks : subtitles;
const activeCaptionLanguage = isYoutubeVersion
? activeYoutubeCaptionLanguage
: activeSubtitleLanguage;
const selectCaptionLanguage = isYoutubeVersion
? selectYoutubeCaptionLanguage
: selectSubtitleLanguage;
const {
savedProgress,
showResumePrompt,
@@ -823,6 +865,15 @@ export function VideoPageContent({
selectedQualityLevel={selectedQualityLevel}
qualityOptions={qualityOptions}
handleQualityChange={handleQualityChange}
subtitles={subtitles}
subtitleTracks={subtitleTracks}
subtitleTrackKey={subtitleTrackKey}
activeSubtitleLanguage={activeCaptionLanguage}
onSelectSubtitleLanguage={selectCaptionLanguage}
canManageSubtitles={canManageSubtitles}
onUploadSubtitle={uploadSubtitle}
onDeleteSubtitle={deleteSubtitle}
isUploadingSubtitle={isUploadingSubtitle}
playbackSpeed={playbackSpeed}
speedOptions={speedOptions}
handleSpeedChange={handleSpeedChange}
@@ -0,0 +1,32 @@
/**
* The chosen subtitle language, remembered per video the way a player is expected to.
*
* Shared by both caption paths, so a viewer who turned Turkish on for a Bunny-hosted cut
* gets Turkish again on the YouTube version of the same video.
*/
function preferenceKey(videoId: string): string {
return `openframe:subtitle-language:${videoId}`;
}
export function readStoredSubtitleLanguage(videoId: string): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(preferenceKey(videoId));
} catch {
return null;
}
}
export function writeStoredSubtitleLanguage(videoId: string, language: string | null): void {
if (typeof window === 'undefined') return;
try {
if (language) {
window.localStorage.setItem(preferenceKey(videoId), language);
} else {
window.localStorage.removeItem(preferenceKey(videoId));
}
} catch {
// A browser with storage disabled still gets subtitles, just not a remembered choice.
}
}
@@ -22,6 +22,7 @@ import {
createDownloadProgressToast,
type DownloadProgressToastHandle,
} from '@/components/download-progress-toast';
import { beginUnloadGuard } from '@/lib/client/unload-guard';
function sanitizeDownloadFileName(value: string): string {
return value
@@ -94,6 +95,7 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
isDownloadingRef.current = true;
setActiveDownloadTarget(target);
let progressToast: DownloadProgressToastHandle | null = null;
let releaseUnloadGuard: (() => void) | null = null;
try {
let downloadUrl: string | null = null;
@@ -167,6 +169,9 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
title: `Downloading “${baseName}`,
description: 'Starting…',
});
// The bytes only exist in this tab until the blob is saved, so warn
// before the page goes away instead of losing the whole transfer.
releaseUnloadGuard = beginUnloadGuard();
const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`, (p) => {
progressToast?.update({
description: downloadProgressLabel(p),
@@ -195,6 +200,7 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
toast.error('Failed to start download');
}
} finally {
releaseUnloadGuard?.();
isDownloadingRef.current = false;
setActiveDownloadTarget(null);
}
@@ -0,0 +1,270 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
readStoredSubtitleLanguage,
writeStoredSubtitleLanguage,
} from '@/components/video-page/hooks/subtitle-preference';
import type { Subtitle } from '@/components/video-page/types';
interface UseSubtitlesParams {
videoId: string;
versionId: string | null;
videoRef: RefObject<HTMLVideoElement | null>;
/** Only the providers we play through our own element can carry a track. */
supportsSubtitles: boolean;
}
/**
* A wiped track is remounted at most this many times per version. A file that really is
* empty cannot reach storage (the upload route refuses one), so the cap only exists so a
* surprise can never turn into a fetch loop.
*/
const MAX_TRACK_REPAIRS = 3;
export function useSubtitles({
videoId,
versionId,
videoRef,
supportsSubtitles,
}: UseSubtitlesParams) {
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
const [canManageSubtitles, setCanManageSubtitles] = useState(false);
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
const [isUploadingSubtitle, setIsUploadingSubtitle] = useState(false);
// Bumped to remount the <track> elements when something empties them. See the effect
// below for what does that and why remounting is the fix.
const [trackEpoch, setTrackEpoch] = useState(0);
// The stored preference is applied once per version, not on every list refresh: turning
// subtitles off and then deleting an unrelated track must not switch them back on.
const appliedPreferenceForVersionRef = useRef<string | null>(null);
const loadedLanguagesRef = useRef<Set<string>>(new Set());
const repairCountRef = useRef(0);
useEffect(() => {
loadedLanguagesRef.current.clear();
repairCountRef.current = 0;
}, [versionId]);
const refresh = useCallback(async () => {
if (!versionId || !supportsSubtitles) {
setSubtitles([]);
setCanManageSubtitles(false);
return;
}
try {
const res = await fetch(
`/api/videos/${videoId}/subtitles?versionId=${encodeURIComponent(versionId)}`,
{ cache: 'no-store' }
);
if (!res.ok) return;
const payload = await res.json();
const list: Subtitle[] = Array.isArray(payload?.data?.subtitles)
? payload.data.subtitles
: [];
setSubtitles(list);
setCanManageSubtitles(Boolean(payload?.data?.canManageSubtitles));
} catch {
// A failed list leaves the player without tracks, which is the same as having none.
}
}, [supportsSubtitles, versionId, videoId]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!versionId) return;
if (appliedPreferenceForVersionRef.current === versionId) return;
if (subtitles.length === 0) return;
appliedPreferenceForVersionRef.current = versionId;
const stored = readStoredSubtitleLanguage(videoId);
if (stored && subtitles.some((subtitle) => subtitle.language === stored)) {
setActiveLanguage(stored);
}
}, [subtitles, versionId, videoId]);
// A track that is no longer in the list cannot stay selected.
useEffect(() => {
if (!activeLanguage) return;
if (subtitles.some((subtitle) => subtitle.language === activeLanguage)) return;
setActiveLanguage(null);
}, [activeLanguage, subtitles]);
/**
* React renders the <track> elements; their display mode is set here rather than through
* the `default` attribute, which the browser only honours on first load and which would
* fight the user's choice on every re-render.
*
* The second job here is repair. hls.js empties every text track on the media element,
* ours included, each time it loads a manifest (`_cleanTracks()` in its timeline
* controller). That fires on the initial load and again on every source switch, so a
* viewer who flips quality would watch the subtitles vanish for good: the file has
* already been fetched, so the browser never parses it a second time. Remounting the
* track element under a new key is what makes it fetch again.
*/
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
const findActiveTrack = (): TextTrack | null => {
if (!activeLanguage) return null;
const tracks = videoEl.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
if (tracks[index].language === activeLanguage) return tracks[index];
}
return null;
};
const applyModes = () => {
const tracks = videoEl.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
const shouldShow = Boolean(activeLanguage) && track.language === activeLanguage;
track.mode = shouldShow ? 'showing' : 'disabled';
if (!shouldShow) continue;
// The control bar sits over the bottom of the frame in fullscreen, so cues are
// lifted clear of it instead of landing underneath.
const cues = track.cues;
if (!cues) continue;
for (let cueIndex = 0; cueIndex < cues.length; cueIndex += 1) {
const cue = cues[cueIndex] as VTTCue;
if (typeof cue.line !== 'undefined') {
cue.snapToLines = true;
cue.line = -3;
}
}
}
};
const markLoaded = (event: Event) => {
const element = event.currentTarget as HTMLTrackElement;
loadedLanguagesRef.current.add(element.srclang);
applyModes();
};
const repairIfEmptied = () => {
if (!activeLanguage) return;
// Before the file has loaded a track legitimately has no cues, so only a track we
// have seen load and that is now empty counts as wiped.
if (!loadedLanguagesRef.current.has(activeLanguage)) return;
const track = findActiveTrack();
if (!track || (track.cues?.length ?? 0) > 0) return;
if (repairCountRef.current >= MAX_TRACK_REPAIRS) return;
repairCountRef.current += 1;
loadedLanguagesRef.current.delete(activeLanguage);
setTrackEpoch((epoch) => epoch + 1);
};
applyModes();
// A track's cues are null until the browser has fetched the file, which it only does
// once the track is not disabled. The lift above therefore has to run again on load.
const trackElements = Array.from(videoEl.querySelectorAll('track'));
trackElements.forEach((element) => element.addEventListener('load', markLoaded));
videoEl.textTracks.addEventListener('addtrack', applyModes);
// `loadeddata` catches a source switch while paused; `timeupdate` catches everything
// else within a quarter of a second of playback.
videoEl.addEventListener('loadeddata', repairIfEmptied);
videoEl.addEventListener('timeupdate', repairIfEmptied);
return () => {
trackElements.forEach((element) => element.removeEventListener('load', markLoaded));
videoEl.textTracks.removeEventListener('addtrack', applyModes);
videoEl.removeEventListener('loadeddata', repairIfEmptied);
videoEl.removeEventListener('timeupdate', repairIfEmptied);
};
}, [activeLanguage, subtitles, trackEpoch, videoRef, versionId]);
const selectSubtitleLanguage = useCallback(
(language: string | null) => {
setActiveLanguage(language);
writeStoredSubtitleLanguage(videoId, language);
appliedPreferenceForVersionRef.current = versionId;
},
[versionId, videoId]
);
const uploadSubtitle = useCallback(
async (file: File, language: string, label: string): Promise<string | null> => {
if (!versionId) return 'No version selected';
setIsUploadingSubtitle(true);
try {
const formData = new FormData();
formData.append('subtitle', file);
formData.append('versionId', versionId);
formData.append('language', language);
formData.append('label', label);
const res = await fetch(`/api/videos/${videoId}/subtitles`, {
method: 'POST',
body: formData,
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
return payload?.error?.message || payload?.error || 'Failed to upload subtitle';
}
await refresh();
selectSubtitleLanguage(language.toLowerCase());
return null;
} catch {
return 'Failed to upload subtitle';
} finally {
setIsUploadingSubtitle(false);
}
},
[refresh, selectSubtitleLanguage, versionId, videoId]
);
const deleteSubtitle = useCallback(
async (subtitleId: string): Promise<string | null> => {
try {
const res = await fetch(`/api/videos/${videoId}/subtitles/${subtitleId}`, {
method: 'DELETE',
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
return payload?.error?.message || payload?.error || 'Failed to delete subtitle';
}
await refresh();
return null;
} catch {
return 'Failed to delete subtitle';
}
},
[refresh, videoId]
);
return useMemo(
() => ({
subtitles,
canManageSubtitles,
activeSubtitleLanguage: activeLanguage,
subtitleTrackKey: String(trackEpoch),
selectSubtitleLanguage,
uploadSubtitle,
deleteSubtitle,
isUploadingSubtitle,
refreshSubtitles: refresh,
}),
[
activeLanguage,
canManageSubtitles,
deleteSubtitle,
isUploadingSubtitle,
refresh,
selectSubtitleLanguage,
subtitles,
trackEpoch,
uploadSubtitle,
]
);
}
@@ -81,6 +81,10 @@ export function useVideoPlayer({
}: UseVideoPlayerParams) {
const [isApiLoaded, setIsApiLoaded] = useState(false);
const [isReady, setIsReady] = useState(false);
// Bumped every time the YouTube player loads or unloads a module. It is the only
// signal that `getOption('captions', ...)` will answer, so the captions hook waits
// on it rather than polling.
const [youtubeModuleRevision, setYoutubeModuleRevision] = useState(0);
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
const [currentTime, setCurrentTime] = useState(0);
const [videoDuration, setVideoDuration] = useState(0);
@@ -315,6 +319,9 @@ export function useVideoPlayer({
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
},
onApiChange: () => {
setYoutubeModuleRevision((revision) => revision + 1);
},
onStateChange: (event: YT.OnStateChangeEvent) => {
setIsPlaying(event.data === YT.PlayerState.PLAYING);
@@ -1344,6 +1351,7 @@ export function useVideoPlayer({
return {
isReady,
youtubeModuleRevision,
bunnyPlaybackState,
currentTime,
setCurrentTime,
@@ -0,0 +1,177 @@
'use client';
// Same exemption as the players themselves: this hook mirrors an external player's
// caption state into React, which is the case the rule cannot distinguish.
/* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
readStoredSubtitleLanguage,
writeStoredSubtitleLanguage,
} from '@/components/video-page/hooks/subtitle-preference';
import type { PlayerAdapter, SubtitleTrackOption } from '@/components/video-page/types';
interface UseYoutubeCaptionsParams {
videoId: string;
versionId: string | null;
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
/** The active version is a YouTube one. */
enabled: boolean;
isReady: boolean;
/** Incremented by the player on every onApiChange. */
moduleRevision: number;
}
/** One entry of `getOption('captions', 'tracklist')`. Only these fields are relied on. */
type YoutubeCaptionTrack = {
languageCode?: string;
languageName?: string;
displayName?: string;
};
const CAPTIONS_MODULE = 'captions';
function asYoutubePlayer(
player: YT.Player | PlayerAdapter | null
): (YT.Player & { loadModule?: unknown }) | null {
if (!player) return null;
const candidate = player as YT.Player;
return typeof candidate.loadModule === 'function' ? candidate : null;
}
/**
* Drives YouTube's own captions from our control bar.
*
* A YouTube version plays inside an iframe we do not own, so a <track> element is not an
* option and neither is an uploaded file: the only captions that exist for it are the ones
* the video already carries. The player is embedded with controls=0, which hides
* YouTube's CC button along with the rest of its chrome, so without this the captions
* would be unreachable even when they exist.
*/
export function useYoutubeCaptions({
videoId,
versionId,
playerRef,
enabled,
isReady,
moduleRevision,
}: UseYoutubeCaptionsParams) {
const [tracks, setTracks] = useState<SubtitleTrackOption[]>([]);
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
// Read inside effects that must not re-run when the selection changes.
const activeLanguageRef = useRef<string | null>(null);
useEffect(() => {
activeLanguageRef.current = activeLanguage;
}, [activeLanguage]);
const appliedPreferenceForVersionRef = useRef<string | null>(null);
/**
* The caption state we last pushed into the player, or `undefined` before the first
* push. Loading and unloading a module both fire onApiChange, so an effect that reacted
* to every revision by unloading again would answer its own event forever.
*/
const appliedLanguageRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
setTracks([]);
setActiveLanguage(null);
appliedLanguageRef.current = undefined;
}, [versionId]);
// Loading the module is what makes the track list readable, and it also switches
// captions on. The probe below turns them straight back off for a viewer who has not
// asked for them: at this point the video is at its first frame with no cue to draw,
// so there is nothing to flash.
useEffect(() => {
if (!enabled || !isReady) return;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
try {
player.loadModule(CAPTIONS_MODULE);
} catch {
// An older or restricted player without the module API simply has no captions.
}
}, [enabled, isReady, playerRef, versionId]);
useEffect(() => {
if (!enabled || !isReady || moduleRevision === 0) return;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
let rawTracks: YoutubeCaptionTrack[] = [];
try {
rawTracks = player.getOption<YoutubeCaptionTrack[]>(CAPTIONS_MODULE, 'tracklist') ?? [];
} catch {
rawTracks = [];
}
const mapped: SubtitleTrackOption[] = rawTracks
.filter((track): track is YoutubeCaptionTrack & { languageCode: string } =>
Boolean(track?.languageCode)
)
.map((track) => ({
id: `youtube:${track.languageCode}`,
language: track.languageCode.toLowerCase(),
label: track.displayName || track.languageName || track.languageCode.toUpperCase(),
canDelete: false,
}));
setTracks(mapped);
const stored =
versionId && appliedPreferenceForVersionRef.current !== versionId
? readStoredSubtitleLanguage(videoId)
: null;
if (versionId) appliedPreferenceForVersionRef.current = versionId;
const wanted =
activeLanguageRef.current ??
(stored && mapped.some((track) => track.language === stored) ? stored : null);
if (appliedLanguageRef.current === wanted) return;
appliedLanguageRef.current = wanted;
try {
if (wanted) {
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: wanted });
setActiveLanguage(wanted);
} else {
player.unloadModule(CAPTIONS_MODULE);
}
} catch {
// Same as above: a player that will not take the option has no captions to give.
}
}, [enabled, isReady, moduleRevision, playerRef, versionId, videoId]);
const selectCaptionLanguage = useCallback(
(language: string | null) => {
setActiveLanguage(language);
writeStoredSubtitleLanguage(videoId, language);
appliedPreferenceForVersionRef.current = versionId;
appliedLanguageRef.current = language;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
try {
if (language) {
player.loadModule(CAPTIONS_MODULE);
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: language });
} else {
player.unloadModule(CAPTIONS_MODULE);
}
} catch {
// Nothing to recover: the menu already reflects the choice, and a player that
// refuses the module was never going to show captions.
}
},
[playerRef, versionId, videoId]
);
return useMemo(
() => ({
youtubeCaptionTracks: enabled ? tracks : [],
activeYoutubeCaptionLanguage: enabled ? activeLanguage : null,
selectYoutubeCaptionLanguage: selectCaptionLanguage,
}),
[activeLanguage, enabled, selectCaptionLanguage, tracks]
);
}
+57 -2
View File
@@ -32,7 +32,13 @@ import {
type AnnotationStroke,
} from '@/components/annotation-canvas';
import { SILENT_ABOVE_SPEED } from '@/components/video-page/hooks/video-player-utils';
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
import { SubtitleControls } from '@/components/video-page/subtitle-controls';
import type {
BunnyQualityOption,
CommentMarker,
Subtitle,
SubtitleTrackOption,
} from '@/components/video-page/types';
interface PlayerCoreProps {
activeVersionId: string | null;
@@ -85,6 +91,24 @@ interface PlayerCoreProps {
selectedQualityLevel: number;
qualityOptions: BunnyQualityOption[];
handleQualityChange: (level: number) => void;
/** Uploaded tracks, rendered as <track> elements. Empty for a YouTube version. */
subtitles: Subtitle[];
/**
* What the CC menu offers, which is the list above for our own player and YouTube's
* own caption list for an embedded YouTube version.
*/
subtitleTracks: SubtitleTrackOption[];
/**
* Changes when a track has to be re-fetched. It is part of each <track> key because
* remounting the element is the only way to make the browser parse the file again.
*/
subtitleTrackKey: string;
activeSubtitleLanguage: string | null;
onSelectSubtitleLanguage: (language: string | null) => void;
canManageSubtitles: boolean;
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
isUploadingSubtitle: boolean;
playbackSpeed: number;
speedOptions: number[];
handleSpeedChange: (speed: number) => void;
@@ -153,6 +177,15 @@ export const PlayerCore = memo(function PlayerCore({
selectedQualityLevel,
qualityOptions,
handleQualityChange,
subtitles,
subtitleTracks,
subtitleTrackKey,
activeSubtitleLanguage,
onSelectSubtitleLanguage,
canManageSubtitles,
onUploadSubtitle,
onDeleteSubtitle,
isUploadingSubtitle,
playbackSpeed,
speedOptions,
handleSpeedChange,
@@ -208,7 +241,17 @@ export const PlayerCore = memo(function PlayerCore({
}}
preload="metadata"
playsInline
/>
>
{subtitles.map((subtitle) => (
<track
key={`${subtitle.id}:${subtitleTrackKey}`}
kind="subtitles"
src={subtitle.url}
srcLang={subtitle.language}
label={subtitle.label}
/>
))}
</video>
</div>
</div>
) : (
@@ -442,6 +485,18 @@ export const PlayerCore = memo(function PlayerCore({
</DropdownMenu>
)}
{activeProviderId && activeProviderId !== 'direct' && (
<SubtitleControls
subtitles={subtitleTracks}
activeSubtitleLanguage={activeSubtitleLanguage}
onSelectSubtitleLanguage={onSelectSubtitleLanguage}
canManageSubtitles={canManageSubtitles}
onUploadSubtitle={onUploadSubtitle}
onDeleteSubtitle={onDeleteSubtitle}
isUploadingSubtitle={isUploadingSubtitle}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
+303
View File
@@ -0,0 +1,303 @@
'use client';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Captions, Loader2, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { SubtitleTrackOption } from '@/components/video-page/types';
const COMMON_LANGUAGES = [
'tr',
'en',
'de',
'fr',
'es',
'it',
'pt',
'nl',
'pl',
'ru',
'ar',
'ja',
'ko',
'zh',
'hi',
] as const;
const OTHER_LANGUAGE = '__other__';
/** A file named `cut-v3.tr.srt` already says which language it is. */
const LANGUAGE_FROM_FILENAME = /\.([a-z]{2,3}(?:-[a-z0-9]{2,8})?)\.(?:srt|vtt)$/i;
function describeLanguage(tag: string): string {
try {
const displayNames = new Intl.DisplayNames(undefined, { type: 'language' });
return displayNames.of(tag) || tag.toUpperCase();
} catch {
return tag.toUpperCase();
}
}
function guessLanguageFromFileName(fileName: string): string | null {
const match = LANGUAGE_FROM_FILENAME.exec(fileName);
return match ? match[1].toLowerCase() : null;
}
interface SubtitleControlsProps {
/**
* What the menu lists. For a Bunny or R2 version these are the tracks uploaded to this
* cut; for a YouTube version they are the captions the video already carries, which is
* why the shape is narrower than a stored subtitle.
*/
subtitles: SubtitleTrackOption[];
activeSubtitleLanguage: string | null;
onSelectSubtitleLanguage: (language: string | null) => void;
canManageSubtitles: boolean;
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
isUploadingSubtitle: boolean;
}
export const SubtitleControls = memo(function SubtitleControls({
subtitles,
activeSubtitleLanguage,
onSelectSubtitleLanguage,
canManageSubtitles,
onUploadSubtitle,
onDeleteSubtitle,
isUploadingSubtitle,
}: SubtitleControlsProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [languageChoice, setLanguageChoice] = useState<string>('tr');
const [customLanguage, setCustomLanguage] = useState('');
const [label, setLabel] = useState('');
const activeSubtitle = useMemo(
() => subtitles.find((subtitle) => subtitle.language === activeSubtitleLanguage) ?? null,
[activeSubtitleLanguage, subtitles]
);
const resolvedLanguage = (
languageChoice === OTHER_LANGUAGE ? customLanguage : languageChoice
).trim();
const handleFileChosen = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
// Clearing the input lets the same file be picked again after a failed upload.
event.target.value = '';
if (!file) return;
const guessed = guessLanguageFromFileName(file.name);
const known = guessed && (COMMON_LANGUAGES as readonly string[]).includes(guessed);
setLanguageChoice(known ? (guessed as string) : guessed ? OTHER_LANGUAGE : 'tr');
setCustomLanguage(known ? '' : (guessed ?? ''));
setLabel('');
setPendingFile(file);
}, []);
const handleUpload = useCallback(async () => {
if (!pendingFile || !resolvedLanguage) return;
const finalLabel = label.trim() || describeLanguage(resolvedLanguage);
const error = await onUploadSubtitle(pendingFile, resolvedLanguage, finalLabel);
if (error) {
toast.error(error);
return;
}
toast.success('Subtitle added');
setPendingFile(null);
}, [label, onUploadSubtitle, pendingFile, resolvedLanguage]);
const handleDelete = useCallback(
async (subtitle: SubtitleTrackOption) => {
const error = await onDeleteSubtitle(subtitle.id);
if (error) {
toast.error(error);
return;
}
toast.success(`${subtitle.label} removed`);
},
[onDeleteSubtitle]
);
if (subtitles.length === 0 && !canManageSubtitles) return null;
const replacesExisting = subtitles.some(
(subtitle) => subtitle.language === resolvedLanguage.toLowerCase()
);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={activeSubtitle ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1 text-xs"
title="Subtitles"
>
<Captions className="h-3.5 w-3.5" />
{activeSubtitle ? activeSubtitle.label : 'CC'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem
onClick={() => onSelectSubtitleLanguage(null)}
className={cn(!activeSubtitleLanguage && 'font-bold text-primary')}
>
Off
</DropdownMenuItem>
{subtitles.map((subtitle) => (
<DropdownMenuItem
key={subtitle.id}
onClick={() => onSelectSubtitleLanguage(subtitle.language)}
className={cn(
'flex items-center justify-between gap-2',
subtitle.language === activeSubtitleLanguage && 'font-bold text-primary'
)}
>
<span className="truncate">{subtitle.label}</span>
{subtitle.canDelete && (
<button
type="button"
aria-label={`Delete ${subtitle.label} subtitle`}
className="text-muted-foreground hover:text-destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDelete(subtitle);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</DropdownMenuItem>
))}
{canManageSubtitles && (
<>
{subtitles.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingSubtitle}
>
<Upload className="h-3.5 w-3.5 mr-2" />
Add subtitle
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<input
ref={fileInputRef}
type="file"
accept=".srt,.vtt,text/vtt,application/x-subrip"
className="hidden"
onChange={handleFileChosen}
/>
<Dialog
open={!!pendingFile}
onOpenChange={(open) => {
if (!open && !isUploadingSubtitle) setPendingFile(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add subtitle</DialogTitle>
<DialogDescription>
{pendingFile?.name} is attached to this version only, because cue timings belong to
one cut. SRT files are converted to WebVTT on upload.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="subtitle-language">Language</Label>
<Select value={languageChoice} onValueChange={setLanguageChoice}>
<SelectTrigger id="subtitle-language">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMMON_LANGUAGES.map((tag) => (
<SelectItem key={tag} value={tag}>
{describeLanguage(tag)}
</SelectItem>
))}
<SelectItem value={OTHER_LANGUAGE}>Other</SelectItem>
</SelectContent>
</Select>
{languageChoice === OTHER_LANGUAGE && (
<Input
value={customLanguage}
onChange={(event) => setCustomLanguage(event.target.value)}
placeholder="Language tag, e.g. en-US"
maxLength={20}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="subtitle-label">Label</Label>
<Input
id="subtitle-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder={resolvedLanguage ? describeLanguage(resolvedLanguage) : 'Türkçe'}
maxLength={60}
/>
</div>
{replacesExisting && (
<p className="text-xs text-muted-foreground">
This version already has a track in that language. Uploading replaces it.
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingFile(null)}
disabled={isUploadingSubtitle}
>
Cancel
</Button>
<Button
onClick={() => void handleUpload()}
disabled={isUploadingSubtitle || !resolvedLanguage}
>
{isUploadingSubtitle && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
});
+21
View File
@@ -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;
+3
View File
@@ -21,6 +21,9 @@ const eslintConfig = defineConfig([
'test-results/**',
'reports/**',
'.stryker-tmp/**',
// Git worktrees checked out under .claude/worktrees are separate checkouts,
// not part of this tree; linting them fails the run on their files.
'.claude/**',
]),
prettier,
{
+2 -2
View File
@@ -6,7 +6,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { hasBillingAccess, startCardlessTrial } from '@/lib/billing';
import { hasBillingAccess, startCardlessTrialOnSignup } from '@/lib/billing';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { isEmailVerificationEnabled } from '@/lib/email-verification';
@@ -171,7 +171,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// opposite of what the signup page promised it. The address is already
// proven here: the signIn callback above turns away an OAuth profile that
// reports its email as unverified.
await startCardlessTrial(user.id);
await startCardlessTrialOnSignup(user.id);
},
},
});
+131 -15
View File
@@ -1,6 +1,6 @@
import type { Prisma } from '@prisma/client';
import type Stripe from 'stripe';
import { BillingSubscriptionStatus } from '@prisma/client';
import { BillingSubscriptionStatus, InvitationStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
@@ -336,6 +336,12 @@ export function buildEffectiveBillingStatusWhereInput(
* `billingTrialConsumedAt` is written here rather than only by the Stripe sync.
* It is the once-per-account marker, so a re-issued verification link, a second
* device or a replayed request all land on the `WHERE` clause and change nothing.
*
* Signup goes through `startCardlessTrialOnSignup` instead, which holds the trial
* back for an account that only exists because somebody invited it. This is the
* unconditional grant, reached later only when that account explicitly asks for
* its deferred trial through the start-trial endpoint. It is never started as a
* side effect of some other action; the clock costs the account its only trial.
*/
export async function startCardlessTrial(userId: string, now: Date = new Date()) {
// Without billing nothing is gated, so a trial would be a date nobody reads.
@@ -366,6 +372,69 @@ export async function startCardlessTrial(userId: string, now: Date = new Date())
return true;
}
/**
* Whether this account arrived as somebody else's collaborator.
*
* An invited member works inside the inviter's workspace on the inviter's
* billing, so a trial handed to them at signup buys them nothing and is spent
* before they have seen the product on an account of their own. Worse, it is
* spent for good: `billingTrialConsumedAt` is never cleared, so the day they
* consider becoming a customer themselves the trial is already gone.
*
* Two signals, because the invitation lands at different points on the two
* signup paths. The credentials route accepts the token inside the same request
* that creates the account, so by the time the trial is considered the
* membership row exists. An OAuth signup creates the account on the way out to
* the provider and accepts the invitation only on the way back, so there the
* pending invitation is the only thing to go on.
*/
async function arrivedAsCollaborator(userId: string, now: Date) {
const user = await db.user.findUnique({
where: { id: userId },
select: { email: true },
});
const [workspaceMemberships, projectMemberships, pendingInvitations] = await Promise.all([
db.workspaceMember.count({
where: { userId, workspace: { ownerId: { not: userId } } },
}),
db.projectMember.count({
where: { userId, project: { ownerId: { not: userId } } },
}),
user?.email
? db.invitation.count({
where: {
email: user.email,
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
})
: Promise.resolve(0),
]);
return workspaceMemberships > 0 || projectMemberships > 0 || pendingInvitations > 0;
}
/**
* The trial as granted at signup: to everyone except an invited collaborator,
* whose clock is deferred until they own something of their own.
*
* Nothing is lost by waiting. The deferred trial stays claimable forever: the
* account starts it whenever it chooses through the start-trial endpoint, which
* the workspace-creation and billing screens point at.
*/
export async function startCardlessTrialOnSignup(userId: string, now: Date = new Date()) {
if (!isStripeFeatureEnabled()) {
return false;
}
if (await arrivedAsCollaborator(userId, now)) {
return false;
}
return startCardlessTrial(userId, now);
}
export async function getStripeCheckoutState(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
@@ -439,6 +508,12 @@ export async function getWorkspaceCreationEligibility(userId: string) {
const billingAccess = hasBillingAccess(user);
const isPaid = isPaidTier(user);
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
// An invited collaborator whose trial was deferred at signup. Their trial is
// still owed, but starting it is their call, not a side effect of clicking
// "create workspace": the clock costs them their only trial, so it runs only
// after they ask for it through the explicit start-trial endpoint.
const canStartTrial =
isStripeFeatureEnabled() && !billingAccess && !user.trialEndsAt && !user.billingTrialConsumedAt;
// A paying account creates as many workspaces as it wants. Everyone else gets
// one, which covers both the cardless trial and the pre-trial state where an
@@ -452,9 +527,9 @@ export async function getWorkspaceCreationEligibility(userId: string) {
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
reason = 'Your free trial includes one workspace. Subscribe to create more.';
} else if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
} else if (canStartTrial && collaborationCount > 0 && ownedWorkspaceCount === 0) {
reason =
'You are currently collaborating in someone elses workspace or project. Start a subscription to create a workspace of your own.';
'You are collaborating in someone elses workspace, so your free trial has not started yet. Start it to create a workspace of your own.';
} else {
reason = 'Your trial has ended. Start a subscription to create and keep owning workspaces.';
}
@@ -462,6 +537,7 @@ export async function getWorkspaceCreationEligibility(userId: string) {
return {
canCreateWorkspace,
canStartTrial,
reason,
ownedWorkspaceCount,
invitedWorkspaceCount,
@@ -493,6 +569,7 @@ export async function getBillingOverview(userId: string) {
return {
workspaceCreation: {
canCreateWorkspace: billing.canCreateWorkspace,
canStartTrial: billing.canStartTrial,
reason: billing.reason,
ownedWorkspaceCount: billing.ownedWorkspaceCount,
invitedWorkspaceCount: billing.invitedWorkspaceCount,
@@ -547,26 +624,65 @@ export async function getTrialNotice(
const contentKeptUntil = getStorageCleanupEligibleAt(user);
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
const notice = ((): TrialNotice | null => {
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
return null;
}
return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
}
const endsAt = getBillingAccessEndDate(user);
if (!endsAt || hasBillingAccess(user, now)) {
return null;
}
return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
}
// Past the cleanup date there is nothing left to reassure anybody about.
if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
return null;
}
const endsAt = getBillingAccessEndDate(user);
if (!endsAt || hasBillingAccess(user, now)) {
return { kind: 'ended', endsAt, contentKeptUntil };
})();
// Neither sentence is true for a guest in somebody else's workspace: no
// deadline is coming for them, and the media the banner promises to keep is
// not theirs and is not at risk. They were reading "your projects and media
// are kept until" about a paying customer's work. Checked last so the queries
// only run for the few accounts a banner was about to be shown to.
if (notice && (await isCollaboratorWithNothingOfTheirOwn(userId, now))) {
return null;
}
// Past the cleanup date there is nothing left to reassure anybody about.
if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
return null;
}
return notice;
}
return { kind: 'ended', endsAt, contentKeptUntil };
/**
* Somebody who only ever works inside workspaces they do not own.
*
* Ownership is what makes billing personal: the storage, the projects and the
* cleanup deadline all hang off the owning account. An account that owns none of
* that, and reaches the product entirely through a workspace whose owner is
* paying, has nothing of its own on the line.
*/
async function isCollaboratorWithNothingOfTheirOwn(userId: string, now: Date) {
const [ownedWorkspaceCount, collaborationCount] = await Promise.all([
db.workspace.count({ where: { ownerId: userId } }),
db.workspace.count({
where: {
ownerId: { not: userId },
owner: buildBillingAccessWhereInput(now),
OR: [
{ members: { some: { userId } } },
{ projects: { some: { members: { some: { userId } } } } },
],
},
}),
]);
return ownedWorkspaceCount === 0 && collaborationCount > 0;
}
export async function getOrCreateStripeCustomerId(userId: string) {
+48
View File
@@ -0,0 +1,48 @@
'use client';
/**
* Downloads that we pull through fetch() live inside the page: closing the tab
* (or reloading) throws away every byte received so far and the browser gives no
* warning, because as far as it knows nothing is downloading. While one of those
* is in flight we register a beforeunload handler so the user gets the native
* "leave site?" dialog instead of silently losing the transfer.
*
* Plain navigation downloads (the `download` attribute / a redirect to the CDN)
* are owned by the browser and survive a tab close, so they must NOT be guarded.
*/
let activeCount = 0;
function handleBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
// Legacy browsers only show the dialog when returnValue is set; the string
// itself is ignored, every browser shows its own wording.
event.returnValue = '';
}
/** Registers the guard and returns a release function. Safe to call again while
* another download is already guarded — the listener is reference counted and
* only detaches once the last one releases. Releasing twice is a no-op. */
export function beginUnloadGuard(): () => void {
if (typeof window === 'undefined') return () => {};
if (activeCount === 0) {
window.addEventListener('beforeunload', handleBeforeUnload);
}
activeCount += 1;
let released = false;
return () => {
if (released) return;
released = true;
activeCount -= 1;
if (activeCount === 0) {
window.removeEventListener('beforeunload', handleBeforeUnload);
}
};
}
/** Test helper: number of downloads currently holding the guard. */
export function unloadGuardCount(): number {
return activeCount;
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled, isStripeFeatureEnabled } from '@/lib/feature-flags';
import { startCardlessTrial } from '@/lib/billing';
import { startCardlessTrialOnSignup } from '@/lib/billing';
// Reduce window to 2 hours — shorter exposure in access logs and backups.
const TOKEN_EXPIRY_HOURS = 2;
@@ -120,7 +120,7 @@ export async function consumeVerificationToken(token: string): Promise<string |
});
}
await startCardlessTrial(verified.id);
await startCardlessTrialOnSignup(verified.id);
}
return record.identifier;
+6
View File
@@ -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[],
+26 -4
View File
@@ -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<R
* Collect all media URLs from comments under a given video (all versions).
*/
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
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<string[]>
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<string[]>
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<string[]>
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
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<string
where: { providerId: 'r2', video: { projectId } },
select: { originalUrl: true, thumbnailUrl: true },
}),
db.videoSubtitle.findMany({
where: { version: { video: { projectId } } },
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -153,6 +165,9 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
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;
}
@@ -160,7 +175,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
* Collect all media URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
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<st
where: { providerId: 'r2', video: { project: { workspaceId } } },
select: { originalUrl: true, thumbnailUrl: true },
}),
db.videoSubtitle.findMany({
where: { version: { video: { project: { workspaceId } } } },
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -192,6 +211,9 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
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;
}
+1 -1
View File
@@ -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);
+3
View File
@@ -71,6 +71,9 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'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
+25 -8
View File
@@ -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<bigint> {
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<bigint>
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 }]>`
+298
View File
@@ -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,
/^<v(?:\.[\w-]+)*(?:\s+[^<>]{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 `<b>` out of `<scr<b>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, '&lt;').replace(/>/g, '&gt;');
}
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 `&lt` 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}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openframe",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -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;
+31
View File
@@ -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/<uuid>.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())
Binary file not shown.
+55 -1
View File
@@ -50,6 +50,7 @@ import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/rou
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
import * as billingPortalRoute from '@/app/api/billing/portal/route';
import * as billingTrialRoute from '@/app/api/billing/trial/route';
import * as billingRoute from '@/app/api/billing/route';
import * as commentRoute from '@/app/api/comments/[commentId]/route';
import * as feedbackRoute from '@/app/api/feedback/route';
@@ -82,6 +83,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 +94,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 +149,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 = 67;
/**
* Routes that are public by design, and why. Everything else must reject an
@@ -203,6 +207,7 @@ const PUBLIC_ROUTES: ReadonlyMap<string, string> = 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 +222,7 @@ interface Fixtures {
versionId: string;
commentId: string;
assetId: string;
subtitleId: string;
approvalRequestId: string;
feedbackId: string;
}
@@ -280,6 +286,20 @@ async function seedFixtures(): Promise<Fixtures> {
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 +330,7 @@ async function seedFixtures(): Promise<Fixtures> {
versionId: version.id,
commentId: comment.id,
assetId: asset.id,
subtitleId: subtitle.id,
approvalRequestId: approvalRequest.id,
feedbackId: feedback.id,
};
@@ -397,6 +418,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
headers: { origin: 'http://localhost:3000' },
},
{ file: 'billing/route.ts', module: billingRoute, url: () => '/api/billing' },
{
file: 'billing/trial/route.ts',
module: billingTrialRoute,
url: () => '/api/billing/trial',
headers: { origin: 'http://localhost:3000' },
},
{
file: 'comments/[commentId]/route.ts',
module: commentRoute,
@@ -595,6 +622,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 +702,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,
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { POST as startTrialRoute } from '@/app/api/billing/trial/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { addWorkspaceMember, createExpiredUser, createUser, seedProject } from '../factories';
const ORIGIN_HEADERS = { origin: 'http://localhost:3000' };
function startTrialRequest() {
return apiRequest('/api/billing/trial', { method: 'POST', headers: ORIGIN_HEADERS });
}
describe('POST /api/billing/trial', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(startTrialRoute, startTrialRequest());
expect(response.status).toBe(401);
});
it('rejects a cross-origin request', async () => {
const response = await callRoute(
startTrialRoute,
apiRequest('/api/billing/trial', { method: 'POST', headers: { origin: 'https://evil.test' } })
);
expect(response.status).toBe(403);
});
// The whole point of the endpoint: an invited collaborator whose trial was
// deferred at signup claims it here, explicitly, and nowhere else.
it('starts the deferred trial for a collaborator who asks for it', async () => {
const host = await seedProject();
const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
signedInAs(invited);
const response = await callRoute(startTrialRoute, startTrialRequest());
expect(response.status).toBe(200);
const data = await readData<{ trialEndsAt: string | null }>(response);
expect(data.trialEndsAt).not.toBeNull();
const after = await db.user.findUniqueOrThrow({ where: { id: invited.id } });
expect(after.billingTrialConsumedAt).not.toBeNull();
expect(after.trialEndsAt!.getTime()).toBeGreaterThan(Date.now());
});
// Once per account. An expired user already spent theirs; asking again must
// not reset the clock.
it('refuses a second trial to an account that already spent one', async () => {
const expired = await createExpiredUser();
signedInAs(expired);
const response = await callRoute(startTrialRoute, startTrialRequest());
expect(response.status).toBe(409);
const after = await db.user.findUniqueOrThrow({ where: { id: expired.id } });
expect(after.trialEndsAt?.getTime()).toBeLessThan(Date.now());
});
});
+50 -1
View File
@@ -7,6 +7,7 @@
// fails a test rather than a security review.
import { createHash } from 'node:crypto';
import { InvitationScope } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import nodemailer from 'nodemailer';
import { db } from '@/lib/db';
@@ -20,7 +21,7 @@ import { GET as verifyEmail } from '@/app/api/auth/verify-email/route';
import { POST as resendVerification } from '@/app/api/auth/verify-email/resend/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { mailTo, sentMail } from '../helpers/mail';
import { createUser } from '../factories';
import { addWorkspaceMember, createInvitation, createUser, seedProject } from '../factories';
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
@@ -117,6 +118,54 @@ describe('consumeVerificationToken', () => {
expect(days).toBe(7);
});
// An invited collaborator works inside the inviter's workspace on the inviter's
// billing, so a trial handed over here would be spent before they had seen the
// product on an account of their own, and `billingTrialConsumedAt` is never
// cleared. It waits until they create a workspace of their own.
it('holds the trial back for somebody who verified as an invited member', async () => {
const host = await seedProject();
const user = await createUser({
email: '[email protected]',
emailVerified: null,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: user.id });
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(verified.emailVerified).toBeInstanceOf(Date);
expect(verified.trialEndsAt).toBeNull();
expect(verified.billingTrialConsumedAt).toBeNull();
});
// The OAuth half of the same case: the account exists before the invitation is
// accepted, so the still-open invitation is the only signal there is.
it('holds the trial back while an invitation to that address is still open', async () => {
const host = await seedProject();
const user = await createUser({
email: '[email protected]',
emailVerified: null,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
await createInvitation({
email: '[email protected]',
scope: InvitationScope.WORKSPACE,
workspaceId: host.workspace.id,
invitedById: host.owner.id,
});
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(verified.trialEndsAt).toBeNull();
expect(verified.billingTrialConsumedAt).toBeNull();
});
it('does not hand a second trial to an account that already had one', async () => {
const consumedAt = new Date('2026-01-01T00:00:00.000Z');
const trialEndsAt = new Date('2026-01-08T00:00:00.000Z');
+47
View File
@@ -407,6 +407,53 @@ describe('POST /api/auth/register', () => {
expect(created.billingTrialConsumedAt).toBeNull();
});
// Registering through an invitation is the one case where the trial is held
// back even on an instance with no SMTP: the account is verified and created,
// but it joined somebody else's workspace and does not need a trial to work
// there. Creating a workspace of its own is what starts the clock.
it('grants no trial to an invited collaborator even without a verification step', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
role: 'COMMENTATOR',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Invited Guest',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(201);
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
expect(created.emailVerified).toBeInstanceOf(Date);
expect(created.trialEndsAt).toBeNull();
expect(created.billingTrialConsumedAt).toBeNull();
});
it('starts the trial for somebody signing themselves up without SMTP', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
await post({ name: 'Self Hosted', email: '[email protected]', password: PASSWORD });
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
expect(created.trialEndsAt).toBeInstanceOf(Date);
expect(created.billingTrialConsumedAt).toBeInstanceOf(Date);
});
it('reports the rate limit budget on a successful registration', async () => {
const response = await post({
name: 'Rate Limited',
+514
View File
@@ -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<typeof import('@/lib/r2')>();
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: '<script>' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
});
it('refuses a version that belongs to another video', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
const other = await seedVersion({ providerId: 'bunny', ownerUser: scenario.owner });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: other.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(404);
expect(r2.puts).toHaveLength(0);
});
it('refuses a project COMMENTATOR, who may comment but not edit the cut', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
expect(r2.puts).toHaveLength(0);
});
it('refuses an anonymous caller', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedOut();
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// GET /api/videos/[videoId]/subtitles
// ---------------------------------------------------------------------------
describe('GET /api/videos/[videoId]/subtitles', () => {
it('lists the tracks of one version and tells an editor they may manage them', async () => {
const scenario = await seedSubtitledVersion();
const response = await callRoute(
listSubtitles,
apiRequest(subtitlesUrl(scenario.video.id), {
searchParams: { versionId: scenario.version.id },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(200);
const data = await readData<{
subtitles: Array<{ language: string; canDelete: boolean }>;
canManageSubtitles: boolean;
}>(response);
expect(data.subtitles.map((subtitle) => subtitle.language)).toEqual(['tr']);
expect(data.canManageSubtitles).toBe(true);
expect(data.subtitles[0].canDelete).toBe(true);
});
it('shows a COMMENTATOR the tracks without the ability to manage them', async () => {
const scenario = await seedSubtitledVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(listSubtitles, apiRequest(subtitlesUrl(scenario.video.id)), {
videoId: scenario.video.id,
});
expect(response.status).toBe(200);
const data = await readData<{
subtitles: Array<{ canDelete: boolean }>;
canManageSubtitles: boolean;
}>(response);
expect(data.subtitles).toHaveLength(1);
expect(data.canManageSubtitles).toBe(false);
expect(data.subtitles[0].canDelete).toBe(false);
});
it('refuses a signed-in stranger', async () => {
const scenario = await seedSubtitledVersion();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(listSubtitles, apiRequest(subtitlesUrl(scenario.video.id)), {
videoId: scenario.video.id,
});
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// DELETE /api/videos/[videoId]/subtitles/[subtitleId]
// ---------------------------------------------------------------------------
describe('DELETE /api/videos/[videoId]/subtitles/[subtitleId]', () => {
it('removes the row and the stored object', async () => {
const scenario = await seedSubtitledVersion();
const storedKey = r2.puts[0].key;
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(scenario.video.id)}/${scenario.subtitle.id}`, {
method: 'DELETE',
}),
{ videoId: scenario.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(200);
expect(await db.videoSubtitle.count()).toBe(0);
expect(r2.deletedKeys).toEqual([storedKey]);
});
it('refuses a COMMENTATOR and leaves the track in place', async () => {
const scenario = await seedSubtitledVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(scenario.video.id)}/${scenario.subtitle.id}`, {
method: 'DELETE',
}),
{ videoId: scenario.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(403);
expect(await db.videoSubtitle.count()).toBe(1);
expect(r2.deletedKeys).toHaveLength(0);
});
it('answers 404 for a subtitle that belongs to another video', async () => {
const scenario = await seedSubtitledVersion();
const other = await seedVersion({ providerId: 'bunny', ownerUser: scenario.owner });
signedInAs(scenario.owner);
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(other.video.id)}/${scenario.subtitle.id}`, { method: 'DELETE' }),
{ videoId: other.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(404);
expect(await db.videoSubtitle.count()).toBe(1);
});
});
// ---------------------------------------------------------------------------
// GET /api/upload/subtitle/[filename]
// ---------------------------------------------------------------------------
describe('GET /api/upload/subtitle/[filename]', () => {
function fileNameOf(url: string): string {
return url.slice('/api/upload/subtitle/'.length);
}
it('serves the stored WebVTT to a viewer', async () => {
const scenario = await seedSubtitledVersion();
const filename = fileNameOf(scenario.subtitle.url);
const response = await callRoute(serveSubtitle, apiRequest(scenario.subtitle.url), {
filename,
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/vtt; charset=utf-8');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(await response.text()).toBe(NORMALIZED_VTT);
});
it('refuses a signed-in stranger without reading the object', async () => {
const scenario = await seedSubtitledVersion();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(serveSubtitle, apiRequest(scenario.subtitle.url), {
filename: fileNameOf(scenario.subtitle.url),
});
expect(response.status).toBe(403);
expect(r2.gets).toHaveLength(0);
});
it('rejects a filename that is not a stored subtitle', async () => {
const response = await callRoute(serveSubtitle, apiRequest('/api/upload/subtitle/x'), {
filename: '../../etc/passwd',
});
expect(response.status).toBe(400);
});
});
+38
View File
@@ -14,6 +14,7 @@ import {
DELETE as removeWorkspaceMember,
PATCH as patchWorkspaceMember,
} from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
import { startCardlessTrial } from '@/lib/billing';
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
@@ -180,6 +181,43 @@ describe('POST /api/workspaces', () => {
expect(await db.workspace.count()).toBe(1);
});
// An invited collaborator's trial is deferred, and nothing starts it as a side
// effect: the create is refused until they claim the trial explicitly.
it('refuses a workspace to a collaborator whose trial is still unclaimed', async () => {
const host = await seedProject();
const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
signedInAs(invited);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'My Own' } })
);
expect(response.status).toBe(403);
expect(await db.workspace.count({ where: { ownerId: invited.id } })).toBe(0);
const after = await db.user.findUniqueOrThrow({ where: { id: invited.id } });
expect(after.trialEndsAt).toBeNull();
expect(after.billingTrialConsumedAt).toBeNull();
});
it('lets that collaborator create a workspace once they start their trial', async () => {
const host = await seedProject();
const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
signedInAs(invited);
await startCardlessTrial(invited.id);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'My Own' } })
);
expect(response.status).toBe(201);
expect(await db.workspace.count({ where: { ownerId: invited.id } })).toBe(1);
});
it('refuses a second workspace for an expired user', async () => {
const expired = await createExpiredUser();
await createWorkspace({ ownerId: expired.id });
@@ -652,3 +652,69 @@ describe('useDownloadActions repeated clicks', () => {
expect(clicked).toHaveLength(1);
});
});
describe('useDownloadActions guarding the tab', () => {
function fireBeforeUnload(): BeforeUnloadEvent {
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent;
window.dispatchEvent(event);
return event;
}
// Closing the tab used to throw away a half-pulled file without a word,
// because the browser has no idea a fetch-driven download is running.
it('warns before the tab closes while the bytes are being pulled', async () => {
const pending = deferred<unknown>();
const harness = renderDownload();
fetchMock.mockImplementation((url: string) => {
if (typeof url === 'string' && url.includes('prepare=1')) {
return Promise.resolve(prepareResponse(true, { data: {} }));
}
return pending.promise;
});
let started: Promise<void> | undefined;
await act(async () => {
started = harness.result.current.startDownload();
// Let the prepare call settle so the byte fetch is the pending one.
await Promise.resolve();
});
expect(fireBeforeUnload().defaultPrevented).toBe(true);
await act(async () => {
pending.resolve(fileResponse());
await started;
});
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
it('releases the guard when the download fails', async () => {
downloadResponse = fileResponse({ ok: false });
const harness = renderDownload();
await act(async () => {
await harness.result.current.startDownload();
});
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
// A same-origin proxy download is handed to the browser, which keeps going
// after the tab closes, so nothing should block the unload there.
it('does not warn for a browser-owned download', async () => {
const harness = renderDownload({
activeVersion: makeVersion({
providerId: 'r2',
originalUrl: '/api/upload/video/abc.mp4',
}),
});
await act(async () => {
await harness.result.current.startDownload();
});
expect(clicked).toHaveLength(1);
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
import { beginUnloadGuard, unloadGuardCount } from '@/lib/client/unload-guard';
let addSpy: MockInstance<typeof window.addEventListener>;
let removeSpy: MockInstance<typeof window.removeEventListener>;
/** True when something cancelled the unload, which is what makes the browser
* show its "leave site?" dialog. */
function unloadWasBlocked(): boolean {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
function listenerCalls(spy: MockInstance<typeof window.addEventListener>): number {
return spy.mock.calls.filter(([type]) => type === 'beforeunload').length;
}
beforeEach(() => {
addSpy = vi.spyOn(window, 'addEventListener');
removeSpy = vi.spyOn(window, 'removeEventListener');
});
afterEach(() => {
vi.restoreAllMocks();
// A leaked guard would block the unload for the rest of the session, so a
// test that leaves one behind must fail here rather than in the next test.
expect(unloadGuardCount()).toBe(0);
});
describe('beginUnloadGuard', () => {
it('cancels the unload while a download holds it', () => {
const release = beginUnloadGuard();
const blocked = unloadWasBlocked();
release();
expect(blocked).toBe(true);
});
it('lets the page go once the download is released', () => {
beginUnloadGuard()();
expect(unloadWasBlocked()).toBe(false);
});
it('keeps the listener until the last concurrent download releases', () => {
const releaseA = beginUnloadGuard();
const releaseB = beginUnloadGuard();
expect(listenerCalls(addSpy)).toBe(1);
releaseA();
const stillBlocked = unloadWasBlocked();
releaseB();
expect(stillBlocked).toBe(true);
expect(listenerCalls(removeSpy)).toBe(1);
expect(unloadWasBlocked()).toBe(false);
});
// The download hook releases from a finally block, and a caller could hold
// the returned function longer; a double release must not drop a guard
// another download still holds.
it('ignores a second release', () => {
const releaseA = beginUnloadGuard();
const releaseB = beginUnloadGuard();
releaseA();
releaseA();
const stillBlocked = unloadWasBlocked();
releaseB();
expect(stillBlocked).toBe(true);
});
});
+1
View File
@@ -69,6 +69,7 @@ const REVIEWED_MIGRATIONS = [
'20260801120000_add_acquisition_analytics',
'20260818120000_add_upload_reservation_purpose',
'20260820120000_add_comment_images',
'20260822120000_add_video_subtitles',
];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
+195 -7
View File
@@ -15,6 +15,7 @@ import {
getOrCreateStripeCustomerId,
getStorageCleanupEligibleAt,
getStripeCheckoutState,
getTrialNotice,
getWorkspaceCreationEligibility,
hasActiveSubscription,
hasActiveTrial,
@@ -26,6 +27,7 @@ import {
markSubscriptionCanceledByCustomerId,
selectAuthoritativeSubscription,
startCardlessTrial,
startCardlessTrialOnSignup,
syncStripeCustomerSubscriptions,
syncStripeSubscriptionToUser,
} from '@/lib/billing';
@@ -35,6 +37,7 @@ const dbMock = vi.hoisted(() => ({
workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() },
projectMember: { count: vi.fn() },
invitation: { count: vi.fn() },
analyticsEvent: { createMany: vi.fn() },
}));
@@ -818,6 +821,7 @@ describe('database backed billing helpers', () => {
dbMock.workspace.count.mockReset();
dbMock.workspaceMember.count.mockReset();
dbMock.projectMember.count.mockReset();
dbMock.invitation.count.mockReset();
stripeMock.customers.create.mockReset();
stripeMock.subscriptions.list.mockReset();
dbMock.user.update.mockImplementation(async (args: { data: unknown }) => args.data);
@@ -926,19 +930,188 @@ describe('database backed billing helpers', () => {
});
});
describe('startCardlessTrialOnSignup', () => {
function mockSignup(options: {
email?: string | null;
workspaceMemberships?: number;
projectMemberships?: number;
pendingInvitations?: number;
}) {
dbMock.user.findUnique.mockResolvedValue({
email: 'email' in options ? options.email : '[email protected]',
});
dbMock.workspaceMember.count.mockResolvedValue(options.workspaceMemberships ?? 0);
dbMock.projectMember.count.mockResolvedValue(options.projectMemberships ?? 0);
dbMock.invitation.count.mockResolvedValue(options.pendingInvitations ?? 0);
dbMock.user.updateMany.mockResolvedValue({ count: 1 });
}
it('grants the trial to somebody who signed themselves up', async () => {
mockSignup({});
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(true);
expect(dbMock.user.updateMany).toHaveBeenCalled();
});
// The credentials route accepts the invitation in the same request that
// creates the account, so the membership is what gives the collaborator away.
it('holds the trial back for a member of somebody else workspace', async () => {
mockSignup({ workspaceMemberships: 1 });
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
expect(dbMock.user.updateMany).not.toHaveBeenCalled();
});
it('holds the trial back for a member of somebody else project', async () => {
mockSignup({ projectMemberships: 1 });
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
expect(dbMock.user.updateMany).not.toHaveBeenCalled();
});
// An OAuth signup creates the account before the invitation is accepted, so
// there the still-pending invitation is the only signal available.
it('holds the trial back while an invitation to this address is pending', async () => {
mockSignup({ pendingInvitations: 1 });
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
expect(dbMock.user.updateMany).not.toHaveBeenCalled();
});
it('only counts invitations that are still open', async () => {
mockSignup({});
await startCardlessTrialOnSignup('u1');
expect(dbMock.invitation.count).toHaveBeenCalledWith({
where: {
email: '[email protected]',
status: 'PENDING',
expiresAt: { gt: NOW },
},
});
});
it('does not look for invitations when the account has no address', async () => {
mockSignup({ email: null });
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(true);
expect(dbMock.invitation.count).not.toHaveBeenCalled();
});
it('grants nothing when billing is switched off entirely', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
mockSignup({});
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
expect(dbMock.user.findUnique).not.toHaveBeenCalled();
});
});
describe('getTrialNotice', () => {
function mockNotice(options: {
trialEndsAt?: Date | null;
status?: BillingSubscriptionStatus;
billingAccessEndedAt?: Date | null;
ownedWorkspaces?: number;
collaborations?: number;
}) {
dbMock.user.findUnique.mockResolvedValue({
subscriptionStatus: options.status ?? BillingSubscriptionStatus.FREE,
trialEndsAt: options.trialEndsAt ?? null,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: options.billingAccessEndedAt ?? null,
});
dbMock.workspace.count
.mockResolvedValueOnce(options.ownedWorkspaces ?? 1)
.mockResolvedValueOnce(options.collaborations ?? 0);
}
it('says nothing while the trial still has more than the notice window left', async () => {
mockNotice({ trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS) });
await expect(getTrialNotice('u1')).resolves.toBeNull();
});
it('counts down once the trial is inside the notice window', async () => {
mockNotice({ trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS) });
const notice = await getTrialNotice('u1');
expect(notice?.kind).toBe('ending');
});
it('reports the trial as ended along with the date the media is kept until', async () => {
const endedAt = new Date(NOW.getTime() - 2 * DAY_MS);
mockNotice({ trialEndsAt: endedAt, billingAccessEndedAt: endedAt });
const notice = await getTrialNotice('u1');
expect(notice?.kind).toBe('ended');
expect(notice?.contentKeptUntil?.getTime()).toBe(endedAt.getTime() + 15 * DAY_MS);
});
// The banner is about this account's own deadline and its own media. A guest
// in a paying customer's workspace has neither, and was being told a paying
// customer's work would be deleted.
it('says nothing to a collaborator who owns no workspace of their own', async () => {
mockNotice({
trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
ownedWorkspaces: 0,
collaborations: 1,
});
await expect(getTrialNotice('u1')).resolves.toBeNull();
});
it('still warns a collaborator who also owns a workspace', async () => {
mockNotice({
trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
ownedWorkspaces: 1,
collaborations: 1,
});
expect((await getTrialNotice('u1'))?.kind).toBe('ending');
});
// A solo account that has not set anything up yet is not a collaborator, and
// its deadline is real.
it('still warns an account that owns nothing and collaborates nowhere', async () => {
mockNotice({
trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
ownedWorkspaces: 0,
collaborations: 0,
});
expect((await getTrialNotice('u1'))?.kind).toBe('ending');
});
it('leaves the ownership queries unrun when there is no notice to show', async () => {
mockNotice({ trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS) });
await getTrialNotice('u1');
expect(dbMock.workspace.count).not.toHaveBeenCalled();
});
});
describe('getWorkspaceCreationEligibility', () => {
function mockEligibility(options: {
user?: Record<string, unknown> | null;
owned?: number;
invited?: number;
projectOnly?: number;
/** Whether the once-per-account trial has already been spent and run out. */
consumed?: boolean;
}) {
dbMock.user.findUnique.mockResolvedValue(
options.user === undefined
? {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: null,
billingTrialConsumedAt: null,
billingTrialConsumedAt: options.consumed
? new Date(NOW.getTime() - 30 * DAY_MS)
: null,
stripeCustomerId: null,
stripeSubscriptionId: null,
stripePriceId: null,
@@ -1045,7 +1218,7 @@ describe('database backed billing helpers', () => {
});
it('blocks an expired owner who already has a workspace', async () => {
mockEligibility({ owned: 1 });
mockEligibility({ owned: 1, consumed: true });
const result = await getWorkspaceCreationEligibility('u1');
@@ -1053,27 +1226,41 @@ describe('database backed billing helpers', () => {
expect(result.reason).toContain('Your trial has ended');
});
it('blocks an expired user who only collaborates in someone else workspace', async () => {
// The deferred trial stays the collaborator's to spend, but never as a side
// effect: the workspace door stays shut until they explicitly start it, which
// is what `canStartTrial` tells the UI to offer.
it('blocks a collaborator whose trial is still unclaimed but offers to start it', async () => {
mockEligibility({ owned: 0, invited: 1 });
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
expect(result.reason).toContain('currently collaborating');
expect(result.canStartTrial).toBe(true);
expect(result.reason).toContain('Start it to create a workspace of your own');
});
it('counts project-only collaboration towards the same block', async () => {
it('offers the same deferred trial to a project-only collaborator', async () => {
mockEligibility({ owned: 0, projectOnly: 2 });
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
expect(result.reason).toContain('currently collaborating');
expect(result.canStartTrial).toBe(true);
expect(result.projectOnlyCollaborationCount).toBe(2);
});
it('blocks a collaborator whose own trial has already run out', async () => {
mockEligibility({ owned: 0, invited: 1, consumed: true });
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
expect(result.canStartTrial).toBe(false);
expect(result.reason).toContain('Your trial has ended');
});
it('prefers the trial-ended reason when the user both owns and collaborates', async () => {
mockEligibility({ owned: 1, invited: 1 });
mockEligibility({ owned: 1, invited: 1, consumed: true });
expect((await getWorkspaceCreationEligibility('u1')).reason).toContain(
'Your trial has ended'
@@ -1136,6 +1323,7 @@ describe('database backed billing helpers', () => {
expect(overview.workspaceCreation).toEqual({
canCreateWorkspace: true,
canStartTrial: false,
reason: null,
ownedWorkspaceCount: 2,
invitedWorkspaceCount: 1,
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import {
decodeSubtitleBuffer,
getSubtitleExtension,
MAX_SUBTITLE_CUES,
normalizeSubtitleFile,
normalizeSubtitleLanguage,
parseSubtitleCues,
sanitizeSubtitleLabel,
SAFE_SUBTITLE_PROXY_PATH,
serializeWebVtt,
subtitleProxyPathToObjectKey,
} from '@/lib/subtitle-validation';
const UUID = '11111111-2222-3333-4444-555555555555';
function encode(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
describe('getSubtitleExtension', () => {
it('accepts the two subtitle formats and nothing else', () => {
expect(getSubtitleExtension('cut.srt')).toBe('srt');
expect(getSubtitleExtension('cut.VTT')).toBe('vtt');
expect(getSubtitleExtension('cut.ass')).toBeNull();
expect(getSubtitleExtension('cut.srt.exe')).toBeNull();
});
});
describe('normalizeSubtitleLanguage', () => {
it('lowercases so a re-upload replaces the track it means to', () => {
expect(normalizeSubtitleLanguage('TR')).toBe('tr');
expect(normalizeSubtitleLanguage(' en-US ')).toBe('en-us');
expect(normalizeSubtitleLanguage('zh-Hant-TW')).toBe('zh-hant-tw');
});
it('rejects anything that is not a language tag', () => {
expect(normalizeSubtitleLanguage('')).toBeNull();
expect(normalizeSubtitleLanguage('t')).toBeNull();
expect(normalizeSubtitleLanguage('tr; drop table')).toBeNull();
expect(normalizeSubtitleLanguage('<script>')).toBeNull();
expect(normalizeSubtitleLanguage(42)).toBeNull();
});
});
describe('sanitizeSubtitleLabel', () => {
it('falls back when the label is empty after cleaning', () => {
expect(sanitizeSubtitleLabel(' ', 'TR')).toBe('TR');
expect(sanitizeSubtitleLabel(undefined, 'TR')).toBe('TR');
});
it('strips control characters and collapses whitespace', () => {
expect(sanitizeSubtitleLabel('Türk\u0000\n çe ', 'TR')).toBe('Türk çe');
});
it('caps the length', () => {
expect(sanitizeSubtitleLabel('a'.repeat(200), 'TR')).toHaveLength(60);
});
});
describe('decodeSubtitleBuffer', () => {
it('reads UTF-8 and drops the byte order mark', () => {
expect(decodeSubtitleBuffer(encode('\uFEFFmerhaba'))).toBe('merhaba');
});
it('falls back to a legacy codepage rather than rejecting the file', () => {
// 0xFD is "ı" in windows-1254 and not valid UTF-8 on its own.
const decoded = decodeSubtitleBuffer(new Uint8Array([0x61, 0xfd, 0x62]));
expect(decoded).not.toBeNull();
expect(decoded).toHaveLength(3);
});
});
describe('parseSubtitleCues', () => {
it('parses SRT, comma decimals and sequence numbers included', () => {
const cues = parseSubtitleCues(
[
'1',
'00:00:01,000 --> 00:00:02,500',
'Merhaba',
'',
'2',
'00:00:03,000 --> 00:00:04,000',
'Dünya',
'',
].join('\n')
);
expect(cues).toEqual([
{ start: 1, end: 2.5, text: 'Merhaba' },
{ start: 3, end: 4, text: 'Dünya' },
]);
});
it('parses WebVTT with cue ids, settings and short timestamps', () => {
const cues = parseSubtitleCues(
['WEBVTT', '', 'intro', '00:01.000 --> 00:02.000 align:start position:10%', 'Hello', ''].join(
'\n'
)
);
expect(cues).toEqual([{ start: 1, end: 2, text: 'Hello' }]);
});
it('skips NOTE, STYLE and REGION blocks', () => {
const cues = parseSubtitleCues(
[
'WEBVTT',
'',
'NOTE this is a comment',
'still the comment',
'',
'STYLE',
'::cue { color: red }',
'',
'00:00:01.000 --> 00:00:02.000',
'Kept',
'',
].join('\n')
);
expect(cues).toEqual([{ start: 1, end: 2, text: 'Kept' }]);
});
it('drops cues that end before they start and cues with no text', () => {
const cues = parseSubtitleCues(
[
'00:00:05,000 --> 00:00:02,000',
'Backwards',
'',
'00:00:06,000 --> 00:00:07,000',
'',
'00:00:08,000 --> 00:00:09,000',
'Good',
'',
].join('\n')
);
expect(cues).toEqual([{ start: 8, end: 9, text: 'Good' }]);
});
it('keeps known cue markup and removes everything else', () => {
const cues = parseSubtitleCues(
['00:00:01,000 --> 00:00:02,000', '<i>tilt</i><script>alert(1)</script>{\\an8}', ''].join(
'\n'
)
);
expect(cues[0].text).toBe('<i>tilt</i>alert(1)');
});
it('escapes the leftovers of a rejected tag so it cannot be reassembled', () => {
// Deleting `<b>` out of the middle would close the two halves into a `<script>` that
// was never written. Escaping what is left over is what stops that.
const cues = parseSubtitleCues(
['00:00:01,000 --> 00:00:02,000', '<scr<b>ipt>alert(1)', ''].join('\n')
);
expect(cues[0].text).toBe('&lt;scr<b>ipt&gt;alert(1)');
expect(cues[0].text).not.toContain('<script');
});
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 --&gt; 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();
});
});
+10
View File
@@ -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<T = unknown>(module: string, option: string): T | undefined;
}
interface PlayerOptions {