mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Compare commits
16
Commits
v0.1.1
..
79bba5e7a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79bba5e7a1 | ||
|
|
ab03f7c378 | ||
|
|
43cc54c0ae | ||
|
|
54e99cb4ab | ||
|
|
5f061d1b09 | ||
|
|
59a64141ee | ||
|
|
4b3c3934dd | ||
|
|
c5c9da1e30 | ||
|
|
07a6bfbef4 | ||
|
|
d894eeb0e4 | ||
|
|
cba8163286 | ||
|
|
74e4b4353e | ||
|
|
a709ca8544 | ||
|
|
d981d98cf5 | ||
|
|
1f3c6b3f1e | ||
|
|
6f575e48bf |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 else’s workspace or project. Start a subscription to create a workspace of your own.';
|
||||
'You are collaborating in someone else’s 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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 }]>`
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function sanitizeCueLine(line: string): string {
|
||||
// ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT parser
|
||||
// renders them as literal text, which is never what the author meant.
|
||||
const withoutOverrides = line.replace(/\{\\[^}]*\}/g, '');
|
||||
|
||||
let sanitized = '';
|
||||
let cursor = 0;
|
||||
CUE_TAG.lastIndex = 0;
|
||||
for (let match = CUE_TAG.exec(withoutOverrides); match; match = CUE_TAG.exec(withoutOverrides)) {
|
||||
sanitized += escapeCueText(withoutOverrides.slice(cursor, match.index));
|
||||
if (ALLOWED_CUE_TAGS.some((allowed) => allowed.test(match[0]))) {
|
||||
sanitized += match[0];
|
||||
}
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
sanitized += escapeCueText(withoutOverrides.slice(cursor));
|
||||
|
||||
return sanitized.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SRT or WebVTT into cues. Unknown blocks (NOTE, STYLE, REGION, cue identifiers,
|
||||
* SRT sequence numbers) are skipped rather than carried over.
|
||||
*/
|
||||
export function parseSubtitleCues(input: string): SubtitleCue[] {
|
||||
const lines = input.replace(/\r\n?/g, '\n').split('\n');
|
||||
const cues: SubtitleCue[] = [];
|
||||
|
||||
let index = 0;
|
||||
// A STYLE or REGION block runs until the next blank line and may itself contain no
|
||||
// timing, so it is skipped wholesale rather than line by line.
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(?:WEBVTT|NOTE|STYLE|REGION)\b/.test(trimmed)) {
|
||||
index += 1;
|
||||
while (index < lines.length && lines[index].trim()) index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A cue may be preceded by an identifier line (an SRT sequence number, or a VTT cue
|
||||
// id). The timing is then on the following line.
|
||||
let timing = parseTimingLine(trimmed);
|
||||
if (!timing) {
|
||||
const next = lines[index + 1]?.trim();
|
||||
if (!next) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
timing = parseTimingLine(next);
|
||||
if (!timing) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
index += 1;
|
||||
|
||||
const textLines: string[] = [];
|
||||
while (index < lines.length && lines[index].trim()) {
|
||||
const sanitized = sanitizeCueLine(lines[index]);
|
||||
if (sanitized.trim()) textLines.push(sanitized);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (timing.end <= timing.start) continue;
|
||||
// The cap can land inside an escape the sanitiser wrote, so a dangling `<` tail is
|
||||
// trimmed rather than left for the parser to render as text.
|
||||
const text = textLines
|
||||
.join('\n')
|
||||
.slice(0, MAX_CUE_TEXT_LENGTH)
|
||||
.replace(/&[a-z]{0,5}$/i, '')
|
||||
.trim();
|
||||
if (!text) continue;
|
||||
|
||||
cues.push({ start: timing.start, end: timing.end, text });
|
||||
if (cues.length >= MAX_SUBTITLE_CUES) break;
|
||||
}
|
||||
|
||||
return cues;
|
||||
}
|
||||
|
||||
export function serializeWebVtt(cues: SubtitleCue[]): string {
|
||||
const body = cues
|
||||
.map((cue) => `${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}`)
|
||||
.join('\n\n');
|
||||
return `WEBVTT\n\n${body}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole pipeline: bytes in, a canonical WebVTT string out, or a message explaining
|
||||
* what is wrong with the file in terms the person who uploaded it can act on.
|
||||
*/
|
||||
export function normalizeSubtitleFile(buffer: Uint8Array): SubtitleNormalizeResult {
|
||||
if (buffer.byteLength === 0) {
|
||||
return { ok: false, error: 'Subtitle file is empty' };
|
||||
}
|
||||
|
||||
const decoded = decodeSubtitleBuffer(buffer);
|
||||
if (decoded === null) {
|
||||
return { ok: false, error: 'Could not read the subtitle file. Save it as UTF-8 and retry.' };
|
||||
}
|
||||
|
||||
const cues = parseSubtitleCues(decoded);
|
||||
if (cues.length === 0) {
|
||||
return { ok: false, error: 'No subtitle cues found. Upload a valid .srt or .vtt file.' };
|
||||
}
|
||||
|
||||
const vtt = serializeWebVtt(cues);
|
||||
if (Buffer.byteLength(vtt, 'utf8') > MAX_NORMALIZED_SUBTITLE_SIZE) {
|
||||
return { ok: false, error: 'Subtitle file is too large after conversion' };
|
||||
}
|
||||
|
||||
return { ok: true, vtt, cueCount: cues.length };
|
||||
}
|
||||
|
||||
export function subtitleFileNameToProxyUrl(fileName: string): string {
|
||||
return `${SUBTITLE_PROXY_PREFIX}${fileName}`;
|
||||
}
|
||||
|
||||
export function extractSubtitleFileNameFromProxyUrl(url: string): string | null {
|
||||
if (!SAFE_SUBTITLE_PROXY_PATH.test(url)) return null;
|
||||
return url.slice(SUBTITLE_PROXY_PREFIX.length) || null;
|
||||
}
|
||||
|
||||
export function subtitleProxyPathToObjectKey(url: string): string | null {
|
||||
const fileName = extractSubtitleFileNameFromProxyUrl(url);
|
||||
if (!fileName) return null;
|
||||
return `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
@@ -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.
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('<scr<b>ipt>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 --> 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();
|
||||
});
|
||||
});
|
||||
Vendored
+10
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user