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
+77 -25
View File
@@ -194,19 +194,31 @@ export const getCachedUserBunnyStorage = unstable_cache(
const bunnyStats = await getCachedBunnyStorageStats();
if (bunnyStats.totalBytes < 0) return perUserStorage;
const bunnyVersions = await db.videoVersion.findMany({
where: { providerId: 'bunny' },
select: {
videoId: true,
video: {
select: {
project: {
select: { ownerId: true },
const [bunnyVersions, bunnyAssets] = await Promise.all([
db.videoVersion.findMany({
where: { providerId: 'bunny' },
select: {
videoId: true,
video: {
select: {
project: {
select: { ownerId: true },
},
},
},
},
},
});
}),
db.videoAsset.findMany({
where: {
provider: 'BUNNY',
providerVideoId: { not: null },
},
select: {
providerVideoId: true,
billedUserId: true,
},
}),
]);
const seenVideoIds = new Set<string>();
for (const version of bunnyVersions) {
@@ -218,6 +230,17 @@ export const getCachedUserBunnyStorage = unstable_cache(
const size = bunnyStats.byVideoId[version.videoId] || 0;
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
}
for (const asset of bunnyAssets) {
if (!asset.providerVideoId) continue;
const billedUserId = asset.billedUserId;
const dedupeKey = `${billedUserId}:${asset.providerVideoId}`;
if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey);
const size = bunnyStats.byVideoId[asset.providerVideoId] || 0;
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
}
} catch (err) {
console.error('Failed to calculate per-user Bunny storage:', err);
}
@@ -234,19 +257,21 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
const snapshot = await getR2StorageSnapshot();
const seenKeys = new Set<string>();
const mediaComments = await db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
select: {
voiceUrl: true,
imageUrl: true,
version: {
select: {
video: {
select: {
project: {
select: {
workspace: {
select: { ownerId: true },
const [mediaComments, mediaAssets] = await Promise.all([
db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
select: {
voiceUrl: true,
imageUrl: true,
version: {
select: {
video: {
select: {
project: {
select: {
workspace: {
select: { ownerId: true },
},
},
},
},
@@ -254,8 +279,15 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
},
},
},
},
});
}),
db.videoAsset.findMany({
where: { provider: 'R2_IMAGE' },
select: {
sourceUrl: true,
billedUserId: true,
},
}),
]);
for (const comment of mediaComments) {
const billedUserId = comment.version.video.project.workspace.ownerId;
@@ -291,6 +323,26 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
}
}
}
for (const asset of mediaAssets) {
const billedUserId = asset.billedUserId;
if (!billedUserId) continue;
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
const keyParts = asset.sourceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
if (!filename) continue;
const r2Key = `images/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (seenKeys.has(dedupeKey)) continue;
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].image += size;
userStorage[billedUserId].total += size;
}
} catch (err) {
console.error('Failed to parse user storage:', err);
}
+194
View File
@@ -0,0 +1,194 @@
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
export type BunnyDownloadSource = {
sourceType: 'original' | 'compressed';
quality: number | null;
url: string;
};
const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
const BUNNY_MAX_PROBE_CANDIDATES = 4;
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000;
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
type BunnyDownloadSourceCacheRecord = {
source: BunnyDownloadSource | null;
expiresAt: number;
};
const bunnyDownloadSourceCache = new Map<string, BunnyDownloadSourceCacheRecord>();
export function resolveBunnyCdnHostname(): string {
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
if (!raw) return DEFAULT_BUNNY_CDN_HOSTNAME;
try {
const parsed = new URL(raw);
return parsed.hostname || DEFAULT_BUNNY_CDN_HOSTNAME;
} catch {
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_CDN_HOSTNAME;
}
}
export async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BUNNY_REMOTE_FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
async function isRemoteFileAvailable(url: string): Promise<boolean> {
try {
const headRes = await fetchWithTimeout(url, { method: 'HEAD', cache: 'no-store' });
if (headRes.ok) return true;
if (headRes.status === 405) {
const rangeRes = await fetchWithTimeout(url, {
method: 'GET',
headers: { Range: 'bytes=0-0' },
cache: 'no-store',
});
return rangeRes.ok || rangeRes.status === 206;
}
return false;
} catch {
return false;
}
}
function buildBunnyOriginalUrl(videoId: string): string {
return `https://${resolveBunnyCdnHostname()}/${videoId}/original`;
}
function extractHeightFromBunnyMp4Url(url: string): number | null {
const match = url.match(/\/play_(\d+)p\.mp4$/);
if (!match?.[1]) return null;
const parsed = Number(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
const hostname = resolveBunnyCdnHostname();
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
let playlistHeights: number[] = [];
try {
const playlistRes = await fetchWithTimeout(playlistUrl, { cache: 'no-store' });
if (playlistRes.ok) {
const playlist = await playlistRes.text();
const matches = [...playlist.matchAll(/RESOLUTION=\d+x(\d+)/g)];
playlistHeights = matches
.map((match) => Number(match[1]))
.filter((height) => Number.isFinite(height) && BUNNY_ALLOWED_QUALITIES.has(height))
.sort((a, b) => b - a);
}
} catch {
// Fall through to static fallback list.
}
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])]
.slice(0, BUNNY_MAX_PROBE_CANDIDATES);
for (const height of candidateHeights) {
const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`;
if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl;
}
const fallbackHeight = candidateHeights[0] ?? 1080;
return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`;
}
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
const originalUrl = buildBunnyOriginalUrl(videoId);
if (await isRemoteFileAvailable(originalUrl)) {
return {
sourceType: 'original',
quality: null,
url: originalUrl,
};
}
return null;
}
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) {
const requestedUrl = `https://${resolveBunnyCdnHostname()}/${videoId}/play_${requestedQuality}p.mp4`;
if (await isRemoteFileAvailable(requestedUrl)) {
return {
sourceType: 'compressed',
quality: extractHeightFromBunnyMp4Url(requestedUrl),
url: requestedUrl,
};
}
}
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
return {
sourceType: 'compressed',
quality: extractHeightFromBunnyMp4Url(fallbackUrl),
url: fallbackUrl,
};
}
function buildSourceCacheKey(videoId: string, requestedQuality: number | null, preference: BunnyDownloadSourcePreference): string {
return `${videoId}:${requestedQuality ?? 'none'}:${preference}`;
}
function getCachedSource(cacheKey: string, now: number): BunnyDownloadSource | null | undefined {
const cached = bunnyDownloadSourceCache.get(cacheKey);
if (!cached) return undefined;
if (cached.expiresAt <= now) {
bunnyDownloadSourceCache.delete(cacheKey);
return undefined;
}
return cached.source;
}
function setCachedSource(cacheKey: string, source: BunnyDownloadSource | null, now: number): void {
bunnyDownloadSourceCache.set(cacheKey, {
source,
expiresAt: now + BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS,
});
}
export async function resolveBunnyDownloadSource(
videoId: string,
requestedQuality: number | null,
preference: BunnyDownloadSourcePreference
): Promise<BunnyDownloadSource | null> {
const now = Date.now();
const cacheKey = buildSourceCacheKey(videoId, requestedQuality, preference);
const cached = getCachedSource(cacheKey, now);
if (cached !== undefined) return cached;
let resolvedSource: BunnyDownloadSource | null;
if (preference === 'original') {
resolvedSource = await resolveBunnyOriginalSource(videoId);
setCachedSource(cacheKey, resolvedSource, now);
return resolvedSource;
}
if (preference === 'compressed') {
resolvedSource = await resolveBunnyCompressedSource(videoId, requestedQuality);
setCachedSource(cacheKey, resolvedSource, now);
return resolvedSource;
}
const originalSource = await resolveBunnyOriginalSource(videoId);
if (originalSource) {
setCachedSource(cacheKey, originalSource, now);
return originalSource;
}
resolvedSource = await resolveBunnyCompressedSource(videoId, requestedQuality);
setCachedSource(cacheKey, resolvedSource, now);
return resolvedSource;
}
+9 -4
View File
@@ -6,10 +6,11 @@ const GUEST_UPLOAD_TOKEN_TYPE = 'guest-upload';
const DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS = 60 * 3;
const GUEST_UPLOAD_VIDEO_WINDOW_MS = 15 * 60 * 1000;
const GUEST_UPLOAD_VIDEO_MAX_REQUESTS = 12;
const GUEST_BUNNY_UPLOAD_VIDEO_MAX_REQUESTS = 4;
const GUEST_UPLOAD_SESSION_WINDOW_MS = 15 * 60 * 1000;
const GUEST_UPLOAD_SESSION_MAX_REQUESTS = 8;
export type GuestUploadIntent = 'audio' | 'image';
export type GuestUploadIntent = 'audio' | 'image' | 'bunny';
interface GuestUploadTokenPayload {
typ: typeof GUEST_UPLOAD_TOKEN_TYPE;
@@ -71,7 +72,7 @@ function isValidPayload(value: unknown): value is GuestUploadTokenPayload {
&& Number.isFinite(payload.iat)
&& typeof payload.exp === 'number'
&& Number.isFinite(payload.exp)
&& (payload.intent === 'audio' || payload.intent === 'image')
&& (payload.intent === 'audio' || payload.intent === 'image' || payload.intent === 'bunny')
&& typeof payload.ctx === 'string';
}
@@ -150,17 +151,21 @@ export async function enforceGuestUploadQuota(
);
}
const videoScopedMaxRequests = intent === 'bunny'
? GUEST_BUNNY_UPLOAD_VIDEO_MAX_REQUESTS
: GUEST_UPLOAD_VIDEO_MAX_REQUESTS;
const videoScoped = await checkRateLimit(
`${ip}:guest-upload:${intent}:video:${videoId}`,
`guest-upload-${intent}-video`,
{ windowMs: GUEST_UPLOAD_VIDEO_WINDOW_MS, maxRequests: GUEST_UPLOAD_VIDEO_MAX_REQUESTS }
{ windowMs: GUEST_UPLOAD_VIDEO_WINDOW_MS, maxRequests: videoScopedMaxRequests }
);
if (!videoScoped.allowed) {
return NextResponse.json(
{ error: 'Too many uploads for this video. Please wait before uploading again.' },
{
status: 429,
headers: rateLimitHeaders(videoScoped, GUEST_UPLOAD_VIDEO_MAX_REQUESTS),
headers: rateLimitHeaders(videoScoped, videoScopedMaxRequests),
}
);
}
+57 -21
View File
@@ -45,18 +45,30 @@ async function deleteMediaFiles(mediaUrls: string[]) {
* Collect all media URLs from comments under a given video (all versions).
*/
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { videoParentId: videoId },
},
select: { voiceUrl: true, imageUrl: true },
});
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { videoParentId: videoId },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
videoId,
provider: 'R2_IMAGE',
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
@@ -64,18 +76,30 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { projectId } },
},
select: { voiceUrl: true, imageUrl: true },
});
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { projectId } },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
provider: 'R2_IMAGE',
video: { projectId },
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
@@ -83,18 +107,30 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
* Collect all media URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { project: { workspaceId } } },
},
select: { voiceUrl: true, imageUrl: true },
});
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { project: { workspaceId } } },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
provider: 'R2_IMAGE',
video: { project: { workspaceId } },
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
+5
View File
@@ -36,6 +36,11 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
'asset-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
'asset-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'asset-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
// Watch progress — allow frequent updates but prevent abuse
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
+155
View File
@@ -0,0 +1,155 @@
import type { NextRequest } from 'next/server';
import type { VideoAsset } from '@prisma/client';
import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { validateShareLinkAccess } from '@/lib/share-links';
const IMAGE_PROXY_PREFIX = '/api/upload/image/';
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
export const SAFE_IMAGE_PROXY_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
export type VideoAssetAccessContext = {
video: {
id: string;
title: string;
projectId: string;
project: {
id: string;
name: string;
ownerId: string;
workspaceId: string;
visibility: string;
workspace: {
id: string;
ownerId: string;
};
};
};
hasViewAccess: boolean;
canUploadAssets: boolean;
canDownloadAssets: boolean;
canManageAssets: boolean;
viewerUserId: string | null;
viewerGuestIdentityId: string | null;
};
export function sanitizeAssetDisplayName(value: string | null | undefined, fallback: string): string {
const raw = typeof value === 'string' ? value : '';
const normalized = raw
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/[\[\]\(\)]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (normalized.length === 0) return fallback;
return normalized.slice(0, 200);
}
export function extractImageKeyFromProxyUrl(url: string): string | null {
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
if (!filename) return null;
return `images/${filename}`;
}
export function extractImageFileNameFromProxyUrl(url: string): string | null {
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
return filename || null;
}
export function mediaUrlToR2Key(url: string): string | null {
if (url.includes(IMAGE_PROXY_PREFIX)) {
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
return filename ? `images/${filename}` : null;
}
if (url.includes(AUDIO_PROXY_PREFIX)) {
const filename = url.slice(url.indexOf(AUDIO_PROXY_PREFIX) + AUDIO_PROXY_PREFIX.length);
return filename ? `voice/${filename}` : null;
}
return null;
}
export function canDeleteAssetForViewer(
asset: Pick<VideoAsset, 'uploadedByUserId' | 'uploadedByGuestIdentityId'>,
viewer: Pick<VideoAssetAccessContext, 'canManageAssets' | 'viewerUserId' | 'viewerGuestIdentityId'>
): boolean {
if (viewer.canManageAssets) return true;
if (viewer.viewerUserId && asset.uploadedByUserId === viewer.viewerUserId) return true;
if (
!viewer.viewerUserId
&& viewer.viewerGuestIdentityId
&& asset.uploadedByGuestIdentityId
&& asset.uploadedByGuestIdentityId === viewer.viewerGuestIdentityId
) {
return true;
}
return false;
}
export async function getVideoAssetAccessContext(
request: NextRequest,
videoId: string,
requiredPermission: 'VIEW' | 'COMMENT' = 'VIEW'
): Promise<VideoAssetAccessContext | null> {
const session = await auth();
const video = await db.video.findUnique({
where: { id: videoId },
select: {
id: true,
title: true,
projectId: true,
project: {
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
workspace: {
select: {
id: true,
ownerId: true,
},
},
},
},
},
});
if (!video) return null;
const access = await checkProjectAccess(video.project, session?.user?.id);
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: video.projectId,
videoId: video.id,
requiredPermission,
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link: null };
const hasViewAccess = access.hasAccess || shareAccess.hasAccess;
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
const canCommentWithShare = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canUploadAssets = canCommentWithMembership || canCommentWithShare;
const canDownloadAssets = !!session?.user?.id && hasViewAccess;
const viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
return {
video,
hasViewAccess,
canUploadAssets,
canDownloadAssets,
canManageAssets: access.canEdit,
viewerUserId,
viewerGuestIdentityId,
};
}