From a70ddae68b9d9de9516ca686ec1dbc6407722d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87=C4=B1nar=20=C3=96zkan?= <110178368+cinarozkan@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:45:58 +0300 Subject: [PATCH 1/9] ci: add GitHub Actions workflow for bun check --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7eacd67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,12 @@ +name: CI + +on: [push, pull_request] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - run: bun install + - run: bun run check From e2ea5c46b72f638457d9cdeced6d1e0a44d324b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87=C4=B1nar=20=C3=96zkan?= <110178368+cinarozkan@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:51:25 +0300 Subject: [PATCH 2/9] ci: update setup-bun to v2 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7eacd67..47e86e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 - run: bun install - run: bun run check From 8a12bb484b2f9a78802747c9285136cf666e2d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Tue, 14 Apr 2026 13:41:41 +0300 Subject: [PATCH 3/9] feat(assets): add image upload state management and update button behavior during upload --- app/api/upload/image/[filename]/route.ts | 38 ++++++++++++------------ components/video-page/assets-pane.tsx | 12 ++++++-- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/app/api/upload/image/[filename]/route.ts b/app/api/upload/image/[filename]/route.ts index 272bfcf..d80554b 100644 --- a/app/api/upload/image/[filename]/route.ts +++ b/app/api/upload/image/[filename]/route.ts @@ -37,38 +37,38 @@ export async function GET( // Parallelize the DB lookup and session check to narrow the timing delta // between "asset not found" and "asset found, access denied" responses. const imageUrl = `/api/upload/image/${filename}`; - const [comment, session] = await Promise.all([ + const projectSelect = { + id: true, + ownerId: true, + workspaceId: true, + visibility: true, + } as const; + const videoSelect = { + id: true, + projectId: true, + project: { select: projectSelect }, + } as const; + const [comment, videoAsset, session] = await Promise.all([ db.comment.findFirst({ where: { imageUrl }, select: { version: { - select: { - video: { - select: { - id: true, - projectId: true, - project: { - select: { - id: true, - ownerId: true, - workspaceId: true, - visibility: true, - }, - }, - }, - }, - }, + select: { video: { select: videoSelect } }, }, }, }), + db.videoAsset.findFirst({ + where: { sourceUrl: imageUrl }, + select: { video: { select: videoSelect } }, + }), auth(), ]); - if (!comment) { + const video = comment?.version?.video ?? videoAsset?.video ?? null; + if (!video) { return apiErrors.forbidden('Access denied'); } - const { video } = comment.version; const access = await checkProjectAccess(video.project, session?.user?.id); if (!access.hasAccess) { diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx index 5e20697..30b37a6 100644 --- a/components/video-page/assets-pane.tsx +++ b/components/video-page/assets-pane.tsx @@ -109,6 +109,7 @@ export const AssetsPane = memo(function AssetsPane({ const [youtubeUrl, setYoutubeUrl] = useState(''); const [youtubeTitle, setYoutubeTitle] = useState(''); const [bunnyTitle, setBunnyTitle] = useState(''); + const [isUploadingImage, setIsUploadingImage] = useState(false); const [isUploadingBunny, setIsUploadingBunny] = useState(false); const [bunnyProgress, setBunnyProgress] = useState(0); const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState>({}); @@ -290,6 +291,7 @@ export const AssetsPane = memo(function AssetsPane({ return; } + setIsUploadingImage(true); try { const formData = new FormData(); formData.append('image', file); @@ -319,6 +321,8 @@ export const AssetsPane = memo(function AssetsPane({ } catch (error) { console.error('Failed to upload image asset:', error); toast.error('Failed to upload image'); + } finally { + setIsUploadingImage(false); } }, [videoId, getGuestUploadToken, createAsset, imageTitle]); @@ -807,7 +811,7 @@ export const AssetsPane = memo(function AssetsPane({ Date: Tue, 14 Apr 2026 13:46:43 +0300 Subject: [PATCH 4/9] refactor(download): remove unused content type handling and filename sanitization logic --- .../versions/[versionId]/download/route.ts | 146 +----------------- 1 file changed, 2 insertions(+), 144 deletions(-) diff --git a/app/api/versions/[versionId]/download/route.ts b/app/api/versions/[versionId]/download/route.ts index f605480..35b65f0 100644 --- a/app/api/versions/[versionId]/download/route.ts +++ b/app/api/versions/[versionId]/download/route.ts @@ -21,19 +21,6 @@ const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240]; const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS); const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000; const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000; -const SAFE_DOWNLOAD_CONTENT_TYPE = 'application/octet-stream'; -const CONTENT_TYPE_EXTENSION_MAP: Record = { - 'video/mp4': '.mp4', - 'video/quicktime': '.mov', - 'video/webm': '.webm', - 'video/x-matroska': '.mkv', - 'video/x-msvideo': '.avi', - 'video/mpeg': '.mpeg', - 'video/3gpp': '.3gp', - 'video/ogg': '.ogv', -}; -const SAFE_VIDEO_CONTENT_TYPES = new Set(Object.keys(CONTENT_TYPE_EXTENSION_MAP)); -const SAFE_VIDEO_EXTENSIONS = new Set(Object.values(CONTENT_TYPE_EXTENSION_MAP)); type BunnyDownloadSourceCacheRecord = { source: BunnyDownloadSource | null; @@ -43,29 +30,6 @@ type BunnyDownloadSourceCacheRecord = { const BUNNY_SOURCE_CACHE_MAX_ENTRIES = 500; const bunnyDownloadSourceCache = new Map(); -function sanitizeFileName(value: string): string { - const sanitized = value - .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') - .replace(/\s+/g, ' ') - .trim(); - return sanitized.length > 0 ? sanitized : 'video'; -} - -function toAsciiFileName(value: string): string { - const normalized = value - .normalize('NFKD') - .replace(/[^\x20-\x7E]/g, '') - .replace(/\s+/g, ' ') - .trim(); - return normalized.length > 0 ? normalized : 'video'; -} - -function buildContentDisposition(fileNameWithExt: string): string { - const asciiFallback = toAsciiFileName(fileNameWithExt).replace(/["\\]/g, '_'); - const encoded = encodeURIComponent(fileNameWithExt); - return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`; -} - function resolveBunnyCdnHostname(): string | null { return resolveServerBunnyCdnHostname(); } @@ -264,80 +228,6 @@ function extractHeightFromBunnyMp4Url(url: string): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } -function extractFileNameFromContentDisposition(contentDisposition: string | null): string | null { - if (!contentDisposition) return null; - - const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); - if (utf8Match?.[1]) { - try { - return decodeURIComponent(utf8Match[1]); - } catch { - return utf8Match[1]; - } - } - - const fallbackMatch = contentDisposition.match(/filename="?([^";]+)"?/i); - return fallbackMatch?.[1] ?? null; -} - -function extractFileExtension(fileName: string | null): string | null { - if (!fileName) return null; - const dotIndex = fileName.lastIndexOf('.'); - if (dotIndex <= 0 || dotIndex === fileName.length - 1) return null; - const extension = fileName.slice(dotIndex).toLowerCase(); - return /^[.][a-z0-9]{1,10}$/i.test(extension) ? extension : null; -} - -function inferExtensionFromContentType(contentType: string | null): string | null { - if (!contentType) return null; - const normalized = contentType.split(';')[0]?.trim().toLowerCase(); - return normalized ? CONTENT_TYPE_EXTENSION_MAP[normalized] ?? null : null; -} - -function normalizeContentType(contentType: string | null): string | null { - if (!contentType) return null; - const normalized = contentType.split(';')[0]?.trim().toLowerCase(); - return normalized || null; -} - -function resolveSafeDownloadMetadata( - sourceType: BunnyDownloadSource['sourceType'], - sourceFileName: string | null, - sourceContentType: string | null -): { extension: string; contentType: string } | null { - const rawExtension = extractFileExtension(sourceFileName); - const sourceExtension = rawExtension && SAFE_VIDEO_EXTENSIONS.has(rawExtension) ? rawExtension : null; - - const normalizedContentType = normalizeContentType(sourceContentType); - const safeContentType = - normalizedContentType && SAFE_VIDEO_CONTENT_TYPES.has(normalizedContentType) - ? normalizedContentType - : null; - const inferredExtension = safeContentType ? inferExtensionFromContentType(safeContentType) : null; - const fallbackExtension = sourceType === 'compressed' ? '.mp4' : null; - - const extension = sourceExtension || inferredExtension || fallbackExtension; - if (!extension) return null; - - return { - extension, - contentType: safeContentType ?? (sourceType === 'compressed' ? 'video/mp4' : SAFE_DOWNLOAD_CONTENT_TYPE), - }; -} - -function parseEstimatedBytes(contentLengthHeader: string | null): bigint { - if (!contentLengthHeader) return BigInt(0); - const normalized = contentLengthHeader.trim(); - if (!/^\d+$/.test(normalized)) return BigInt(0); - - try { - const parsed = BigInt(normalized); - return parsed > BigInt(0) ? parsed : BigInt(0); - } catch { - return BigInt(0); - } -} - // GET /api/versions/[versionId]/download export async function GET(request: NextRequest, { params }: RouteParams) { try { @@ -437,38 +327,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) { 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 versionLabel = version.versionLabel?.trim() || `v${version.versionNumber}`; - const sourceFileName = extractFileNameFromContentDisposition(upstream.headers.get('content-disposition')); - const metadata = resolveSafeDownloadMetadata( - source.sourceType, - sourceFileName, - upstream.headers.get('content-type') - ); - if (!metadata) { - return apiErrors.badRequest('Original file format is not supported for download'); - } - - const filename = sanitizeFileName(`${version.video.title} ${versionLabel}`) + metadata.extension; - const contentDisposition = buildContentDisposition(filename); - - const response = new Response(upstream.body, { - status: 200, - headers: { - 'Content-Type': metadata.contentType, - 'Content-Disposition': contentDisposition, - '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); - try { await db.downloadEgressEvent.create({ data: { @@ -480,14 +338,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) { downloaderUserId: session?.user?.id ?? null, source: source.sourceType === 'original' ? DownloadEgressSource.ORIGINAL : DownloadEgressSource.COMPRESSED, quality: source.quality, - estimatedBytes: parseEstimatedBytes(contentLength), + estimatedBytes: BigInt(0), }, }); } catch (egressError) { logError('Failed to record download egress event:', egressError); } - return withCacheControl(response, 'private, no-store'); + return Response.redirect(source.url, 302); } catch (error) { logError('Error downloading version:', error); return apiErrors.internalError('Failed to download video'); From 50d31ef8949f0982969ca325eee30e44bb3ba43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Tue, 14 Apr 2026 13:56:27 +0300 Subject: [PATCH 5/9] feat(download): add estimation of egress bytes by fetching Content-Length via HEAD request --- app/api/versions/[versionId]/download/route.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/api/versions/[versionId]/download/route.ts b/app/api/versions/[versionId]/download/route.ts index 35b65f0..ec3cd1e 100644 --- a/app/api/versions/[versionId]/download/route.ts +++ b/app/api/versions/[versionId]/download/route.ts @@ -327,6 +327,19 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return withCacheControl(response, 'private, no-store'); } + // Fetch Content-Length via HEAD so we can record egress bytes without proxying the body. + let estimatedBytes = BigInt(0); + try { + const headRes = await fetchWithTimeout(source.url, { method: 'HEAD', cache: 'no-store' }); + const cl = headRes.headers.get('content-length'); + if (cl && /^\d+$/.test(cl.trim())) { + const parsed = BigInt(cl.trim()); + if (parsed > BigInt(0)) estimatedBytes = parsed; + } + } catch { + // Best-effort — leave estimatedBytes as 0 if HEAD fails. + } + try { await db.downloadEgressEvent.create({ data: { @@ -338,7 +351,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { downloaderUserId: session?.user?.id ?? null, source: source.sourceType === 'original' ? DownloadEgressSource.ORIGINAL : DownloadEgressSource.COMPRESSED, quality: source.quality, - estimatedBytes: BigInt(0), + estimatedBytes, }, }); } catch (egressError) { From acf31b3d6b7e74ca5a26cfddced3c9f6551aaf5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Wed, 15 Apr 2026 17:00:41 +0300 Subject: [PATCH 6/9] refactor(docker): remove unnecessary next-env.d.ts copy from Dockerfile --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index aafeb47..629b8e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,6 @@ COPY public ./public COPY scripts ./scripts COPY types ./types COPY components.json ./components.json -COPY next-env.d.ts ./next-env.d.ts COPY next.config.ts ./next.config.ts COPY postcss.config.mjs ./postcss.config.mjs COPY prisma.config.ts ./prisma.config.ts From 873945464db30241b80c70f103213e356fd2cb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Wed, 15 Apr 2026 19:53:43 +0300 Subject: [PATCH 7/9] feat: implement storage quota management for uploads - Added storage quota enforcement for audio and image uploads in the respective routes. - Introduced reservation system to manage concurrent uploads and prevent quota overages. - Enhanced comment creation to account for audio and image attachment sizes against user quotas. - Created new UploadReservation model to track in-flight upload reservations. - Backfilled existing video assets with size information from R2. - Added progress component for UI feedback during uploads. - Updated API responses to include reservation IDs for better quota management. - Adjusted error handling to return appropriate storage limit exceeded messages. --- AGENTS.md | 1 + .../settings/settings-page-client.tsx | 84 +++++++- app/api/comments/[commentId]/route.ts | 3 + .../[projectId]/videos/bunny-init/route.ts | 6 +- app/api/settings/storage/route.ts | 40 ++++ app/api/upload/audio/route.ts | 43 +++- app/api/upload/image/route.ts | 44 ++-- .../versions/[versionId]/comments/route.ts | 79 +++++++- .../[videoId]/assets/bunny-init/route.ts | 5 + app/api/videos/[videoId]/assets/route.ts | 189 ++++++++++++++---- components/ui/progress.tsx | 23 +++ components/video-page/assets-pane.tsx | 7 +- .../video-page/hooks/use-comment-actions.ts | 6 +- .../video-page/hooks/use-video-assets.ts | 1 + lib/admin-stats.ts | 2 +- lib/api-response.ts | 7 + lib/storage-quota.ts | 176 ++++++++++++++++ .../migration.sql | 2 + .../migration.sql | 13 ++ prisma/schema.prisma | 14 ++ scripts/backfill-asset-sizes.ts | 93 +++++++++ 21 files changed, 753 insertions(+), 85 deletions(-) create mode 100644 app/api/settings/storage/route.ts create mode 100644 components/ui/progress.tsx create mode 100644 lib/storage-quota.ts create mode 100644 prisma/migrations/20260414120000_add_size_bytes_to_video_assets/migration.sql create mode 100644 prisma/migrations/20260415120000_add_upload_reservations/migration.sql create mode 100644 scripts/backfill-asset-sizes.ts diff --git a/AGENTS.md b/AGENTS.md index 4844073..f53fc1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ - Use `checkProjectAccess()` / `checkWorkspaceAccess()` for authorization instead of ad-hoc role checks. - For API responses, use `successResponse` / `apiErrors` from `@/lib/api-response`. - Keep API and UI imports on `@/` aliases when available. +- In Prisma raw SQL, use `$executeRaw` for statements that return no rows (e.g. `pg_advisory_xact_lock`). Using `$queryRaw` on void-returning functions causes a Prisma deserialization error (`Failed to deserialize column of type 'void'`). ## Important locations - Custom SQL managed by Prisma migrations: `prisma/migrations/*/migration.sql`. diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 54bfee2..27fdaea 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe, CreditCard } from 'lucide-react'; +import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe, CreditCard, HardDrive } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; import { Select, SelectContent, @@ -61,6 +62,20 @@ interface BillingOverview { }; } +interface StorageInfo { + usedBytes: string; + limitBytes: string; + percentage: number; +} + +function formatBytes(bytesStr: string): string { + const bytes = Number(bytesStr); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + function ToggleButton({ enabled, onToggle, @@ -124,6 +139,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo const [billing, setBilling] = useState(null); const [billingLoading, setBillingLoading] = useState(true); const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | null>(null); + const [storageInfo, setStorageInfo] = useState(null); + const [storageLoading, setStorageLoading] = useState(true); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // Form state for Telegram chat ID (separate from saved settings for editing) @@ -136,9 +153,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo useEffect(() => { async function fetchSettings() { try { - const [settingsRes, billingRes] = await Promise.all([ + const [settingsRes, billingRes, storageRes] = await Promise.all([ fetch('/api/settings/notifications'), fetch('/api/billing'), + fetch('/api/settings/storage'), ]); if (settingsRes.ok) { @@ -151,11 +169,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo const data = await billingRes.json(); setBilling(data.data); } + + if (storageRes.ok) { + const data = await storageRes.json(); + setStorageInfo(data.data); + } } catch { console.error('Failed to fetch settings'); } finally { setLoading(false); setBillingLoading(false); + setStorageLoading(false); } } fetchSettings(); @@ -448,6 +472,62 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo + {billing?.subscription.hasBillingAccess && ( + + + + + Storage + + + Combined usage across video files and media attachments (200 GB limit) + + + + {storageLoading || !storageInfo ? ( +
+ + +
+ ) : ( + <> +
+ + {formatBytes(storageInfo.usedBytes)} used of {formatBytes(storageInfo.limitBytes)} + + = 90 + ? 'text-destructive font-medium' + : storageInfo.percentage >= 75 + ? 'text-amber-600 dark:text-amber-400 font-medium' + : 'text-muted-foreground' + } + > + {storageInfo.percentage < 0.1 ? '<0.1%' : `${storageInfo.percentage.toFixed(1)}%`} + +
+ = 90 + ? '[&>div]:bg-destructive' + : storageInfo.percentage >= 75 + ? '[&>div]:bg-amber-500' + : '' + } + /> + {storageInfo.percentage >= 90 && ( +

+ Storage is almost full. Delete unused files or contact support. +

+ )} + + )} +
+
+ )} + {!billingOnly && ( <> {/* Event Subscriptions */} diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index 089bddc..d35333c 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -189,6 +189,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { if (annotationData === null) { updateData.annotationData = null; } else { + if (!Array.isArray(annotationData)) { + return apiErrors.badRequest('annotationData must be an array of valid stroke objects'); + } const validStrokes = validateAnnotationStrokes(annotationData); if (validStrokes === null) { return apiErrors.badRequest('annotationData must be an array of valid stroke objects'); diff --git a/app/api/projects/[projectId]/videos/bunny-init/route.ts b/app/api/projects/[projectId]/videos/bunny-init/route.ts index 35cab0d..624c8df 100644 --- a/app/api/projects/[projectId]/videos/bunny-init/route.ts +++ b/app/api/projects/[projectId]/videos/bunny-init/route.ts @@ -8,13 +8,14 @@ import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup'; import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token'; import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; import { logError } from '@/lib/logger'; +import { enforceStorageQuota } from '@/lib/storage-quota'; type RouteParams = { params: Promise<{ projectId: string }> }; async function getProjectWithEditAccess(projectId: string, userId: string) { const project = await db.project.findUnique({ where: { id: projectId }, - select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true }, + select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true, workspace: { select: { ownerId: true } } }, }); if (!project) return null; @@ -56,6 +57,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.badRequest('Direct uploads are disabled by this host'); } + const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0)); + 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; diff --git a/app/api/settings/storage/route.ts b/app/api/settings/storage/route.ts new file mode 100644 index 0000000..c057a27 --- /dev/null +++ b/app/api/settings/storage/route.ts @@ -0,0 +1,40 @@ +import { auth } from '@/lib/auth'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { getUserStorageInfo } from '@/lib/storage-quota'; +import { hasBillingAccess } from '@/lib/billing'; +import { db } from '@/lib/db'; + +// GET /api/settings/storage +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return apiErrors.unauthorized(); + } + + // Only users with active billing (or on a self-hosted instance where billing + // is disabled) should be able to enumerate their storage breakdown. + const user = await db.user.findUnique({ + where: { id: session.user.id }, + select: { + subscriptionStatus: true, + trialEndsAt: true, + stripeCurrentPeriodEnd: true, + billingAccessEndedAt: true, + }, + }); + + if (!user || !hasBillingAccess(user)) { + return apiErrors.forbidden(); + } + + const info = await getUserStorageInfo(session.user.id); + + const response = successResponse({ + usedBytes: info.usedBytes.toString(), + limitBytes: info.limitBytes.toString(), + percentage: info.percentage, + }); + + // Cache for 60s — stale data is acceptable for a usage meter + return withCacheControl(response, 'private, max-age=60'); +} diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts index cc8631e..8278ea3 100644 --- a/app/api/upload/audio/route.ts +++ b/app/api/upload/audio/route.ts @@ -13,6 +13,7 @@ import { enforceGuestUploadQuota, verifyGuestUploadToken, } from '@/lib/guest-upload-token'; +import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota'; import { logError } from '@/lib/logger'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -118,7 +119,11 @@ export async function POST(request: NextRequest) { const safeVideoId = videoId.trim(); const video = await db.video.findUnique({ where: { id: safeVideoId }, - include: { project: true }, + include: { + project: { + include: { workspace: { select: { ownerId: true } } }, + }, + }, }); if (!video) { return apiErrors.notFound('Video'); @@ -170,11 +175,20 @@ export async function POST(request: NextRequest) { return apiErrors.badRequest('File too large. Maximum size is 10MB.'); } + // Enforce per-user storage quota before uploading. + // All paths use the advisory-locked reservation so concurrent uploads always + // see each other's in-flight sizes, eliminating the TOCTOU race. + const workspaceOwnerId = video.project.workspace.ownerId; + const reserveResult = await reserveStorageQuota(workspaceOwnerId, BigInt(file.size)); + if ('error' in reserveResult) return reserveResult.error; + const reservationId = reserveResult.reservationId; + // Normalize content type: strip codec params, then resolve aliases const rawContentType = file.type || 'audio/webm'; const strippedType = rawContentType.split(';')[0].trim().toLowerCase(); const contentType = MIME_ALIASES[strippedType] ?? strippedType; if (!ALLOWED_TYPES.has(contentType)) { + await releaseStorageReservation(reservationId); return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`); } @@ -191,26 +205,33 @@ export async function POST(request: NextRequest) { // Validate file content against magic bytes — rejects HTML/scripts masquerading as audio if (isHtmlContent(buffer)) { + await releaseStorageReservation(reservationId); return apiErrors.badRequest('File content does not match an audio format'); } if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) { + await releaseStorageReservation(reservationId); return apiErrors.badRequest('File content does not match the declared audio format'); } - // Upload to R2 - await r2Client.send( - new PutObjectCommand({ - Bucket: R2_BUCKET_NAME, - Key: key, - Body: buffer, - ContentType: contentType, - }) - ); + try { + // Upload to R2 + await r2Client.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + Body: buffer, + ContentType: contentType, + }) + ); + } catch (uploadError) { + await releaseStorageReservation(reservationId); + throw uploadError; + } // Return the URL through our proxy endpoint const voiceUrl = `/api/upload/audio/${filename}`; - const response = successResponse({ url: voiceUrl }, 201); + const response = successResponse({ url: voiceUrl, reservationId }, 201); return withCacheControl(response, 'private, no-store'); } catch (error) { logError('Error uploading audio:', error); diff --git a/app/api/upload/image/route.ts b/app/api/upload/image/route.ts index 9803e6b..82d2f15 100644 --- a/app/api/upload/image/route.ts +++ b/app/api/upload/image/route.ts @@ -20,6 +20,7 @@ import { verifyGuestUploadToken, } from '@/lib/guest-upload-token'; import { logError } from '@/lib/logger'; +import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead @@ -64,7 +65,11 @@ export async function POST(request: NextRequest) { const safeVideoId = videoId.trim(); const video = await db.video.findUnique({ where: { id: safeVideoId }, - include: { project: true }, + include: { + project: { + include: { workspace: { select: { ownerId: true } } }, + }, + }, }); if (!video) { return apiErrors.notFound('Video'); @@ -116,9 +121,18 @@ export async function POST(request: NextRequest) { return apiErrors.badRequest('File too large. Maximum size is 10MB.'); } + // Enforce per-user storage quota before uploading. + // All paths use the advisory-locked reservation so concurrent uploads always + // see each other's in-flight sizes, eliminating the TOCTOU race. + const workspaceOwnerId = video.project.workspace.ownerId; + const reserveResult = await reserveStorageQuota(workspaceOwnerId, BigInt(file.size)); + if ('error' in reserveResult) return reserveResult.error; + const reservationId = reserveResult.reservationId; + // Check content type const normalizedMime = normalizeImageMime(file.type); if (normalizedMime && !isAllowedImageType(normalizedMime)) { + await releaseStorageReservation(reservationId); return apiErrors.badRequest(`Unsupported image format: ${file.type}`); } @@ -127,6 +141,7 @@ export async function POST(request: NextRequest) { const buffer = Buffer.from(arrayBuffer); const detectedMime = detectImageMime(buffer); if (!detectedMime) { + await releaseStorageReservation(reservationId); return apiErrors.badRequest('Uploaded file content does not match an allowed image type'); } @@ -135,21 +150,26 @@ export async function POST(request: NextRequest) { const filename = `${randomUUID()}.${ext}`; const key = `images/${filename}`; - // Upload to R2 - await r2Client.send( - new PutObjectCommand({ - Bucket: R2_BUCKET_NAME, - Key: key, - Body: buffer, - ContentType: detectedMime, - }) - ); + try { + // Upload to R2 + await r2Client.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + Body: buffer, + ContentType: detectedMime, + }) + ); + } catch (uploadError) { + await releaseStorageReservation(reservationId); + throw uploadError; + } // Return the URL through our proxy endpoint const imageUrl = `/api/upload/image/${filename}`; - const response = successResponse({ url: imageUrl }, 201); - return withCacheControl(response, 'public, max-age=31536000, immutable'); + const response = successResponse({ url: imageUrl, reservationId }, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { logError('Error uploading image:', error); return apiErrors.internalError('Failed to upload image'); diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index d2da94c..94449dd 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -9,18 +9,21 @@ import { getShareSessionFromRequest } from '@/lib/share-session'; import { HeadObjectCommand } from '@aws-sdk/client-s3'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; -import { extractImageFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets'; +import { extractImageFileNameFromProxyUrl, extractAudioFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets'; import { validateAnnotationStrokes } from '@/lib/validation'; import { logError } from '@/lib/logger'; +import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota'; type RouteParams = { params: Promise<{ versionId: string }> }; const SAFE_IMAGE_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; const SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000; -async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise { +type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint }; + +async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise { const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/'; - if (!url.startsWith(prefix)) return false; + if (!url.startsWith(prefix)) return { isFresh: false, sizeBytes: BigInt(0) }; const filename = url.slice(prefix.length); const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`; @@ -32,10 +35,11 @@ async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise< Key: key, }) ); - if (!head.LastModified) return false; - return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; + if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) }; + const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; + return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) }; } catch { - return false; + return { isFresh: false, sizeBytes: BigInt(0) }; } } @@ -188,6 +192,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // POST /api/versions/[versionId]/comments export async function POST(request: NextRequest, { params }: RouteParams) { + let attachmentReservationId: string | null = null; try { const limited = await rateLimit(request, 'comment'); if (limited) return limited; @@ -275,8 +280,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // Validate annotation data structure to prevent prototype pollution and stored XSS. // Reject anything that is not a well-formed array of AnnotationStroke objects. + // The HTTP body is already JSON-parsed by Next.js; double-encoded strings are rejected. let serializedAnnotationData: string | null = null; if (annotationData !== undefined && annotationData !== null) { + if (!Array.isArray(annotationData)) { + return apiErrors.badRequest('annotationData must be an array of valid stroke objects'); + } const validStrokes = validateAnnotationStrokes(annotationData); if (validStrokes === null) { return apiErrors.badRequest('annotationData must be an array of valid stroke objects'); @@ -317,21 +326,45 @@ export async function POST(request: NextRequest, { params }: RouteParams) { if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) { return apiErrors.badRequest('Voice URL must reference an uploaded audio file'); } - if (voiceUrl && !(await isFreshAttachment(voiceUrl, 'audio'))) { - return apiErrors.badRequest('Voice upload expired. Please upload again.'); + let voiceSizeBytes = BigInt(0); + if (voiceUrl) { + const voiceCheck = await isFreshAttachment(voiceUrl, 'audio'); + if (!voiceCheck.isFresh) { + return apiErrors.badRequest('Voice upload expired. Please upload again.'); + } + voiceSizeBytes = voiceCheck.sizeBytes; } if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) { return apiErrors.badRequest('Image URL must reference an uploaded image file'); } - if (imageUrl && !(await isFreshAttachment(imageUrl, 'image'))) { - return apiErrors.badRequest('Image upload expired. Please upload again.'); + let imageSizeBytes = BigInt(0); + if (imageUrl) { + const imageCheck = await isFreshAttachment(imageUrl, 'image'); + if (!imageCheck.isFresh) { + return apiErrors.badRequest('Image upload expired. Please upload again.'); + } + imageSizeBytes = imageCheck.sizeBytes; } const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; - // Use a transaction to create both comment and asset (if image is attached) + // Enforce per-workspace storage quota for any R2 attachments on this comment. + // Uses the advisory-locked reservation path so concurrent comment submissions + // see each other's in-flight sizes, eliminating the TOCTOU race. + const totalAttachmentBytes = voiceSizeBytes + imageSizeBytes; + if (totalAttachmentBytes > BigInt(0)) { + const reserveResult = await reserveStorageQuota(project.workspace.ownerId, totalAttachmentBytes); + if ('error' in reserveResult) return reserveResult.error; + attachmentReservationId = reserveResult.reservationId; + } + + // Use a transaction to create both the comment and any asset rows atomically. + // Consume the reservation inside the transaction so quota is never double-counted. const result = await db.$transaction(async (tx) => { + if (attachmentReservationId) { + await tx.uploadReservation.deleteMany({ where: { id: attachmentReservationId, billedUserId: project.workspace.ownerId } }); + } const comment = await tx.comment.create({ data: { content: content?.trim() || null, @@ -375,6 +408,29 @@ export async function POST(request: NextRequest, { params }: RouteParams) { displayName, sourceUrl: imageUrl, thumbnailUrl: imageUrl, + sizeBytes: imageSizeBytes, + uploadedByUserId: session?.user?.id || null, + uploadedByGuestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, + uploadedByGuestName: isGuest ? safeGuestName : null, + billedUserId: project.workspace.ownerId, + }, + }); + } + + // If a voice recording was attached, also track it in the assets pane + if (voiceUrl) { + const fileName = extractAudioFileNameFromProxyUrl(voiceUrl); + const displayName = sanitizeAssetDisplayName(null, fileName || 'Voice Comment'); + const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80); + + await tx.videoAsset.create({ + data: { + videoId: version.video.id, + kind: 'AUDIO', + provider: 'R2_AUDIO', + displayName, + sourceUrl: voiceUrl, + sizeBytes: voiceSizeBytes, uploadedByUserId: session?.user?.id || null, uploadedByGuestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, uploadedByGuestName: isGuest ? safeGuestName : null, @@ -450,6 +506,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } return withCacheControl(response, 'private, no-store'); } catch (error) { + await releaseStorageReservation(attachmentReservationId); logError('Error creating comment:', error); return apiErrors.internalError('Failed to create comment'); } diff --git a/app/api/videos/[videoId]/assets/bunny-init/route.ts b/app/api/videos/[videoId]/assets/bunny-init/route.ts index df65303..d3946c8 100644 --- a/app/api/videos/[videoId]/assets/bunny-init/route.ts +++ b/app/api/videos/[videoId]/assets/bunny-init/route.ts @@ -14,6 +14,7 @@ import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; import { getShareSessionFromRequest } from '@/lib/share-session'; import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets'; import { logError } from '@/lib/logger'; +import { enforceStorageQuota } from '@/lib/storage-quota'; type RouteParams = { params: Promise<{ videoId: string }> }; @@ -36,6 +37,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.badRequest('Direct uploads are disabled by this host'); } + const billedUserId = context.video.project.workspace.ownerId; + const quotaError = await enforceStorageQuota(billedUserId, BigInt(0)); + if (quotaError) return quotaError; + const shareSession = getShareSessionFromRequest(request, context.video.id); if (!context.viewerUserId) { const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null); diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts index 61eabd2..648658a 100644 --- a/app/api/videos/[videoId]/assets/route.ts +++ b/app/api/videos/[videoId]/assets/route.ts @@ -25,6 +25,13 @@ import { sanitizeAssetDisplayName, } from '@/lib/video-assets'; import { logError } from '@/lib/logger'; +import { enforceStorageQuota, reserveStorageQuota, releaseStorageReservation, PLAN_STORAGE_LIMIT_BYTES } from '@/lib/storage-quota'; +import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; +import { isStripeFeatureEnabled } from '@/lib/feature-flags'; + +// Sentinel thrown inside a Prisma transaction when a fake reservationId is +// supplied and the fallback quota check finds the limit would be exceeded. +class QuotaExceededInTxError extends Error {} type RouteParams = { params: Promise<{ videoId: string }> }; @@ -147,35 +154,39 @@ async function fetchYouTubeTitle(videoId: string): Promise { return title; } -async function isFreshImageAttachment(url: string): Promise { +type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint }; + +async function isFreshImageAttachment(url: string): Promise { const key = extractImageKeyFromProxyUrl(url); - if (!key) return false; + if (!key) return { isFresh: false, sizeBytes: BigInt(0) }; 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; + if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) }; + const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; + return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) }; } catch { - return false; + return { isFresh: false, sizeBytes: BigInt(0) }; } } -async function isFreshAudioAttachment(url: string): Promise { +async function isFreshAudioAttachment(url: string): Promise { const key = extractAudioKeyFromProxyUrl(url); - if (!key) return false; + if (!key) return { isFresh: false, sizeBytes: BigInt(0) }; 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; + if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) }; + const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; + return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) }; } catch { - return false; + return { isFresh: false, sizeBytes: BigInt(0) }; } } @@ -268,6 +279,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // POST /api/videos/[videoId]/assets export async function POST(request: NextRequest, { params }: RouteParams) { + let reservationId: string | null = null; try { const limited = await rateLimit(request, 'asset-create'); if (limited) return limited; @@ -293,20 +305,38 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; const requestedDisplayName = typeof body?.displayName === 'string' ? body.displayName : null; + // Optional reservation ID created by the upload route for atomic quota accounting + reservationId = typeof body?.reservationId === 'string' ? body.reservationId.trim() : null; let displayName = ''; let sourceUrl = ''; let providerVideoId: string | null = null; let thumbnailUrl: string | null = null; let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE'; + let assetSizeBytes = BigInt(0); + + const billedUserId = context.video.project.workspace.ownerId; 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))) { + const imageCheck = await isFreshImageAttachment(sourceUrl); + if (!imageCheck.isFresh) { return apiErrors.badRequest('Image upload expired. Please upload again.'); } + assetSizeBytes = imageCheck.sizeBytes; + + // Always use the advisory-locked reservation path so concurrent uploads + // see each other's in-flight sizes, eliminating the TOCTOU race. When + // the client already supplied a reservationId (new upload flow) the + // existing reservation is consumed in the transaction below. For the + // backward-compat path (no reservationId) we create one here. + if (!reservationId) { + const reserveResult = await reserveStorageQuota(billedUserId, assetSizeBytes); + if ('error' in reserveResult) return reserveResult.error; + reservationId = reserveResult.reservationId; + } const fileName = extractImageFileNameFromProxyUrl(sourceUrl); displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Image'); @@ -319,9 +349,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) { if (!SAFE_AUDIO_PROXY_PATH.test(sourceUrl)) { return apiErrors.badRequest('Audio URL must reference an uploaded audio file'); } - if (!(await isFreshAudioAttachment(sourceUrl))) { + const audioCheck = await isFreshAudioAttachment(sourceUrl); + if (!audioCheck.isFresh) { return apiErrors.badRequest('Audio upload expired. Please upload again.'); } + assetSizeBytes = audioCheck.sizeBytes; + + // Same reservation logic as R2_IMAGE above + if (!reservationId) { + const reserveResult = await reserveStorageQuota(billedUserId, assetSizeBytes); + if ('error' in reserveResult) return reserveResult.error; + reservationId = reserveResult.reservationId; + } const fileName = extractAudioFileNameFromProxyUrl(sourceUrl); displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Voice Recording'); @@ -405,40 +444,100 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } } kind = 'VIDEO'; + + const quotaError = await enforceStorageQuota(billedUserId, BigInt(0)); + if (quotaError) return quotaError; } - 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 }, + // Pre-fetch Bunny storage BEFORE entering the transaction to avoid making an + // HTTP call while holding a DB connection open (connection-pool exhaustion + // risk under adversarial load). Mirrors the discipline in reserveStorageQuota. + // Only needed for R2 providers where the invalid-reservation fallback quota + // check requires Bunny usage data. + const preFetchedBunnyData = + provider === VideoAssetProvider.R2_IMAGE || provider === VideoAssetProvider.R2_AUDIO + ? await getCachedUserBunnyStorage() + : null; + + // Create the VideoAsset and atomically consume the upload reservation (if any) + // so the spot is never double-counted. + const created = await db.$transaction(async (tx) => { + if (reservationId) { + // Acquire the per-user advisory lock unconditionally so both the happy path + // (valid reservation) and the fallback path (fake/expired reservation ID) are + // serialised — eliminating the TOCTOU race in the deleted.count === 0 branch. + await tx.$executeRaw` + SELECT pg_advisory_xact_lock( + ('x' || left(md5(${billedUserId}), 16))::bit(64)::bigint + ) + `; + // Validate the reservation by checking it actually exists and belongs to the + // billed user. A client-supplied fake ID would delete 0 rows — in that case + // we fall back to a standard (non-locked) quota check so the bypass attempt + // is caught rather than silently allowed. + const deleted = await tx.uploadReservation.deleteMany({ + where: { id: reservationId, billedUserId, expiresAt: { gt: new Date() } }, + }); + if (deleted.count === 0) { + // Reservation didn't exist — enforce quota the normal way inside the tx. + // We read inside the same transaction so the check is at least consistent + // with the asset insert that follows. + const [r2Row] = await tx.$queryRaw<[{ total: bigint }]>` + SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total + FROM video_assets + WHERE "billedUserId" = ${billedUserId} + AND provider IN ('R2_IMAGE', 'R2_AUDIO') + `; + const [resRow] = await tx.$queryRaw<[{ total: bigint }]>` + SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total + FROM upload_reservations + WHERE "billedUserId" = ${billedUserId} + AND "expiresAt" > NOW() + `; + const bunnyData = preFetchedBunnyData ?? {}; + const totalUsed = + (r2Row?.total ?? BigInt(0)) + + (resRow?.total ?? BigInt(0)) + + BigInt(bunnyData[billedUserId] ?? 0); + if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) { + throw new QuotaExceededInTxError(); + } + } + } + return tx.videoAsset.create({ + data: { + videoId: context.video.id, + kind, + provider, + displayName, + sourceUrl, + providerVideoId, + thumbnailUrl, + sizeBytes: assetSizeBytes, + uploadedByUserId: context.viewerUserId, + uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null, + uploadedByGuestName: context.viewerUserId + ? null + : sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'), + billedUserId, }, - }, + 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( @@ -451,6 +550,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } return withCacheControl(response, 'private, no-store'); } catch (error) { + if (error instanceof QuotaExceededInTxError) { + return apiErrors.storageExceeded() as NextResponse; + } + await releaseStorageReservation(reservationId); logError('Error creating video asset:', error); return apiErrors.internalError('Failed to create asset'); } diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx new file mode 100644 index 0000000..3ae52b3 --- /dev/null +++ b/components/ui/progress.tsx @@ -0,0 +1,23 @@ +import { cn } from '@/lib/utils'; + +function Progress({ + value = 0, + className, + ...props +}: React.ComponentProps<'div'> & { value?: number }) { + const clamped = Math.min(100, Math.max(0, value)); + return ( +
+
+
+ ); +} + +export { Progress }; diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx index 30b37a6..5c5a608 100644 --- a/components/video-page/assets-pane.tsx +++ b/components/video-page/assets-pane.tsx @@ -33,7 +33,7 @@ function formatTime(seconds: number): string { } type UploadAudioResponse = { - data?: { url?: string }; + data?: { url?: string; reservationId?: string | null }; error?: string; }; @@ -74,6 +74,7 @@ interface AssetsPaneProps { providerVideoId?: string; thumbnailUrl?: string; uploadToken?: string; + reservationId?: string | null; }) => Promise; deleteAsset: (assetId: string) => Promise; downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise; @@ -303,7 +304,7 @@ export const AssetsPane = memo(function AssetsPane({ method: 'POST', body: formData, }); - const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null; + const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string; reservationId?: string | null }; error?: string } | null; const uploadedImageUrl = uploadPayload?.data?.url; if (!uploadRes.ok || !uploadedImageUrl) { toast.error(uploadPayload?.error || 'Failed to upload image'); @@ -314,6 +315,7 @@ export const AssetsPane = memo(function AssetsPane({ provider: 'R2_IMAGE', sourceUrl: uploadedImageUrl, displayName: imageTitle.trim() || file.name, + reservationId: uploadPayload?.data?.reservationId ?? null, }); if (imageInputRef.current) imageInputRef.current.value = ''; setImageTitle(''); @@ -586,6 +588,7 @@ export const AssetsPane = memo(function AssetsPane({ provider: 'R2_AUDIO', sourceUrl: uploadedUrl, displayName: voiceTitle.trim() || fallbackName, + reservationId: uploadPayload?.data?.reservationId ?? null, }); setVoiceTitle(''); setAudioBlob(null); diff --git a/components/video-page/hooks/use-comment-actions.ts b/components/video-page/hooks/use-comment-actions.ts index b73a02d..e25f052 100644 --- a/components/video-page/hooks/use-comment-actions.ts +++ b/components/video-page/hooks/use-comment-actions.ts @@ -207,7 +207,7 @@ export function useCommentActions({ ...(imageData && { imageUrl: imageData.url }), ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), ...(selectedTagId && { tagId: selectedTagId }), - ...(serializedAnnotation && { annotationData: serializedAnnotation }), + ...(effectiveStrokes && { annotationData: effectiveStrokes }), }), }); @@ -830,7 +830,9 @@ export function useCommentActions({ try { const body: Record = { content: editText }; if (editTagId !== undefined) body.tagId = editTagId; - if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData; + if (finalAnnotationData !== undefined) { + body.annotationData = finalAnnotationData !== null ? JSON.parse(finalAnnotationData) : null; + } if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName; const res = await fetch(`/api/comments/${commentId}`, { method: 'PATCH', diff --git a/components/video-page/hooks/use-video-assets.ts b/components/video-page/hooks/use-video-assets.ts index bd726d4..f15cdc1 100644 --- a/components/video-page/hooks/use-video-assets.ts +++ b/components/video-page/hooks/use-video-assets.ts @@ -13,6 +13,7 @@ type CreateAssetPayload = { providerVideoId?: string; thumbnailUrl?: string; uploadToken?: string; + reservationId?: string | null; }; interface UseVideoAssetsParams { diff --git a/lib/admin-stats.ts b/lib/admin-stats.ts index 3600ea3..b1b506f 100644 --- a/lib/admin-stats.ts +++ b/lib/admin-stats.ts @@ -7,7 +7,7 @@ import { getStripe, getStripePriceId } from '@/lib/stripe'; import { logError } from '@/lib/logger'; const BUNNY_API_BASE = 'https://video.bunnycdn.com'; -const STORAGE_CACHE_SECONDS = 600; +const STORAGE_CACHE_SECONDS = 120; interface R2StorageSnapshot { fileSizes: Map; diff --git a/lib/api-response.ts b/lib/api-response.ts index 0e2111c..dfeec0c 100644 --- a/lib/api-response.ts +++ b/lib/api-response.ts @@ -36,6 +36,7 @@ export const HttpStatus = { CONFLICT: 409, UNPROCESSABLE_ENTITY: 422, TOO_MANY_REQUESTS: 429, + INSUFFICIENT_STORAGE: 507, INTERNAL_SERVER_ERROR: 500, } as const; @@ -62,6 +63,9 @@ export const ErrorCode = { // Server errors INTERNAL_ERROR: "INTERNAL_ERROR", SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE", + + // Storage errors + STORAGE_LIMIT_EXCEEDED: "STORAGE_LIMIT_EXCEEDED", } as const; /** @@ -158,4 +162,7 @@ export const apiErrors = { internalError: (message = "Internal server error") => errorResponse(message, HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR), + + storageExceeded: (message = "Storage limit exceeded. Please delete some files to free up space.") => + errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED), }; diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts new file mode 100644 index 0000000..5c0fcba --- /dev/null +++ b/lib/storage-quota.ts @@ -0,0 +1,176 @@ +import type { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { apiErrors } from '@/lib/api-response'; +import { isStripeFeatureEnabled } from '@/lib/feature-flags'; +import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; + +// 200 GB expressed in bytes +export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024) * BigInt(1024); + +// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads +const RESERVATION_TTL_MS = 30 * 60 * 1000; + +// Sentinel error thrown inside a Prisma transaction to signal quota exceeded +class QuotaExceededError extends Error {} + +/** + * Returns total bytes used by a given billed user across R2 (image + audio), + * Bunny Stream, and any active (non-expired) upload reservations. + * Uses the cached Bunny stats (10-min TTL) to avoid calling the Bunny API on + * every upload. + */ +export async function getUserTotalStorageBytes(userId: string): Promise { + const [r2Rows, bunnyByUser, reservationRows] = await Promise.all([ + db.$queryRaw<[{ total: bigint }]>` + SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total + FROM video_assets + WHERE "billedUserId" = ${userId} + AND provider IN ('R2_IMAGE', 'R2_AUDIO') + `, + getCachedUserBunnyStorage(), + db.$queryRaw<[{ total: bigint }]>` + SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total + FROM upload_reservations + WHERE "billedUserId" = ${userId} + AND "expiresAt" > NOW() + `, + ]); + + const r2Bytes = r2Rows[0]?.total ?? BigInt(0); + const bunnyBytes = BigInt(bunnyByUser[userId] ?? 0); + const reservedBytes = reservationRows[0]?.total ?? BigInt(0); + + return r2Bytes + bunnyBytes + reservedBytes; +} + +/** + * Returns storage usage info for a user in a UI-friendly shape. + */ +export async function getUserStorageInfo(userId: string): Promise<{ + usedBytes: bigint; + limitBytes: bigint; + percentage: number; +}> { + const usedBytes = await getUserTotalStorageBytes(userId); + const limitBytes = PLAN_STORAGE_LIMIT_BYTES; + const percentage = limitBytes > BigInt(0) + ? Math.min(100, Number((usedBytes * BigInt(10000)) / limitBytes) / 100) + : 0; + + return { usedBytes, limitBytes, percentage }; +} + +/** + * Checks whether the user can upload `incomingSizeBytes` more data. + * + * Returns a 507 response if the quota would be exceeded, or `null` if the + * upload is allowed. When Stripe is disabled the check is always skipped so + * self-hosted instances without billing still work. + * + * Uses `>=` so a user at exactly the limit cannot initiate new uploads. + */ +export async function enforceStorageQuota( + userId: string, + incomingSizeBytes: bigint, +): Promise { + if (!isStripeFeatureEnabled()) { + return null; + } + + const usedBytes = await getUserTotalStorageBytes(userId); + + if (usedBytes + incomingSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) { + return apiErrors.storageExceeded() as NextResponse; + } + + return null; +} + +/** + * Atomically checks the quota and records an in-flight upload reservation. + * + * Uses a PostgreSQL advisory transaction lock (per user) so concurrent callers + * are serialised: the second request sees the first reservation in the sum and + * cannot double-book the same headroom. + * + * Returns `{ reservationId }` on success or `{ error }` (a 507 NextResponse) + * when the quota would be exceeded. Call `releaseStorageReservation` to delete + * the reservation once the paired asset is committed (or if the upload fails). + * + * When Stripe is disabled the check is skipped and `reservationId` is `null`. + */ +export async function reserveStorageQuota( + userId: string, + incomingSizeBytes: bigint, +): Promise<{ reservationId: string | null } | { error: NextResponse }> { + if (!isStripeFeatureEnabled()) { + return { reservationId: null }; + } + + const expiresAt = new Date(Date.now() + RESERVATION_TTL_MS); + + // Fetch Bunny storage BEFORE entering the transaction to avoid holding the + // advisory lock during a potentially slow/failing HTTP call on cache miss. + const bunnyData = await getCachedUserBunnyStorage(); + const bunnyBytes = BigInt(bunnyData[userId] ?? 0); + + try { + const reservationId = await db.$transaction(async (tx) => { + // Serialise quota checks for this user via a per-user advisory lock. + // Combine two 32-bit hashtext() halves into a single 64-bit bigint to + // eliminate the 32-bit hash-space collision risk of plain hashtext(). + // Use $executeRaw — the function returns void which $queryRaw cannot deserialize. + await tx.$executeRaw` + SELECT pg_advisory_xact_lock( + ('x' || left(md5(${userId}), 16))::bit(64)::bigint + ) + `; + + // Read committed R2 storage under the lock + const [r2Row] = await tx.$queryRaw<[{ total: bigint }]>` + SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total + FROM video_assets + WHERE "billedUserId" = ${userId} + AND provider IN ('R2_IMAGE', 'R2_AUDIO') + `; + const r2Bytes = r2Row?.total ?? BigInt(0); + + // Read active (non-expired) reservations under the same lock + const [resRow] = await tx.$queryRaw<[{ total: bigint }]>` + SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total + FROM upload_reservations + WHERE "billedUserId" = ${userId} + AND "expiresAt" > NOW() + `; + const reservedBytes = resRow?.total ?? BigInt(0); + + const totalUsed = r2Bytes + reservedBytes + bunnyBytes; + if (totalUsed + incomingSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) { + throw new QuotaExceededError(); + } + + const reservation = await tx.uploadReservation.create({ + data: { billedUserId: userId, sizeBytes: incomingSizeBytes, expiresAt }, + select: { id: true }, + }); + + return reservation.id; + }); + + return { reservationId }; + } catch (e) { + if (e instanceof QuotaExceededError) { + return { error: apiErrors.storageExceeded() as NextResponse }; + } + throw e; + } +} + +/** + * Deletes an upload reservation created by `reserveStorageQuota`. + * Safe to call with `null` (no-op) for flows where billing is disabled. + */ +export async function releaseStorageReservation(reservationId: string | null): Promise { + if (!reservationId) return; + await db.uploadReservation.deleteMany({ where: { id: reservationId } }); +} diff --git a/prisma/migrations/20260414120000_add_size_bytes_to_video_assets/migration.sql b/prisma/migrations/20260414120000_add_size_bytes_to_video_assets/migration.sql new file mode 100644 index 0000000..c0e5e07 --- /dev/null +++ b/prisma/migrations/20260414120000_add_size_bytes_to_video_assets/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "video_assets" ADD COLUMN "size_bytes" BIGINT NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20260415120000_add_upload_reservations/migration.sql b/prisma/migrations/20260415120000_add_upload_reservations/migration.sql new file mode 100644 index 0000000..d7f74c0 --- /dev/null +++ b/prisma/migrations/20260415120000_add_upload_reservations/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "upload_reservations" ( + "id" TEXT NOT NULL, + "billedUserId" TEXT NOT NULL, + "sizeBytes" BIGINT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "upload_reservations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "upload_reservations_billedUserId_expiresAt_idx" ON "upload_reservations"("billedUserId", "expiresAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0d57b4e..1764939 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -399,6 +399,7 @@ model VideoAsset { uploadedByGuestName String? billedUserId String billedUser User @relation("VideoAssetBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade) + sizeBytes BigInt @default(0) @map("size_bytes") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -687,6 +688,19 @@ model WatchProgress { @@map("watch_progress") } +// Tracks in-flight R2 upload slots so concurrent uploads are counted against quota +// before the VideoAsset record is committed. Rows expire after a short TTL. +model UploadReservation { + id String @id @default(cuid()) + billedUserId String + sizeBytes BigInt + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([billedUserId, expiresAt]) + @@map("upload_reservations") +} + // Rate limiting table (created as UNLOGGED via raw SQL migration) // Defined here so `prisma db push` doesn't drop it model RateLimit { diff --git a/scripts/backfill-asset-sizes.ts b/scripts/backfill-asset-sizes.ts new file mode 100644 index 0000000..1c901a5 --- /dev/null +++ b/scripts/backfill-asset-sizes.ts @@ -0,0 +1,93 @@ +/** + * One-off backfill: populate size_bytes on existing VideoAsset rows (R2_IMAGE, R2_AUDIO) + * that still have the default value of 0. + * + * Run with: + * bun scripts/backfill-asset-sizes.ts + * + * Dry-run (no writes): + * bun scripts/backfill-asset-sizes.ts --dry-run + */ +import 'dotenv/config'; +import { HeadObjectCommand } from '@aws-sdk/client-s3'; +import { db, disconnectDb } from '../lib/db'; +import { r2Client, R2_BUCKET_NAME } from '../lib/r2'; +import { runWithConcurrency } from '../lib/async-pool'; + +const DRY_RUN = process.argv.includes('--dry-run'); +const CONCURRENCY = 20; + +function sourceUrlToR2Key(sourceUrl: string): string | null { + if (sourceUrl.startsWith('/api/upload/image/')) { + const filename = sourceUrl.slice('/api/upload/image/'.length); + return filename ? `images/${filename}` : null; + } + if (sourceUrl.startsWith('/api/upload/audio/')) { + const filename = sourceUrl.slice('/api/upload/audio/'.length); + return filename ? `voice/${filename}` : null; + } + return null; +} + +async function getR2ObjectSize(key: string): Promise { + try { + const head = await r2Client.send(new HeadObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })); + return head.ContentLength ?? null; + } catch { + return null; + } +} + +async function main() { + console.log(`Starting backfill${DRY_RUN ? ' (DRY RUN)' : ''}…`); + + const assets = await db.$queryRaw<{ id: string; sourceUrl: string; provider: string }[]>` + SELECT id, "sourceUrl", provider + FROM video_assets + WHERE provider IN ('R2_IMAGE', 'R2_AUDIO') + AND size_bytes = 0 + `; + + console.log(`Found ${assets.length} assets with sizeBytes = 0`); + if (assets.length === 0) { + await disconnectDb(); + return; + } + + let updated = 0; + let skipped = 0; + let missing = 0; + + await runWithConcurrency(assets, CONCURRENCY, async (asset) => { + const key = sourceUrlToR2Key(asset.sourceUrl); + if (!key) { + console.warn(` [SKIP] ${asset.id} — cannot derive R2 key from: ${asset.sourceUrl}`); + skipped++; + return; + } + + const size = await getR2ObjectSize(key); + if (size === null) { + console.warn(` [MISS] ${asset.id} — object not found in R2: ${key}`); + missing++; + return; + } + + if (!DRY_RUN) { + await db.$executeRaw` + UPDATE video_assets SET size_bytes = ${BigInt(size)} WHERE id = ${asset.id} + `; + } + + console.log(` [OK] ${asset.id} — ${key}: ${size} bytes`); + updated++; + }); + + console.log(`\nDone. Updated: ${updated}, Skipped: ${skipped}, Missing in R2: ${missing}`); + await disconnectDb(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 6c42fd23d3932386267a8180a461cd7c2f968c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Wed, 15 Apr 2026 19:55:10 +0300 Subject: [PATCH 8/9] feat(LandingPage): add option to download original uploaded video --- components/LandingPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index bac4eb2..0ebffc0 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -552,6 +552,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
  • Share links with permissions
  • Exports (PDF/CSV)
  • Unlimited YouTube Video Imports
  • +
  • Download original uploaded video
  • Includes: 200 GB Storage
  • Need more storage? Add 100 GB for $5/mo.

    From c9522eff82be26d6f20e953b471686ba74189efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Thu, 16 Apr 2026 15:27:25 +0300 Subject: [PATCH 9/9] feat(VideoPageHeader): move DownloadControls to a new position in the layout --- components/video-page/video-page-header.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/components/video-page/video-page-header.tsx b/components/video-page/video-page-header.tsx index 1a12176..2190bff 100644 --- a/components/video-page/video-page-header.tsx +++ b/components/video-page/video-page-header.tsx @@ -190,6 +190,16 @@ export const VideoPageHeader = memo(function VideoPageHeader({ onDelete={onDeleteVersion} /> +
    + +
    + {mode === 'dashboard' && ( <> {canManageVideo ? ( @@ -207,16 +217,6 @@ export const VideoPageHeader = memo(function VideoPageHeader({ ) : null} -
    - -
    - {versions.length >= 2 && (