mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(video-assets): add full video asset system (uploads, downloads, @mentions, and cleanup/billing integration)
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user