Files
OpenFrame/app/api/videos/[videoId]/assets/route.ts
T
yusufipek 63288761ed fix(storage): count a finished Bunny upload the moment it lands
Two reasons the number on the storage page could read as nothing.

The per-user Bunny figure was computed inside a two minute cache. The declared
size lands on the row in the same transaction that deletes the reservation, so
for up to two minutes an upload that had just succeeded counted as nothing:
usage fell back towards zero and the next upload was measured against a total
that ignored the one before it. The call to Bunny stays cached, because it is
the slow half and its answer is the same for everybody. The join against our own
rows is now read fresh, per user, on every check.

A failed call to Bunny returned an empty map before it had looked at a single
row, so an account with gigabytes of declared uploads read as empty whenever
Bunny was unreachable. Bunny's figure being gone is not a reason to forget the
sizes we wrote down ourselves.

The rule for which of the two numbers to charge is unchanged, and the comment
above it now says why rather than guessing. What Bunny reports mid-encode is
partial: storageSize counts what has been written so far and climbs as each
rendition lands. A six minute cut uploaded at 2.5 GB read as 475 MB halfway
through and settled above 3 GB once it finished, because Bunny keeps the
original alongside every rendition. Taking the larger of the declared size and
Bunny's is right at every point on that curve; taking Bunny's whenever it is
non-zero would hand most of the quota back in the middle of an encode.

The settings card also claimed a 200 GB limit while showing a 3 GB one, and told
a trial account to delete files or contact support.
2026-08-18 11:42:30 +03:00

733 lines
28 KiB
TypeScript

import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { VideoAssetProvider } from '@prisma/client';
import { NextRequest, NextResponse } from 'next/server';
import { parseVideoUrl, getThumbnailUrl } from '@/lib/video-providers';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { db } from '@/lib/db';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { readBunnyUploadGrant } from '@/lib/bunny-upload-token';
import { deriveGuestUploadContext, readGuestUploadGrant } from '@/lib/guest-upload-token';
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
import {
SAFE_BUNNY_VIDEO_ID,
SAFE_IMAGE_PROXY_PATH,
SAFE_AUDIO_PROXY_PATH,
SAFE_VIDEO_PROXY_PATH,
canDeleteAssetForViewer,
extractImageFileNameFromProxyUrl,
extractImageKeyFromProxyUrl,
extractAudioKeyFromProxyUrl,
extractAudioFileNameFromProxyUrl,
extractVideoFileNameFromProxyUrl,
getVideoAssetAccessContext,
sanitizeAssetDisplayName,
} from '@/lib/video-assets';
import { logError } from '@/lib/logger';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import {
enforceStorageQuota,
reserveStorageQuota,
releaseStorageReservation,
getStorageContextForUser,
storageExceededResponse,
UPLOAD_RESERVATION_PURPOSES,
type StorageContext,
type UploadReservationPurpose,
} from '@/lib/storage-quota';
import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
// Sentinel thrown inside a Prisma transaction when a fake reservationId is
// supplied and the fallback quota check finds the limit would be exceeded.
class QuotaExceededInTxError extends Error {}
type RouteParams = { params: Promise<{ videoId: string }> };
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
const ASSET_LIST_DEFAULT_LIMIT = 40;
const ASSET_LIST_MAX_LIMIT = 100;
const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
type AssetWithViewerFields = {
id: string;
videoId: string;
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
provider: VideoAssetProvider;
displayName: string;
sourceUrl: string;
providerVideoId: string | null;
thumbnailUrl: string | null;
uploadedByUserId?: string | null;
uploadedByGuestName: string | null;
uploadedByGuestIdentityId?: string | null;
createdAt: Date;
updatedAt: Date;
uploadedByUser: {
id: string;
name: string | null;
image: string | null;
} | null;
};
type YouTubeTitleCacheRecord = {
title: string | null;
expiresAt: number;
};
const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>();
function isAllowedBunnyMediaUrl(url: string): boolean {
const allowedHosts = new Set<string>(['iframe.mediadelivery.net', 'video.bunnycdn.com']);
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
if (bunnyCdnHostname) {
allowedHosts.add(bunnyCdnHostname);
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return false;
return allowedHosts.has(parsed.hostname);
} catch {
return false;
}
}
function shapeAssetForViewer(
asset: AssetWithViewerFields,
canExposeSource: boolean,
canDelete: boolean
) {
return {
id: asset.id,
videoId: asset.videoId,
kind: asset.kind,
provider: asset.provider,
displayName: asset.displayName,
sourceUrl: canExposeSource ? asset.sourceUrl : null,
providerVideoId: canExposeSource ? asset.providerVideoId : null,
thumbnailUrl: canExposeSource ? asset.thumbnailUrl : null,
uploadedByUserId: asset.uploadedByUserId ?? null,
uploadedByGuestName: asset.uploadedByGuestName,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
uploadedByUser: asset.uploadedByUser,
canDelete,
};
}
function normalizeEtag(value: string): string {
return value.trim().replace(/^W\//, '');
}
function parsePaginationParam(value: string | null, fallback: number): number {
const parsed = Number.parseInt(value ?? '', 10);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
return parsed;
}
async function fetchYouTubeTitleFromProvider(videoId: string): Promise<string | null> {
const url = `https://www.youtube.com/oembed?url=${encodeURIComponent(`https://www.youtube.com/watch?v=${videoId}`)}&format=json`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(url, {
method: 'GET',
signal: controller.signal,
cache: 'no-store',
});
if (!response.ok) return null;
const payload = (await response.json().catch(() => null)) as { title?: string } | null;
if (!payload?.title || typeof payload.title !== 'string') return null;
return payload.title.trim() || null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
async function fetchYouTubeTitle(videoId: string): Promise<string | null> {
const now = Date.now();
const cached = youtubeTitleCache.get(videoId);
if (cached && cached.expiresAt > now) {
return cached.title;
}
const title = await fetchYouTubeTitleFromProvider(videoId);
youtubeTitleCache.set(videoId, {
title,
expiresAt: now + YOUTUBE_TITLE_CACHE_TTL_MS,
});
return title;
}
type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
async function isFreshImageAttachment(url: string): Promise<AttachmentCheck> {
const key = extractImageKeyFromProxyUrl(url);
if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
try {
const head = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
} catch {
return { isFresh: false, sizeBytes: BigInt(0) };
}
}
async function isFreshAudioAttachment(url: string): Promise<AttachmentCheck> {
const key = extractAudioKeyFromProxyUrl(url);
if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
try {
const head = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
} catch {
return { isFresh: false, sizeBytes: BigInt(0) };
}
}
// GET /api/videos/[videoId]/assets
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-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 requestedLimit = parsePaginationParam(
request.nextUrl.searchParams.get('limit'),
ASSET_LIST_DEFAULT_LIMIT
);
const requestedOffset = parsePaginationParam(request.nextUrl.searchParams.get('offset'), 0);
const limit = Math.min(ASSET_LIST_MAX_LIMIT, Math.max(1, requestedLimit));
const offset = requestedOffset;
const includeDeleteMetadata = context.canUploadAssets;
const assetsRevision = await db.videoAsset.aggregate({
where: { videoId },
_count: { id: true },
_max: { updatedAt: true },
});
const etag = `"assets:${videoId}:${limit}:${offset}:${includeDeleteMetadata ? 1 : 0}:${context.canDownloadAssets ? 1 : 0}:${assetsRevision._count.id}:${assetsRevision._max.updatedAt?.getTime() ?? 0}"`;
const ifNoneMatch = request.headers.get('if-none-match');
if (ifNoneMatch) {
const matches = ifNoneMatch.split(',').map(normalizeEtag).includes(normalizeEtag(etag));
if (matches) {
const notModified = new NextResponse(null, { status: 304 });
notModified.headers.set('ETag', etag);
return withCacheControl(notModified, 'private, no-cache');
}
}
const assets = await db.videoAsset.findMany({
where: { videoId },
skip: offset,
take: limit + 1,
orderBy: { createdAt: 'desc' },
select: {
id: true,
videoId: true,
kind: true,
provider: true,
displayName: true,
sourceUrl: true,
providerVideoId: true,
thumbnailUrl: true,
uploadedByUserId: includeDeleteMetadata,
uploadedByGuestName: true,
uploadedByGuestIdentityId: includeDeleteMetadata,
createdAt: true,
updatedAt: true,
uploadedByUser: {
select: { id: true, name: true, image: true },
},
},
});
const hasMore = assets.length > limit;
const pagedAssets = hasMore ? assets.slice(0, limit) : assets;
const response = successResponse({
assets: pagedAssets.map((asset) =>
shapeAssetForViewer(
asset,
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
context.canDownloadAssets ||
((asset.provider === VideoAssetProvider.R2_AUDIO ||
asset.provider === VideoAssetProvider.R2_VIDEO) &&
context.hasViewAccess),
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
)
),
pagination: {
limit,
offset,
hasMore,
nextOffset: hasMore ? offset + limit : null,
},
canUploadAssets: context.canUploadAssets,
canDownloadAssets: context.canDownloadAssets,
});
response.headers.set('ETag', etag);
return withCacheControl(response, 'private, no-cache');
} catch (error) {
logError('Error fetching video assets:', error);
return apiErrors.internalError('Failed to fetch assets');
}
}
// POST /api/videos/[videoId]/assets
export async function POST(request: NextRequest, { params }: RouteParams) {
let reservationId: string | null = null;
// What the reservation above was opened for, and who it is billed to. A hold is
// only ever consumed by the flow that opened it: the id below can arrive in the
// request body, and every hold an account owns is billed to the same user, so
// the id alone would let an image being attached release a video upload that
// was still in flight.
let reservationPurpose: UploadReservationPurpose | null = null;
let reservationBilledUserId: string | null = null;
// Carried out of the try so the quota refusal in the catch can be worded for
// the account it is refusing, rather than telling a trial to delete files.
let storageForRefusal: StorageContext | null = null;
let finalizedR2AssetSession: {
sessionId: string;
reservationId: string | null;
billedUserId: string;
objectKey: string;
viewerUserId: string;
projectId: string;
} | null = null;
try {
const limited = await rateLimit(request, 'asset-create');
if (limited) return limited;
const { videoId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
if (!context) return apiErrors.notFound('Video');
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
const body = await request.json().catch(() => null);
const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : '';
if (
provider !== VideoAssetProvider.R2_IMAGE &&
provider !== VideoAssetProvider.YOUTUBE &&
provider !== VideoAssetProvider.BUNNY &&
provider !== VideoAssetProvider.R2_AUDIO &&
provider !== VideoAssetProvider.R2_VIDEO
) {
return apiErrors.badRequest('Invalid provider');
}
const isGuest = !context.viewerUserId;
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
const requestedDisplayName = typeof body?.displayName === 'string' ? body.displayName : null;
// Optional reservation ID created by the upload route for atomic quota accounting
reservationId = typeof body?.reservationId === 'string' ? body.reservationId.trim() : null;
let displayName = '';
let sourceUrl = '';
let providerVideoId: string | null = null;
let thumbnailUrl: string | null = null;
let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE';
let assetSizeBytes = BigInt(0);
const billedUserId = context.video.project.workspace.ownerId;
reservationBilledUserId = billedUserId;
if (provider === VideoAssetProvider.R2_IMAGE) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
if (!SAFE_IMAGE_PROXY_PATH.test(sourceUrl)) {
return apiErrors.badRequest('Image URL must reference an uploaded image file');
}
const imageCheck = await isFreshImageAttachment(sourceUrl);
if (!imageCheck.isFresh) {
return apiErrors.badRequest('Image upload expired. Please upload again.');
}
assetSizeBytes = imageCheck.sizeBytes;
// Always use the advisory-locked reservation path so concurrent uploads
// see each other's in-flight sizes, eliminating the TOCTOU race. When
// the client already supplied a reservationId (new upload flow) the
// existing reservation is consumed in the transaction below. For the
// backward-compat path (no reservationId) we create one here.
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.IMAGE;
if (!reservationId) {
const reserveResult = await reserveStorageQuota(
billedUserId,
assetSizeBytes,
UPLOAD_RESERVATION_PURPOSES.IMAGE
);
if ('error' in reserveResult) return reserveResult.error;
reservationId = reserveResult.reservationId;
}
const fileName = extractImageFileNameFromProxyUrl(sourceUrl);
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Image');
thumbnailUrl = sourceUrl;
kind = 'IMAGE';
}
if (provider === VideoAssetProvider.R2_AUDIO) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
if (!SAFE_AUDIO_PROXY_PATH.test(sourceUrl)) {
return apiErrors.badRequest('Audio URL must reference an uploaded audio file');
}
const audioCheck = await isFreshAudioAttachment(sourceUrl);
if (!audioCheck.isFresh) {
return apiErrors.badRequest('Audio upload expired. Please upload again.');
}
assetSizeBytes = audioCheck.sizeBytes;
// Same reservation logic as R2_IMAGE above
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.AUDIO;
if (!reservationId) {
const reserveResult = await reserveStorageQuota(
billedUserId,
assetSizeBytes,
UPLOAD_RESERVATION_PURPOSES.AUDIO
);
if ('error' in reserveResult) return reserveResult.error;
reservationId = reserveResult.reservationId;
}
const fileName = extractAudioFileNameFromProxyUrl(sourceUrl);
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Voice Recording');
kind = 'AUDIO';
}
if (provider === VideoAssetProvider.YOUTUBE) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
const parsedSource = parseVideoUrl(sourceUrl);
if (!parsedSource || parsedSource.providerId !== 'youtube') {
return apiErrors.badRequest('Only YouTube URLs are allowed for this provider');
}
const sourceUrlError = validateUrl(parsedSource.originalUrl, 'YouTube URL');
if (sourceUrlError) return apiErrors.badRequest(sourceUrlError);
providerVideoId = parsedSource.videoId;
const youtubeTitle = await fetchYouTubeTitle(providerVideoId);
displayName = sanitizeAssetDisplayName(
requestedDisplayName,
youtubeTitle || `YouTube ${providerVideoId}`
);
sourceUrl = parsedSource.originalUrl;
thumbnailUrl = getThumbnailUrl(parsedSource, 'large');
kind = 'VIDEO';
}
if (provider === VideoAssetProvider.R2_VIDEO) {
if (!context.viewerUserId) {
return apiErrors.forbidden('R2 video asset uploads require sign-in');
}
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null;
if (!SAFE_VIDEO_PROXY_PATH.test(sourceUrl)) {
return apiErrors.badRequest('Video URL must reference an uploaded video file');
}
if (!objectKey || !uploadToken) {
return apiErrors.badRequest('objectKey and uploadToken are required');
}
if (thumbnailUrl && !SAFE_IMAGE_PROXY_PATH.test(thumbnailUrl)) {
return apiErrors.badRequest('Thumbnail URL must reference an uploaded image file');
}
const finalizeResult = await finalizeR2VideoUpload({
userId: context.viewerUserId,
projectId: context.video.projectId,
videoUrl: sourceUrl,
objectKey,
uploadToken,
});
if (!finalizeResult.ok) {
if (finalizeResult.status === 403) {
return apiErrors.forbidden(finalizeResult.error);
}
return apiErrors.badRequest(finalizeResult.error);
}
assetSizeBytes = finalizeResult.sizeBytes;
reservationId = finalizeResult.reservationId;
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.R2_VIDEO;
if (!thumbnailUrl) {
thumbnailUrl = finalizeResult.thumbnailProxyUrl;
}
const fileName = extractVideoFileNameFromProxyUrl(sourceUrl);
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Video');
kind = 'VIDEO';
finalizedR2AssetSession = {
sessionId: finalizeResult.sessionId,
reservationId: finalizeResult.reservationId,
billedUserId: finalizeResult.billedUserId,
objectKey: finalizeResult.objectKey,
viewerUserId: context.viewerUserId,
projectId: context.video.projectId,
};
}
if (provider === VideoAssetProvider.BUNNY) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
providerVideoId =
typeof body?.providerVideoId === 'string' ? body.providerVideoId.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null;
if (!providerVideoId || !SAFE_BUNNY_VIDEO_ID.test(providerVideoId)) {
return apiErrors.badRequest('Invalid Bunny video id');
}
const sourceUrlError = validateUrl(sourceUrl, 'Bunny source URL');
if (sourceUrlError) return apiErrors.badRequest(sourceUrlError);
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Bunny thumbnail URL');
if (thumbnailUrlError) return apiErrors.badRequest(thumbnailUrlError);
if (thumbnailUrl && !isAllowedBunnyMediaUrl(thumbnailUrl)) {
return apiErrors.badRequest('Bunny thumbnail URL must use an approved Bunny host');
}
if (!isAllowedBunnyMediaUrl(sourceUrl)) {
return apiErrors.badRequest('Bunny source URL must use an approved Bunny host');
}
if (!uploadToken) {
return apiErrors.badRequest('uploadToken is required');
}
if (context.viewerUserId) {
const grant = readBunnyUploadGrant(uploadToken, {
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: providerVideoId,
});
if (!grant) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
// Charged from now on the size the upload was admitted on: Bunny reports
// nothing until it has finished encoding, and an asset that reads as zero
// bytes for an hour is an hour of uploads measured against a total that
// does not include it.
assetSizeBytes = grant.declaredSizeBytes ?? BigInt(0);
reservationId = grant.reservationId;
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.BUNNY;
} else {
const shareSession = getShareSessionFromRequest(request, context.video.id);
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
if (!expectedContext) {
return apiErrors.forbidden('Missing trusted client IP header');
}
// Read rather than merely verified, for the same reason as above: a guest
// upload that reads as zero bytes until Bunny finishes encoding is an hour
// of the owner's quota spent on nothing. The grant is bound to this Bunny
// video, so the size and the hold it names belong to this upload and no
// other.
const guestGrant = readGuestUploadGrant(
uploadToken,
{
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
},
providerVideoId
);
if (!guestGrant) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
assetSizeBytes = guestGrant.declaredSizeBytes ?? BigInt(0);
reservationId = guestGrant.reservationId;
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.BUNNY;
}
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
if (!thumbnailUrl) {
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
if (bunnyCdnHostname) {
thumbnailUrl = `https://${bunnyCdnHostname}/${providerVideoId}/thumbnail.jpg`;
}
}
kind = 'VIDEO';
const quotaError = await enforceStorageQuota(billedUserId, assetSizeBytes);
if (quotaError) return quotaError;
}
// Pre-fetch Bunny storage BEFORE entering the transaction to avoid making an
// HTTP call while holding a DB connection open (connection-pool exhaustion
// risk under adversarial load). Mirrors the discipline in reserveStorageQuota.
// Needed by every provider that can reach the invalid-reservation fallback
// quota check below, Bunny included. Leaving Bunny out read its own storage as
// zero, and on an account whose storage is all Bunny that made the fallback a
// check that could not fail.
const preFetchedBunnyBytes =
provider === VideoAssetProvider.YOUTUBE ? null : await getUserBunnyStorageBytes(billedUserId);
// The ceiling this account is actually held to, read for the fallback below.
// It used to compare against the plan limit, which is 200 GiB whoever is
// asking: a caller who quoted a reservation id that no longer existed was
// measured against the paid ceiling even on a trial worth 3 GiB.
const storage = await getStorageContextForUser(billedUserId);
storageForRefusal = storage;
// Create the VideoAsset and atomically consume the upload reservation (if any)
// so the spot is never double-counted.
const created = await db.$transaction(async (tx) => {
if (reservationId && reservationPurpose) {
// Acquire the per-user advisory lock unconditionally so both the happy path
// (valid reservation) and the fallback path (fake/expired reservation ID) are
// serialised — eliminating the TOCTOU race in the deleted.count === 0 branch.
await tx.$executeRaw`
SELECT pg_advisory_xact_lock(
('x' || left(md5(${billedUserId}), 16))::bit(64)::bigint
)
`;
// Validate the reservation by checking it actually exists and belongs to the
// billed user. A client-supplied fake ID would delete 0 rows — in that case
// we fall back to a standard (non-locked) quota check so the bypass attempt
// is caught rather than silently allowed.
const deleted = await tx.uploadReservation.deleteMany({
where: {
id: reservationId,
billedUserId,
purpose: reservationPurpose,
expiresAt: { gt: new Date() },
},
});
if (deleted.count === 0) {
// Reservation didn't exist — enforce quota the normal way inside the tx.
// We read inside the same transaction so the check is at least consistent
// with the asset insert that follows.
const [r2Row] = await tx.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets
WHERE "billedUserId" = ${billedUserId}
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
`;
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
FROM upload_reservations
WHERE "billedUserId" = ${billedUserId}
AND "expiresAt" > NOW()
`;
const totalUsed =
(r2Row?.total ?? BigInt(0)) +
(resRow?.total ?? BigInt(0)) +
BigInt(preFetchedBunnyBytes ?? 0);
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storage.limitBytes) {
throw new QuotaExceededInTxError();
}
}
}
if (finalizedR2AssetSession) {
const consumed = await tx.videoUploadSession.updateMany({
where: {
id: finalizedR2AssetSession.sessionId,
status: 'INITIATED',
userId: finalizedR2AssetSession.viewerUserId,
projectId: finalizedR2AssetSession.projectId,
objectKey: finalizedR2AssetSession.objectKey,
},
data: {
status: 'FINALIZED',
consumedAt: new Date(),
},
});
if (consumed.count !== 1) {
throw new Error('Upload session already consumed');
}
}
return tx.videoAsset.create({
data: {
videoId: context.video.id,
kind,
provider,
displayName,
sourceUrl,
providerVideoId,
thumbnailUrl,
sizeBytes: assetSizeBytes,
uploadedByUserId: context.viewerUserId,
uploadedByGuestIdentityId: context.viewerUserId
? null
: (guestIdentity?.identityId ?? null),
uploadedByGuestName: context.viewerUserId
? null
: sanitizeAssetDisplayName(
typeof body?.guestName === 'string' ? body.guestName : null,
'Guest'
),
billedUserId,
},
select: {
id: true,
videoId: true,
kind: true,
provider: true,
displayName: true,
sourceUrl: true,
providerVideoId: true,
thumbnailUrl: true,
uploadedByGuestName: true,
createdAt: true,
updatedAt: true,
uploadedByUser: {
select: { id: true, name: true, image: true },
},
},
});
});
const response = successResponse(
shapeAssetForViewer(created, context.canDownloadAssets, true),
201
);
if (isGuest && guestIdentity?.shouldSetCookie) {
setGuestIdentityCookie(response, guestIdentity.identityId);
}
return withCacheControl(response, 'private, no-store');
} catch (error) {
if (error instanceof QuotaExceededInTxError) {
return storageForRefusal
? storageExceededResponse(storageForRefusal)
: (apiErrors.storageExceeded() as NextResponse);
}
await releaseStorageReservation(
reservationId,
reservationBilledUserId,
reservationPurpose ?? undefined
);
logError('Error creating video asset:', error);
return apiErrors.internalError('Failed to create asset');
}
}