mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
A reservation id was never a secret and could not have been one. An upload token is base64url(payload) followed by its signature, so a client can read every claim out of its own token, and the two R2 init routes hand their reservation ids to the client outright. The asset route takes a reservation id from the request body and deleted it on the strength of that id and the billed user alone, and every hold an account owns is billed to the same user. So a caller could start a Bunny upload, read the id out of the token they were just given, quote it while attaching a one byte image or even a bare YouTube link, and have the quota handed back while the upload carried on. Repeat and a trial worth three gigabytes uploads as much as it likes for as long as Bunny takes to report a figure of its own. Signing the id rather than handing it over bought nothing, because signing is not hiding. A hold now records what it was opened for and is only ever consumed by that flow, so naming one is no longer enough to drop it. Guests hold against the workspace owner's quota rather than their own and had no way to give it back: the release was gated on being signed in. Declaring a size and walking away cost the guest nothing and cost the owner their whole remaining allowance for two hours. The guest grant now carries the reservation and the declared size, bound to the Bunny video as well as to ours, so cancelling gives the quota back and costs them the upload it stood for. What a guest can hold without cancelling lapses in half an hour rather than two hours. The in-transaction fallback check counted the account's Bunny storage as zero on a Bunny upload, because the figure was only prefetched for R2 providers and that branch was unreachable for Bunny until this PR made it reachable. On an account whose storage is all Bunny that was a check that could not fail. It is prefetched for every provider that can reach the fallback now.
726 lines
28 KiB
TypeScript
726 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,
|
|
getStorageLimitForUser,
|
|
UPLOAD_RESERVATION_PURPOSES,
|
|
type UploadReservationPurpose,
|
|
} from '@/lib/storage-quota';
|
|
import { getCachedUserBunnyStorage } 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;
|
|
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 preFetchedBunnyData =
|
|
provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage();
|
|
|
|
// 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 storageLimitBytes = await getStorageLimitForUser(billedUserId);
|
|
|
|
// 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 bunnyData = preFetchedBunnyData ?? {};
|
|
const totalUsed =
|
|
(r2Row?.total ?? BigInt(0)) +
|
|
(resRow?.total ?? BigInt(0)) +
|
|
BigInt(bunnyData[billedUserId] ?? 0);
|
|
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) {
|
|
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 apiErrors.storageExceeded() as NextResponse;
|
|
}
|
|
await releaseStorageReservation(
|
|
reservationId,
|
|
reservationBilledUserId,
|
|
reservationPurpose ?? undefined
|
|
);
|
|
logError('Error creating video asset:', error);
|
|
return apiErrors.internalError('Failed to create asset');
|
|
}
|
|
}
|