feat(video-assets): add full video asset system (uploads, downloads, @mentions, and cleanup/billing integration)

This commit is contained in:
Yusuf İpek
2026-02-25 18:34:03 +03:00
parent 6eea327083
commit 9ce033d306
32 changed files with 3524 additions and 231 deletions
+31
View File
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -157,6 +158,36 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('Only the project owner can delete it');
}
const [projectVersionRefs, projectAssetRefs] = await Promise.all([
db.videoVersion.findMany({
where: {
video: { projectId },
},
select: {
providerId: true,
videoId: true,
},
}),
db.videoAsset.findMany({
where: {
video: { projectId },
provider: 'BUNNY',
providerVideoId: { not: null },
},
select: {
providerVideoId: true,
},
}),
]);
await cleanupBunnyStreamVideos([
...projectVersionRefs,
...projectAssetRefs.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
]);
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupProjectMediaFiles(projectId);
@@ -126,6 +126,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
canManageTags: access.canEdit,
canResolveComments: access.canEdit,
canRequestApproval: access.canEdit,
canShareVideo: access.canEdit,
canUploadAssets: access.hasAccess,
canDownloadAssets: !!session?.user?.id && access.hasAccess,
});
return withCacheControl(response, 'private, no-cache');
@@ -212,6 +215,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
videoId: true,
},
},
assets: {
select: {
provider: true,
providerVideoId: true,
},
},
project: true,
},
});
@@ -226,7 +235,15 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Delete Bunny provider videos first to avoid orphaned assets.
await cleanupBunnyStreamVideos(video.versions);
await cleanupBunnyStreamVideos([
...video.versions,
...video.assets
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
]);
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupVideoMediaFiles(videoId);
@@ -0,0 +1,176 @@
import { NextRequest } from 'next/server';
import { VideoAssetProvider } from '@prisma/client';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
import { fetchWithTimeout, resolveBunnyDownloadSource } from '@/lib/bunny-download';
import { db } from '@/lib/db';
import {
extractImageFileNameFromProxyUrl,
getVideoAssetAccessContext,
} from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
type BunnySourcePreference = 'auto' | 'original' | 'compressed';
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
};
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
function sanitizeFileName(value: string): string {
const sanitized = value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
.replace(/\s+/g, ' ')
.trim();
return sanitized.length > 0 ? sanitized : 'asset';
}
function toAsciiFileName(value: string): string {
const normalized = value
.normalize('NFKD')
.replace(/[^\x20-\x7E]/g, '')
.replace(/\s+/g, ' ')
.trim();
return normalized.length > 0 ? normalized : 'asset';
}
function buildContentDisposition(fileNameWithExt: string): string {
const asciiFallback = toAsciiFileName(fileNameWithExt).replace(/["\\]/g, '_');
const encoded = encodeURIComponent(fileNameWithExt);
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
function imageContentTypeFromFileName(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
}
// GET /api/videos/[videoId]/assets/[assetId]/download
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-download');
if (limited) return limited;
const { videoId, assetId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
if (!context.viewerUserId || !context.canDownloadAssets) {
return apiErrors.forbidden('Asset downloads require an authenticated account');
}
const asset = await db.videoAsset.findFirst({
where: { id: assetId, videoId },
select: {
id: true,
provider: true,
displayName: true,
sourceUrl: true,
providerVideoId: true,
},
});
if (!asset) return apiErrors.notFound('Asset');
if (asset.provider === VideoAssetProvider.YOUTUBE) {
return apiErrors.badRequest('YouTube assets cannot be downloaded');
}
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl);
if (!fileName) return apiErrors.badRequest('Invalid image asset URL');
const key = `images/${fileName}`;
const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png';
const downloadName = `${sanitizeFileName(asset.displayName)}${extension}`;
const contentDisposition = buildContentDisposition(downloadName);
return proxyR2MediaObject({
request,
key,
fallbackContentType: imageContentTypeFromFileName(fileName),
cacheControl: 'private, no-store',
extraHeaders: {
'Content-Disposition': contentDisposition,
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
},
internalErrorMessage: 'Failed to retrieve image',
});
}
const sourceParam = request.nextUrl.searchParams.get('source');
const rawQuality = request.nextUrl.searchParams.get('quality');
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
const requestedQuality = Number(rawQuality);
const sourcePreference: BunnySourcePreference =
sourceParam === null
? 'auto'
: sourceParam === 'original' || sourceParam === 'compressed'
? sourceParam
: 'auto';
if (sourceParam !== null && sourceParam !== 'original' && sourceParam !== 'compressed') {
return apiErrors.badRequest('Invalid source. Allowed values: original, compressed');
}
if (
rawQuality !== null
&& (!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality))
) {
return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240');
}
if (rawQuality !== null && sourcePreference === 'original') {
return apiErrors.badRequest('Quality cannot be used when source=original');
}
if (!asset.providerVideoId) {
return apiErrors.badRequest('Missing Bunny asset video id');
}
const source = await resolveBunnyDownloadSource(
asset.providerVideoId,
Number.isFinite(requestedQuality) ? requestedQuality : null,
sourcePreference
);
if (!source) {
if (sourcePreference === 'original') {
return apiErrors.notFound('Original file');
}
return apiErrors.notFound('Download file');
}
if (isPrepareOnly) {
const response = successResponse({
quality: source.quality,
sourceType: source.sourceType,
});
return withCacheControl(response, 'private, no-store');
}
const upstream = await fetchWithTimeout(source.url, { cache: 'no-store' });
if (!upstream.ok || !upstream.body) {
return apiErrors.notFound('Download file');
}
const extension = source.sourceType === 'compressed' ? '.mp4' : '';
const filename = `${sanitizeFileName(asset.displayName)}${extension}`;
const response = new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream',
'Content-Disposition': buildContentDisposition(filename),
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
const contentLength = upstream.headers.get('content-length');
if (contentLength) response.headers.set('Content-Length', contentLength);
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error downloading asset:', error);
return apiErrors.internalError('Failed to download asset');
}
}
@@ -0,0 +1,89 @@
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { VideoAssetProvider } from '@prisma/client';
import { NextRequest } from 'next/server';
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 { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import {
canDeleteAssetForViewer,
extractImageKeyFromProxyUrl,
getVideoAssetAccessContext,
} from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
// DELETE /api/videos/[videoId]/assets/[assetId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-delete');
if (limited) return limited;
const { videoId, assetId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
if (!context) return apiErrors.notFound('Video');
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
const asset = await db.videoAsset.findFirst({
where: { id: assetId, videoId },
select: {
id: true,
provider: true,
sourceUrl: true,
providerVideoId: true,
uploadedByUserId: true,
uploadedByGuestIdentityId: true,
},
});
if (!asset) return apiErrors.notFound('Asset');
if (!canDeleteAssetForViewer(asset, context)) {
return apiErrors.forbidden('You can only delete assets you uploaded');
}
let shouldDeleteImageObject = false;
await db.$transaction(async (tx) => {
await tx.videoAsset.delete({ where: { id: asset.id } });
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
const [assetReferenceCount, commentReferenceCount] = await Promise.all([
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
tx.comment.count({ where: { imageUrl: asset.sourceUrl } }),
]);
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
}
});
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
const imageKey = extractImageKeyFromProxyUrl(asset.sourceUrl);
if (imageKey) {
try {
await r2Client.send(new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: imageKey,
}));
} catch (error) {
console.error(`Failed to delete R2 image asset ${imageKey}:`, error);
}
}
}
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
try {
await cleanupBunnyStreamVideos([{
providerId: 'bunny',
videoId: asset.providerVideoId,
}]);
} catch (error) {
console.error(`Failed to cleanup Bunny asset ${asset.providerVideoId}:`, error);
}
}
const response = successResponse({ message: 'Asset deleted' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error deleting video asset:', error);
return apiErrors.internalError('Failed to delete asset');
}
}
@@ -0,0 +1,158 @@
import crypto from 'crypto';
import { NextRequest } from 'next/server';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import {
createGuestUploadToken,
deriveGuestUploadContext,
enforceGuestUploadQuota,
verifyGuestUploadToken,
} from '@/lib/guest-upload-token';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string }> };
// POST /api/videos/[videoId]/assets/bunny-init
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-bunny-init');
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 title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) return apiErrors.badRequest('Title is required');
const shareSession = getShareSessionFromRequest(request, context.video.id);
if (!context.viewerUserId) {
const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null);
if (quotaError) return quotaError;
}
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
return apiErrors.internalError('Bunny Stream is not configured correctly');
}
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
method: 'POST',
headers: {
AccessKey: apiKey,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ title }),
});
if (!bunnyRes.ok) {
console.error('Failed to create Bunny Stream video asset', await bunnyRes.text());
return apiErrors.internalError('Failed to initialize Bunny upload');
}
const bunnyVideo = await bunnyRes.json();
const bunnyVideoId = typeof bunnyVideo?.guid === 'string' ? bunnyVideo.guid.trim() : '';
if (!bunnyVideoId || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
return apiErrors.internalError('Upload provider did not return a valid video identifier');
}
const expirationTime = Math.floor(Date.now() / 1000) + 3600;
const hash = crypto.createHash('sha256');
hash.update(libraryId + apiKey + expirationTime + bunnyVideoId);
const signature = hash.digest('hex');
let uploadToken = '';
if (context.viewerUserId) {
uploadToken = createBunnyUploadToken({
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: bunnyVideoId,
}, 3600);
} else {
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
if (!expectedContext) {
return apiErrors.forbidden('Missing trusted client IP header');
}
uploadToken = createGuestUploadToken({
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
}, 3600);
}
const response = successResponse({
videoId: bunnyVideoId,
libraryId,
signature,
expirationTime,
uploadToken,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error initializing Bunny asset upload:', error);
return apiErrors.internalError('Failed to initialize asset upload');
}
}
// DELETE /api/videos/[videoId]/assets/bunny-init
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-bunny-init');
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 bunnyVideoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
if (!bunnyVideoId || !uploadToken || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
return apiErrors.badRequest('videoId and uploadToken are required');
}
if (context.viewerUserId) {
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: bunnyVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
} 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');
}
const isValidUploadToken = verifyGuestUploadToken(uploadToken, {
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId: bunnyVideoId }]);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error cleaning up Bunny asset upload:', error);
return apiErrors.internalError('Failed to cleanup pending upload');
}
}
+385
View File
@@ -0,0 +1,385 @@
import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { VideoAssetProvider } from '@prisma/client';
import { NextRequest } 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 { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { deriveGuestUploadContext, verifyGuestUploadToken } 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 {
SAFE_BUNNY_VIDEO_ID,
SAFE_IMAGE_PROXY_PATH,
canDeleteAssetForViewer,
extractImageFileNameFromProxyUrl,
extractImageKeyFromProxyUrl,
getVideoAssetAccessContext,
sanitizeAssetDisplayName,
} from '@/lib/video-assets';
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 BUNNY_ALLOWED_THUMBNAIL_HOSTS = new Set([
'iframe.mediadelivery.net',
'video.bunnycdn.com',
'vz-965f4f4a-fc1.b-cdn.net',
]);
const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
type AssetWithViewerFields = {
id: string;
videoId: string;
kind: 'IMAGE' | 'VIDEO';
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 {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return false;
return BUNNY_ALLOWED_THUMBNAIL_HOSTS.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 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;
}
async function isFreshImageAttachment(url: string): Promise<boolean> {
const key = extractImageKeyFromProxyUrl(url);
if (!key) return false;
try {
const head = await r2Client.send(new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
}));
if (!head.LastModified) return false;
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
} catch {
return false;
}
}
// 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 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,
context.canDownloadAssets,
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
)),
pagination: {
limit,
offset,
hasMore,
nextOffset: hasMore ? offset + limit : null,
},
canUploadAssets: context.canUploadAssets,
canDownloadAssets: context.canDownloadAssets,
});
return withCacheControl(response, 'private, no-cache');
} catch (error) {
console.error('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) {
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) {
return apiErrors.badRequest('Invalid provider');
}
const isGuest = !context.viewerUserId;
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
const requestedDisplayName = typeof body?.displayName === 'string' ? body.displayName : null;
let displayName = '';
let sourceUrl = '';
let providerVideoId: string | null = null;
let thumbnailUrl: string | null = null;
let kind: 'IMAGE' | 'VIDEO' = 'IMAGE';
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');
}
if (!(await isFreshImageAttachment(sourceUrl))) {
return apiErrors.badRequest('Image upload expired. Please upload again.');
}
const fileName = extractImageFileNameFromProxyUrl(sourceUrl);
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Image');
thumbnailUrl = sourceUrl;
kind = 'IMAGE';
}
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.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 isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: providerVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
} 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');
}
const isValidGuestUploadToken = verifyGuestUploadToken(uploadToken, {
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
});
if (!isValidGuestUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
if (!thumbnailUrl) {
thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${providerVideoId}/thumbnail.jpg`;
}
kind = 'VIDEO';
}
const created = await db.videoAsset.create({
data: {
videoId: context.video.id,
kind,
provider,
displayName,
sourceUrl,
providerVideoId,
thumbnailUrl,
uploadedByUserId: context.viewerUserId,
uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null,
uploadedByGuestName: context.viewerUserId
? null
: sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'),
billedUserId: context.video.project.workspace.ownerId,
},
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) {
console.error('Error creating video asset:', error);
return apiErrors.internalError('Failed to create asset');
}
}
+5
View File
@@ -182,6 +182,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canDownloadWithMembership = access.hasAccess;
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
const canUploadAssets = canCommentWithMembership || canCommentWithShareLink;
const canDownloadAssets = !!session?.user?.id && (access.hasAccess || shareAccess.hasAccess);
const response = successResponse({
...videoData,
versions,
@@ -198,6 +200,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
canManageTags: access.canEdit,
canResolveComments: access.canEdit,
canShareVideo: access.canEdit,
canUploadAssets,
canDownloadAssets,
});
return withCacheControl(response, 'private, no-cache');
+34 -12
View File
@@ -166,20 +166,42 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Delete Bunny provider videos first to avoid orphaned external assets.
const workspaceVersionRefs = await db.videoVersion.findMany({
where: {
video: {
project: {
workspaceId,
const [workspaceVersionRefs, workspaceAssetRefs] = await Promise.all([
db.videoVersion.findMany({
where: {
video: {
project: {
workspaceId,
},
},
},
},
select: {
providerId: true,
videoId: true,
},
});
await cleanupBunnyStreamVideos(workspaceVersionRefs);
select: {
providerId: true,
videoId: true,
},
}),
db.videoAsset.findMany({
where: {
provider: 'BUNNY',
providerVideoId: { not: null },
video: {
project: {
workspaceId,
},
},
},
select: {
providerVideoId: true,
},
}),
]);
await cleanupBunnyStreamVideos([
...workspaceVersionRefs,
...workspaceAssetRefs.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
]);
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupWorkspaceMediaFiles(workspaceId);