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:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user