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); +});