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