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.
This commit is contained in:
Yusuf İpek
2026-04-15 19:53:43 +03:00
parent acf31b3d6b
commit 873945464d
21 changed files with 753 additions and 85 deletions
+1
View File
@@ -14,6 +14,7 @@
- Use `checkProjectAccess()` / `checkWorkspaceAccess()` for authorization instead of ad-hoc role checks. - Use `checkProjectAccess()` / `checkWorkspaceAccess()` for authorization instead of ad-hoc role checks.
- For API responses, use `successResponse` / `apiErrors` from `@/lib/api-response`. - For API responses, use `successResponse` / `apiErrors` from `@/lib/api-response`.
- Keep API and UI imports on `@/` aliases when available. - 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 ## Important locations
- Custom SQL managed by Prisma migrations: `prisma/migrations/*/migration.sql`. - Custom SQL managed by Prisma migrations: `prisma/migrations/*/migration.sql`.
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { import {
Select, Select,
SelectContent, 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({ function ToggleButton({
enabled, enabled,
onToggle, onToggle,
@@ -124,6 +139,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
const [billing, setBilling] = useState<BillingOverview | null>(null); const [billing, setBilling] = useState<BillingOverview | null>(null);
const [billingLoading, setBillingLoading] = useState(true); const [billingLoading, setBillingLoading] = useState(true);
const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | null>(null); const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | null>(null);
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
const [storageLoading, setStorageLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Form state for Telegram chat ID (separate from saved settings for editing) // 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(() => { useEffect(() => {
async function fetchSettings() { async function fetchSettings() {
try { try {
const [settingsRes, billingRes] = await Promise.all([ const [settingsRes, billingRes, storageRes] = await Promise.all([
fetch('/api/settings/notifications'), fetch('/api/settings/notifications'),
fetch('/api/billing'), fetch('/api/billing'),
fetch('/api/settings/storage'),
]); ]);
if (settingsRes.ok) { if (settingsRes.ok) {
@@ -151,11 +169,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
const data = await billingRes.json(); const data = await billingRes.json();
setBilling(data.data); setBilling(data.data);
} }
if (storageRes.ok) {
const data = await storageRes.json();
setStorageInfo(data.data);
}
} catch { } catch {
console.error('Failed to fetch settings'); console.error('Failed to fetch settings');
} finally { } finally {
setLoading(false); setLoading(false);
setBillingLoading(false); setBillingLoading(false);
setStorageLoading(false);
} }
} }
fetchSettings(); fetchSettings();
@@ -448,6 +472,62 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</CardContent> </CardContent>
</Card> </Card>
{billing?.subscription.hasBillingAccess && (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5" />
Storage
</CardTitle>
<CardDescription>
Combined usage across video files and media attachments (200 GB limit)
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{storageLoading || !storageInfo ? (
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
) : (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{formatBytes(storageInfo.usedBytes)} used of {formatBytes(storageInfo.limitBytes)}
</span>
<span
className={
storageInfo.percentage >= 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)}%`}
</span>
</div>
<Progress
value={storageInfo.percentage}
className={
storageInfo.percentage >= 90
? '[&>div]:bg-destructive'
: storageInfo.percentage >= 75
? '[&>div]:bg-amber-500'
: ''
}
/>
{storageInfo.percentage >= 90 && (
<p className="text-xs text-destructive">
Storage is almost full. Delete unused files or contact support.
</p>
)}
</>
)}
</CardContent>
</Card>
)}
{!billingOnly && ( {!billingOnly && (
<> <>
{/* Event Subscriptions */} {/* Event Subscriptions */}
+3
View File
@@ -189,6 +189,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
if (annotationData === null) { if (annotationData === null) {
updateData.annotationData = null; updateData.annotationData = null;
} else { } else {
if (!Array.isArray(annotationData)) {
return apiErrors.badRequest('annotationData must be an array of valid stroke objects');
}
const validStrokes = validateAnnotationStrokes(annotationData); const validStrokes = validateAnnotationStrokes(annotationData);
if (validStrokes === null) { if (validStrokes === null) {
return apiErrors.badRequest('annotationData must be an array of valid stroke objects'); return apiErrors.badRequest('annotationData must be an array of valid stroke objects');
@@ -8,13 +8,14 @@ import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token'; import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { enforceStorageQuota } from '@/lib/storage-quota';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
async function getProjectWithEditAccess(projectId: string, userId: string) { async function getProjectWithEditAccess(projectId: string, userId: string) {
const project = await db.project.findUnique({ const project = await db.project.findUnique({
where: { id: projectId }, 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; 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'); 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 apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID; const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
+40
View File
@@ -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');
}
+23 -2
View File
@@ -13,6 +13,7 @@ import {
enforceGuestUploadQuota, enforceGuestUploadQuota,
verifyGuestUploadToken, verifyGuestUploadToken,
} from '@/lib/guest-upload-token'; } from '@/lib/guest-upload-token';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -118,7 +119,11 @@ export async function POST(request: NextRequest) {
const safeVideoId = videoId.trim(); const safeVideoId = videoId.trim();
const video = await db.video.findUnique({ const video = await db.video.findUnique({
where: { id: safeVideoId }, where: { id: safeVideoId },
include: { project: true }, include: {
project: {
include: { workspace: { select: { ownerId: true } } },
},
},
}); });
if (!video) { if (!video) {
return apiErrors.notFound('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.'); 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 // Normalize content type: strip codec params, then resolve aliases
const rawContentType = file.type || 'audio/webm'; const rawContentType = file.type || 'audio/webm';
const strippedType = rawContentType.split(';')[0].trim().toLowerCase(); const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
const contentType = MIME_ALIASES[strippedType] ?? strippedType; const contentType = MIME_ALIASES[strippedType] ?? strippedType;
if (!ALLOWED_TYPES.has(contentType)) { if (!ALLOWED_TYPES.has(contentType)) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`); return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`);
} }
@@ -191,12 +205,15 @@ export async function POST(request: NextRequest) {
// Validate file content against magic bytes — rejects HTML/scripts masquerading as audio // Validate file content against magic bytes — rejects HTML/scripts masquerading as audio
if (isHtmlContent(buffer)) { if (isHtmlContent(buffer)) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest('File content does not match an audio format'); return apiErrors.badRequest('File content does not match an audio format');
} }
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) { if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest('File content does not match the declared audio format'); return apiErrors.badRequest('File content does not match the declared audio format');
} }
try {
// Upload to R2 // Upload to R2
await r2Client.send( await r2Client.send(
new PutObjectCommand({ new PutObjectCommand({
@@ -206,11 +223,15 @@ export async function POST(request: NextRequest) {
ContentType: contentType, ContentType: contentType,
}) })
); );
} catch (uploadError) {
await releaseStorageReservation(reservationId);
throw uploadError;
}
// Return the URL through our proxy endpoint // Return the URL through our proxy endpoint
const voiceUrl = `/api/upload/audio/${filename}`; 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'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
logError('Error uploading audio:', error); logError('Error uploading audio:', error);
+23 -3
View File
@@ -20,6 +20,7 @@ import {
verifyGuestUploadToken, verifyGuestUploadToken,
} from '@/lib/guest-upload-token'; } from '@/lib/guest-upload-token';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead 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 safeVideoId = videoId.trim();
const video = await db.video.findUnique({ const video = await db.video.findUnique({
where: { id: safeVideoId }, where: { id: safeVideoId },
include: { project: true }, include: {
project: {
include: { workspace: { select: { ownerId: true } } },
},
},
}); });
if (!video) { if (!video) {
return apiErrors.notFound('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.'); 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 // Check content type
const normalizedMime = normalizeImageMime(file.type); const normalizedMime = normalizeImageMime(file.type);
if (normalizedMime && !isAllowedImageType(normalizedMime)) { if (normalizedMime && !isAllowedImageType(normalizedMime)) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest(`Unsupported image format: ${file.type}`); return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
} }
@@ -127,6 +141,7 @@ export async function POST(request: NextRequest) {
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const detectedMime = detectImageMime(buffer); const detectedMime = detectImageMime(buffer);
if (!detectedMime) { if (!detectedMime) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest('Uploaded file content does not match an allowed image type'); return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
} }
@@ -135,6 +150,7 @@ export async function POST(request: NextRequest) {
const filename = `${randomUUID()}.${ext}`; const filename = `${randomUUID()}.${ext}`;
const key = `images/${filename}`; const key = `images/${filename}`;
try {
// Upload to R2 // Upload to R2
await r2Client.send( await r2Client.send(
new PutObjectCommand({ new PutObjectCommand({
@@ -144,12 +160,16 @@ export async function POST(request: NextRequest) {
ContentType: detectedMime, ContentType: detectedMime,
}) })
); );
} catch (uploadError) {
await releaseStorageReservation(reservationId);
throw uploadError;
}
// Return the URL through our proxy endpoint // Return the URL through our proxy endpoint
const imageUrl = `/api/upload/image/${filename}`; const imageUrl = `/api/upload/image/${filename}`;
const response = successResponse({ url: imageUrl }, 201); const response = successResponse({ url: imageUrl, reservationId }, 201);
return withCacheControl(response, 'public, max-age=31536000, immutable'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
logError('Error uploading image:', error); logError('Error uploading image:', error);
return apiErrors.internalError('Failed to upload image'); return apiErrors.internalError('Failed to upload image');
+66 -9
View File
@@ -9,18 +9,21 @@ import { getShareSessionFromRequest } from '@/lib/share-session';
import { HeadObjectCommand } from '@aws-sdk/client-s3'; import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; 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 { validateAnnotationStrokes } from '@/lib/validation';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
type RouteParams = { params: Promise<{ versionId: string }> }; 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_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 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; const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<boolean> { type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<AttachmentCheck> {
const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/'; 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 filename = url.slice(prefix.length);
const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`; const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`;
@@ -32,10 +35,11 @@ async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<
Key: key, Key: key,
}) })
); );
if (!head.LastModified) return false; if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
} catch { } 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 // POST /api/versions/[versionId]/comments
export async function POST(request: NextRequest, { params }: RouteParams) { export async function POST(request: NextRequest, { params }: RouteParams) {
let attachmentReservationId: string | null = null;
try { try {
const limited = await rateLimit(request, 'comment'); const limited = await rateLimit(request, 'comment');
if (limited) return limited; 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. // Validate annotation data structure to prevent prototype pollution and stored XSS.
// Reject anything that is not a well-formed array of AnnotationStroke objects. // 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; let serializedAnnotationData: string | null = null;
if (annotationData !== undefined && annotationData !== 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); const validStrokes = validateAnnotationStrokes(annotationData);
if (validStrokes === null) { if (validStrokes === null) {
return apiErrors.badRequest('annotationData must be an array of valid stroke objects'); 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)) { if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) {
return apiErrors.badRequest('Voice URL must reference an uploaded audio file'); return apiErrors.badRequest('Voice URL must reference an uploaded audio file');
} }
if (voiceUrl && !(await isFreshAttachment(voiceUrl, 'audio'))) { let voiceSizeBytes = BigInt(0);
if (voiceUrl) {
const voiceCheck = await isFreshAttachment(voiceUrl, 'audio');
if (!voiceCheck.isFresh) {
return apiErrors.badRequest('Voice upload expired. Please upload again.'); return apiErrors.badRequest('Voice upload expired. Please upload again.');
} }
voiceSizeBytes = voiceCheck.sizeBytes;
}
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) { if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
return apiErrors.badRequest('Image URL must reference an uploaded image file'); return apiErrors.badRequest('Image URL must reference an uploaded image file');
} }
if (imageUrl && !(await isFreshAttachment(imageUrl, 'image'))) { let imageSizeBytes = BigInt(0);
if (imageUrl) {
const imageCheck = await isFreshAttachment(imageUrl, 'image');
if (!imageCheck.isFresh) {
return apiErrors.badRequest('Image upload expired. Please upload again.'); return apiErrors.badRequest('Image upload expired. Please upload again.');
} }
imageSizeBytes = imageCheck.sizeBytes;
}
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; 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) => { 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({ const comment = await tx.comment.create({
data: { data: {
content: content?.trim() || null, content: content?.trim() || null,
@@ -375,6 +408,29 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
displayName, displayName,
sourceUrl: imageUrl, sourceUrl: imageUrl,
thumbnailUrl: 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, uploadedByUserId: session?.user?.id || null,
uploadedByGuestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, uploadedByGuestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null,
uploadedByGuestName: isGuest ? safeGuestName : null, uploadedByGuestName: isGuest ? safeGuestName : null,
@@ -450,6 +506,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
await releaseStorageReservation(attachmentReservationId);
logError('Error creating comment:', error); logError('Error creating comment:', error);
return apiErrors.internalError('Failed to create comment'); return apiErrors.internalError('Failed to create comment');
} }
@@ -14,6 +14,7 @@ import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
import { getShareSessionFromRequest } from '@/lib/share-session'; import { getShareSessionFromRequest } from '@/lib/share-session';
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets'; import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { enforceStorageQuota } from '@/lib/storage-quota';
type RouteParams = { params: Promise<{ videoId: string }> }; 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'); 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); const shareSession = getShareSessionFromRequest(request, context.video.id);
if (!context.viewerUserId) { if (!context.viewerUserId) {
const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null); const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null);
+117 -14
View File
@@ -25,6 +25,13 @@ import {
sanitizeAssetDisplayName, sanitizeAssetDisplayName,
} from '@/lib/video-assets'; } from '@/lib/video-assets';
import { logError } from '@/lib/logger'; 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 }> }; type RouteParams = { params: Promise<{ videoId: string }> };
@@ -147,35 +154,39 @@ async function fetchYouTubeTitle(videoId: string): Promise<string | null> {
return title; return title;
} }
async function isFreshImageAttachment(url: string): Promise<boolean> { type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
async function isFreshImageAttachment(url: string): Promise<AttachmentCheck> {
const key = extractImageKeyFromProxyUrl(url); const key = extractImageKeyFromProxyUrl(url);
if (!key) return false; if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
try { try {
const head = await r2Client.send(new HeadObjectCommand({ const head = await r2Client.send(new HeadObjectCommand({
Bucket: R2_BUCKET_NAME, Bucket: R2_BUCKET_NAME,
Key: key, Key: key,
})); }));
if (!head.LastModified) return false; if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
} catch { } catch {
return false; return { isFresh: false, sizeBytes: BigInt(0) };
} }
} }
async function isFreshAudioAttachment(url: string): Promise<boolean> { async function isFreshAudioAttachment(url: string): Promise<AttachmentCheck> {
const key = extractAudioKeyFromProxyUrl(url); const key = extractAudioKeyFromProxyUrl(url);
if (!key) return false; if (!key) return { isFresh: false, sizeBytes: BigInt(0) };
try { try {
const head = await r2Client.send(new HeadObjectCommand({ const head = await r2Client.send(new HeadObjectCommand({
Bucket: R2_BUCKET_NAME, Bucket: R2_BUCKET_NAME,
Key: key, Key: key,
})); }));
if (!head.LastModified) return false; if (!head.LastModified) return { isFresh: false, sizeBytes: BigInt(0) };
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
} catch { } 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 // POST /api/videos/[videoId]/assets
export async function POST(request: NextRequest, { params }: RouteParams) { export async function POST(request: NextRequest, { params }: RouteParams) {
let reservationId: string | null = null;
try { try {
const limited = await rateLimit(request, 'asset-create'); const limited = await rateLimit(request, 'asset-create');
if (limited) return limited; if (limited) return limited;
@@ -293,20 +305,38 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
const requestedDisplayName = typeof body?.displayName === 'string' ? body.displayName : 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 displayName = '';
let sourceUrl = ''; let sourceUrl = '';
let providerVideoId: string | null = null; let providerVideoId: string | null = null;
let thumbnailUrl: string | null = null; let thumbnailUrl: string | null = null;
let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE'; let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE';
let assetSizeBytes = BigInt(0);
const billedUserId = context.video.project.workspace.ownerId;
if (provider === VideoAssetProvider.R2_IMAGE) { if (provider === VideoAssetProvider.R2_IMAGE) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
if (!SAFE_IMAGE_PROXY_PATH.test(sourceUrl)) { if (!SAFE_IMAGE_PROXY_PATH.test(sourceUrl)) {
return apiErrors.badRequest('Image URL must reference an uploaded image file'); 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.'); 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); const fileName = extractImageFileNameFromProxyUrl(sourceUrl);
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Image'); displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Image');
@@ -319,9 +349,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (!SAFE_AUDIO_PROXY_PATH.test(sourceUrl)) { if (!SAFE_AUDIO_PROXY_PATH.test(sourceUrl)) {
return apiErrors.badRequest('Audio URL must reference an uploaded audio file'); 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.'); 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); const fileName = extractAudioFileNameFromProxyUrl(sourceUrl);
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Voice Recording'); displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Voice Recording');
@@ -405,9 +444,67 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
} }
kind = 'VIDEO'; kind = 'VIDEO';
const quotaError = await enforceStorageQuota(billedUserId, BigInt(0));
if (quotaError) return quotaError;
} }
const created = await db.videoAsset.create({ // 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: { data: {
videoId: context.video.id, videoId: context.video.id,
kind, kind,
@@ -416,12 +513,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
sourceUrl, sourceUrl,
providerVideoId, providerVideoId,
thumbnailUrl, thumbnailUrl,
sizeBytes: assetSizeBytes,
uploadedByUserId: context.viewerUserId, uploadedByUserId: context.viewerUserId,
uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null, uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null,
uploadedByGuestName: context.viewerUserId uploadedByGuestName: context.viewerUserId
? null ? null
: sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'), : sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'),
billedUserId: context.video.project.workspace.ownerId, billedUserId,
}, },
select: { select: {
id: true, id: true,
@@ -440,6 +538,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}, },
}, },
}); });
});
const response = successResponse(shapeAssetForViewer( const response = successResponse(shapeAssetForViewer(
created, created,
@@ -451,6 +550,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
if (error instanceof QuotaExceededInTxError) {
return apiErrors.storageExceeded() as NextResponse;
}
await releaseStorageReservation(reservationId);
logError('Error creating video asset:', error); logError('Error creating video asset:', error);
return apiErrors.internalError('Failed to create asset'); return apiErrors.internalError('Failed to create asset');
} }
+23
View File
@@ -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 (
<div
data-slot="progress"
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<div
className="h-full bg-primary transition-all"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
export { Progress };
+5 -2
View File
@@ -33,7 +33,7 @@ function formatTime(seconds: number): string {
} }
type UploadAudioResponse = { type UploadAudioResponse = {
data?: { url?: string }; data?: { url?: string; reservationId?: string | null };
error?: string; error?: string;
}; };
@@ -74,6 +74,7 @@ interface AssetsPaneProps {
providerVideoId?: string; providerVideoId?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
uploadToken?: string; uploadToken?: string;
reservationId?: string | null;
}) => Promise<VideoAsset | null>; }) => Promise<VideoAsset | null>;
deleteAsset: (assetId: string) => Promise<boolean>; deleteAsset: (assetId: string) => Promise<boolean>;
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>; downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
@@ -303,7 +304,7 @@ export const AssetsPane = memo(function AssetsPane({
method: 'POST', method: 'POST',
body: formData, 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; const uploadedImageUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedImageUrl) { if (!uploadRes.ok || !uploadedImageUrl) {
toast.error(uploadPayload?.error || 'Failed to upload image'); toast.error(uploadPayload?.error || 'Failed to upload image');
@@ -314,6 +315,7 @@ export const AssetsPane = memo(function AssetsPane({
provider: 'R2_IMAGE', provider: 'R2_IMAGE',
sourceUrl: uploadedImageUrl, sourceUrl: uploadedImageUrl,
displayName: imageTitle.trim() || file.name, displayName: imageTitle.trim() || file.name,
reservationId: uploadPayload?.data?.reservationId ?? null,
}); });
if (imageInputRef.current) imageInputRef.current.value = ''; if (imageInputRef.current) imageInputRef.current.value = '';
setImageTitle(''); setImageTitle('');
@@ -586,6 +588,7 @@ export const AssetsPane = memo(function AssetsPane({
provider: 'R2_AUDIO', provider: 'R2_AUDIO',
sourceUrl: uploadedUrl, sourceUrl: uploadedUrl,
displayName: voiceTitle.trim() || fallbackName, displayName: voiceTitle.trim() || fallbackName,
reservationId: uploadPayload?.data?.reservationId ?? null,
}); });
setVoiceTitle(''); setVoiceTitle('');
setAudioBlob(null); setAudioBlob(null);
@@ -207,7 +207,7 @@ export function useCommentActions({
...(imageData && { imageUrl: imageData.url }), ...(imageData && { imageUrl: imageData.url }),
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
...(selectedTagId && { tagId: selectedTagId }), ...(selectedTagId && { tagId: selectedTagId }),
...(serializedAnnotation && { annotationData: serializedAnnotation }), ...(effectiveStrokes && { annotationData: effectiveStrokes }),
}), }),
}); });
@@ -830,7 +830,9 @@ export function useCommentActions({
try { try {
const body: Record<string, unknown> = { content: editText }; const body: Record<string, unknown> = { content: editText };
if (editTagId !== undefined) body.tagId = editTagId; 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; if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName;
const res = await fetch(`/api/comments/${commentId}`, { const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
@@ -13,6 +13,7 @@ type CreateAssetPayload = {
providerVideoId?: string; providerVideoId?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
uploadToken?: string; uploadToken?: string;
reservationId?: string | null;
}; };
interface UseVideoAssetsParams { interface UseVideoAssetsParams {
+1 -1
View File
@@ -7,7 +7,7 @@ import { getStripe, getStripePriceId } from '@/lib/stripe';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
const BUNNY_API_BASE = 'https://video.bunnycdn.com'; const BUNNY_API_BASE = 'https://video.bunnycdn.com';
const STORAGE_CACHE_SECONDS = 600; const STORAGE_CACHE_SECONDS = 120;
interface R2StorageSnapshot { interface R2StorageSnapshot {
fileSizes: Map<string, number>; fileSizes: Map<string, number>;
+7
View File
@@ -36,6 +36,7 @@ export const HttpStatus = {
CONFLICT: 409, CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422, UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429, TOO_MANY_REQUESTS: 429,
INSUFFICIENT_STORAGE: 507,
INTERNAL_SERVER_ERROR: 500, INTERNAL_SERVER_ERROR: 500,
} as const; } as const;
@@ -62,6 +63,9 @@ export const ErrorCode = {
// Server errors // Server errors
INTERNAL_ERROR: "INTERNAL_ERROR", INTERNAL_ERROR: "INTERNAL_ERROR",
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
// Storage errors
STORAGE_LIMIT_EXCEEDED: "STORAGE_LIMIT_EXCEEDED",
} as const; } as const;
/** /**
@@ -158,4 +162,7 @@ export const apiErrors = {
internalError: (message = "Internal server error") => internalError: (message = "Internal server error") =>
errorResponse(message, HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_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),
}; };
+176
View File
@@ -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<bigint> {
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<NextResponse | null> {
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<void> {
if (!reservationId) return;
await db.uploadReservation.deleteMany({ where: { id: reservationId } });
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "video_assets" ADD COLUMN "size_bytes" BIGINT NOT NULL DEFAULT 0;
@@ -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");
+14
View File
@@ -399,6 +399,7 @@ model VideoAsset {
uploadedByGuestName String? uploadedByGuestName String?
billedUserId String billedUserId String
billedUser User @relation("VideoAssetBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade) billedUser User @relation("VideoAssetBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade)
sizeBytes BigInt @default(0) @map("size_bytes")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -687,6 +688,19 @@ model WatchProgress {
@@map("watch_progress") @@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) // Rate limiting table (created as UNLOGGED via raw SQL migration)
// Defined here so `prisma db push` doesn't drop it // Defined here so `prisma db push` doesn't drop it
model RateLimit { model RateLimit {
+93
View File
@@ -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<number | null> {
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);
});