mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge branch 'yusufipk:master' into master
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
- run: bun install
|
||||||
|
- run: bun run check
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ COPY public ./public
|
|||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
COPY types ./types
|
COPY types ./types
|
||||||
COPY components.json ./components.json
|
COPY components.json ./components.json
|
||||||
COPY next-env.d.ts ./next-env.d.ts
|
|
||||||
COPY next.config.ts ./next.config.ts
|
COPY next.config.ts ./next.config.ts
|
||||||
COPY postcss.config.mjs ./postcss.config.mjs
|
COPY postcss.config.mjs ./postcss.config.mjs
|
||||||
COPY prisma.config.ts ./prisma.config.ts
|
COPY prisma.config.ts ./prisma.config.ts
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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,26 +205,33 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload to R2
|
try {
|
||||||
await r2Client.send(
|
// Upload to R2
|
||||||
new PutObjectCommand({
|
await r2Client.send(
|
||||||
Bucket: R2_BUCKET_NAME,
|
new PutObjectCommand({
|
||||||
Key: key,
|
Bucket: R2_BUCKET_NAME,
|
||||||
Body: buffer,
|
Key: key,
|
||||||
ContentType: contentType,
|
Body: buffer,
|
||||||
})
|
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);
|
||||||
|
|||||||
@@ -37,38 +37,38 @@ export async function GET(
|
|||||||
// Parallelize the DB lookup and session check to narrow the timing delta
|
// Parallelize the DB lookup and session check to narrow the timing delta
|
||||||
// between "asset not found" and "asset found, access denied" responses.
|
// between "asset not found" and "asset found, access denied" responses.
|
||||||
const imageUrl = `/api/upload/image/${filename}`;
|
const imageUrl = `/api/upload/image/${filename}`;
|
||||||
const [comment, session] = await Promise.all([
|
const projectSelect = {
|
||||||
|
id: true,
|
||||||
|
ownerId: true,
|
||||||
|
workspaceId: true,
|
||||||
|
visibility: true,
|
||||||
|
} as const;
|
||||||
|
const videoSelect = {
|
||||||
|
id: true,
|
||||||
|
projectId: true,
|
||||||
|
project: { select: projectSelect },
|
||||||
|
} as const;
|
||||||
|
const [comment, videoAsset, session] = await Promise.all([
|
||||||
db.comment.findFirst({
|
db.comment.findFirst({
|
||||||
where: { imageUrl },
|
where: { imageUrl },
|
||||||
select: {
|
select: {
|
||||||
version: {
|
version: {
|
||||||
select: {
|
select: { video: { select: videoSelect } },
|
||||||
video: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
projectId: true,
|
|
||||||
project: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
ownerId: true,
|
|
||||||
workspaceId: true,
|
|
||||||
visibility: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
db.videoAsset.findFirst({
|
||||||
|
where: { sourceUrl: imageUrl },
|
||||||
|
select: { video: { select: videoSelect } },
|
||||||
|
}),
|
||||||
auth(),
|
auth(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!comment) {
|
const video = comment?.version?.video ?? videoAsset?.video ?? null;
|
||||||
|
if (!video) {
|
||||||
return apiErrors.forbidden('Access denied');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { video } = comment.version;
|
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||||
|
|
||||||
if (!access.hasAccess) {
|
if (!access.hasAccess) {
|
||||||
|
|||||||
@@ -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,21 +150,26 @@ export async function POST(request: NextRequest) {
|
|||||||
const filename = `${randomUUID()}.${ext}`;
|
const filename = `${randomUUID()}.${ext}`;
|
||||||
const key = `images/${filename}`;
|
const key = `images/${filename}`;
|
||||||
|
|
||||||
// Upload to R2
|
try {
|
||||||
await r2Client.send(
|
// Upload to R2
|
||||||
new PutObjectCommand({
|
await r2Client.send(
|
||||||
Bucket: R2_BUCKET_NAME,
|
new PutObjectCommand({
|
||||||
Key: key,
|
Bucket: R2_BUCKET_NAME,
|
||||||
Body: buffer,
|
Key: key,
|
||||||
ContentType: detectedMime,
|
Body: buffer,
|
||||||
})
|
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');
|
||||||
|
|||||||
@@ -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);
|
||||||
return apiErrors.badRequest('Voice upload expired. Please upload again.');
|
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)) {
|
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);
|
||||||
return apiErrors.badRequest('Image upload expired. Please upload again.');
|
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;
|
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,19 +21,6 @@ const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
|
|||||||
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
|
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
|
||||||
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
|
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
|
||||||
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000;
|
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000;
|
||||||
const SAFE_DOWNLOAD_CONTENT_TYPE = 'application/octet-stream';
|
|
||||||
const CONTENT_TYPE_EXTENSION_MAP: Record<string, string> = {
|
|
||||||
'video/mp4': '.mp4',
|
|
||||||
'video/quicktime': '.mov',
|
|
||||||
'video/webm': '.webm',
|
|
||||||
'video/x-matroska': '.mkv',
|
|
||||||
'video/x-msvideo': '.avi',
|
|
||||||
'video/mpeg': '.mpeg',
|
|
||||||
'video/3gpp': '.3gp',
|
|
||||||
'video/ogg': '.ogv',
|
|
||||||
};
|
|
||||||
const SAFE_VIDEO_CONTENT_TYPES = new Set(Object.keys(CONTENT_TYPE_EXTENSION_MAP));
|
|
||||||
const SAFE_VIDEO_EXTENSIONS = new Set(Object.values(CONTENT_TYPE_EXTENSION_MAP));
|
|
||||||
|
|
||||||
type BunnyDownloadSourceCacheRecord = {
|
type BunnyDownloadSourceCacheRecord = {
|
||||||
source: BunnyDownloadSource | null;
|
source: BunnyDownloadSource | null;
|
||||||
@@ -43,29 +30,6 @@ type BunnyDownloadSourceCacheRecord = {
|
|||||||
const BUNNY_SOURCE_CACHE_MAX_ENTRIES = 500;
|
const BUNNY_SOURCE_CACHE_MAX_ENTRIES = 500;
|
||||||
const bunnyDownloadSourceCache = new Map<string, BunnyDownloadSourceCacheRecord>();
|
const bunnyDownloadSourceCache = new Map<string, BunnyDownloadSourceCacheRecord>();
|
||||||
|
|
||||||
function sanitizeFileName(value: string): string {
|
|
||||||
const sanitized = value
|
|
||||||
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
return sanitized.length > 0 ? sanitized : 'video';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAsciiFileName(value: string): string {
|
|
||||||
const normalized = value
|
|
||||||
.normalize('NFKD')
|
|
||||||
.replace(/[^\x20-\x7E]/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
return normalized.length > 0 ? normalized : 'video';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContentDisposition(fileNameWithExt: string): string {
|
|
||||||
const asciiFallback = toAsciiFileName(fileNameWithExt).replace(/["\\]/g, '_');
|
|
||||||
const encoded = encodeURIComponent(fileNameWithExt);
|
|
||||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBunnyCdnHostname(): string | null {
|
function resolveBunnyCdnHostname(): string | null {
|
||||||
return resolveServerBunnyCdnHostname();
|
return resolveServerBunnyCdnHostname();
|
||||||
}
|
}
|
||||||
@@ -264,80 +228,6 @@ function extractHeightFromBunnyMp4Url(url: string): number | null {
|
|||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractFileNameFromContentDisposition(contentDisposition: string | null): string | null {
|
|
||||||
if (!contentDisposition) return null;
|
|
||||||
|
|
||||||
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
|
||||||
if (utf8Match?.[1]) {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(utf8Match[1]);
|
|
||||||
} catch {
|
|
||||||
return utf8Match[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
|
|
||||||
return fallbackMatch?.[1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractFileExtension(fileName: string | null): string | null {
|
|
||||||
if (!fileName) return null;
|
|
||||||
const dotIndex = fileName.lastIndexOf('.');
|
|
||||||
if (dotIndex <= 0 || dotIndex === fileName.length - 1) return null;
|
|
||||||
const extension = fileName.slice(dotIndex).toLowerCase();
|
|
||||||
return /^[.][a-z0-9]{1,10}$/i.test(extension) ? extension : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferExtensionFromContentType(contentType: string | null): string | null {
|
|
||||||
if (!contentType) return null;
|
|
||||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
|
||||||
return normalized ? CONTENT_TYPE_EXTENSION_MAP[normalized] ?? null : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeContentType(contentType: string | null): string | null {
|
|
||||||
if (!contentType) return null;
|
|
||||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
|
||||||
return normalized || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSafeDownloadMetadata(
|
|
||||||
sourceType: BunnyDownloadSource['sourceType'],
|
|
||||||
sourceFileName: string | null,
|
|
||||||
sourceContentType: string | null
|
|
||||||
): { extension: string; contentType: string } | null {
|
|
||||||
const rawExtension = extractFileExtension(sourceFileName);
|
|
||||||
const sourceExtension = rawExtension && SAFE_VIDEO_EXTENSIONS.has(rawExtension) ? rawExtension : null;
|
|
||||||
|
|
||||||
const normalizedContentType = normalizeContentType(sourceContentType);
|
|
||||||
const safeContentType =
|
|
||||||
normalizedContentType && SAFE_VIDEO_CONTENT_TYPES.has(normalizedContentType)
|
|
||||||
? normalizedContentType
|
|
||||||
: null;
|
|
||||||
const inferredExtension = safeContentType ? inferExtensionFromContentType(safeContentType) : null;
|
|
||||||
const fallbackExtension = sourceType === 'compressed' ? '.mp4' : null;
|
|
||||||
|
|
||||||
const extension = sourceExtension || inferredExtension || fallbackExtension;
|
|
||||||
if (!extension) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
extension,
|
|
||||||
contentType: safeContentType ?? (sourceType === 'compressed' ? 'video/mp4' : SAFE_DOWNLOAD_CONTENT_TYPE),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEstimatedBytes(contentLengthHeader: string | null): bigint {
|
|
||||||
if (!contentLengthHeader) return BigInt(0);
|
|
||||||
const normalized = contentLengthHeader.trim();
|
|
||||||
if (!/^\d+$/.test(normalized)) return BigInt(0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = BigInt(normalized);
|
|
||||||
return parsed > BigInt(0) ? parsed : BigInt(0);
|
|
||||||
} catch {
|
|
||||||
return BigInt(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/versions/[versionId]/download
|
// GET /api/versions/[versionId]/download
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
@@ -437,38 +327,19 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetchWithTimeout(source.url, { cache: 'no-store' });
|
// Fetch Content-Length via HEAD so we can record egress bytes without proxying the body.
|
||||||
if (!upstream.ok || !upstream.body) {
|
let estimatedBytes = BigInt(0);
|
||||||
return apiErrors.notFound('Download file');
|
try {
|
||||||
|
const headRes = await fetchWithTimeout(source.url, { method: 'HEAD', cache: 'no-store' });
|
||||||
|
const cl = headRes.headers.get('content-length');
|
||||||
|
if (cl && /^\d+$/.test(cl.trim())) {
|
||||||
|
const parsed = BigInt(cl.trim());
|
||||||
|
if (parsed > BigInt(0)) estimatedBytes = parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort — leave estimatedBytes as 0 if HEAD fails.
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionLabel = version.versionLabel?.trim() || `v${version.versionNumber}`;
|
|
||||||
const sourceFileName = extractFileNameFromContentDisposition(upstream.headers.get('content-disposition'));
|
|
||||||
const metadata = resolveSafeDownloadMetadata(
|
|
||||||
source.sourceType,
|
|
||||||
sourceFileName,
|
|
||||||
upstream.headers.get('content-type')
|
|
||||||
);
|
|
||||||
if (!metadata) {
|
|
||||||
return apiErrors.badRequest('Original file format is not supported for download');
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = sanitizeFileName(`${version.video.title} ${versionLabel}`) + metadata.extension;
|
|
||||||
const contentDisposition = buildContentDisposition(filename);
|
|
||||||
|
|
||||||
const response = new Response(upstream.body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': metadata.contentType,
|
|
||||||
'Content-Disposition': contentDisposition,
|
|
||||||
'Cache-Control': 'private, no-store',
|
|
||||||
'X-Content-Type-Options': 'nosniff',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentLength = upstream.headers.get('content-length');
|
|
||||||
if (contentLength) response.headers.set('Content-Length', contentLength);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.downloadEgressEvent.create({
|
await db.downloadEgressEvent.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -480,14 +351,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
downloaderUserId: session?.user?.id ?? null,
|
downloaderUserId: session?.user?.id ?? null,
|
||||||
source: source.sourceType === 'original' ? DownloadEgressSource.ORIGINAL : DownloadEgressSource.COMPRESSED,
|
source: source.sourceType === 'original' ? DownloadEgressSource.ORIGINAL : DownloadEgressSource.COMPRESSED,
|
||||||
quality: source.quality,
|
quality: source.quality,
|
||||||
estimatedBytes: parseEstimatedBytes(contentLength),
|
estimatedBytes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (egressError) {
|
} catch (egressError) {
|
||||||
logError('Failed to record download egress event:', egressError);
|
logError('Failed to record download egress event:', egressError);
|
||||||
}
|
}
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-store');
|
return Response.redirect(source.url, 302);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Error downloading version:', error);
|
logError('Error downloading version:', error);
|
||||||
return apiErrors.internalError('Failed to download video');
|
return apiErrors.internalError('Failed to download video');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,40 +444,100 @@ 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
|
||||||
data: {
|
// HTTP call while holding a DB connection open (connection-pool exhaustion
|
||||||
videoId: context.video.id,
|
// risk under adversarial load). Mirrors the discipline in reserveStorageQuota.
|
||||||
kind,
|
// Only needed for R2 providers where the invalid-reservation fallback quota
|
||||||
provider,
|
// check requires Bunny usage data.
|
||||||
displayName,
|
const preFetchedBunnyData =
|
||||||
sourceUrl,
|
provider === VideoAssetProvider.R2_IMAGE || provider === VideoAssetProvider.R2_AUDIO
|
||||||
providerVideoId,
|
? await getCachedUserBunnyStorage()
|
||||||
thumbnailUrl,
|
: null;
|
||||||
uploadedByUserId: context.viewerUserId,
|
|
||||||
uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null,
|
// Create the VideoAsset and atomically consume the upload reservation (if any)
|
||||||
uploadedByGuestName: context.viewerUserId
|
// so the spot is never double-counted.
|
||||||
? null
|
const created = await db.$transaction(async (tx) => {
|
||||||
: sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'),
|
if (reservationId) {
|
||||||
billedUserId: context.video.project.workspace.ownerId,
|
// Acquire the per-user advisory lock unconditionally so both the happy path
|
||||||
},
|
// (valid reservation) and the fallback path (fake/expired reservation ID) are
|
||||||
select: {
|
// serialised — eliminating the TOCTOU race in the deleted.count === 0 branch.
|
||||||
id: true,
|
await tx.$executeRaw`
|
||||||
videoId: true,
|
SELECT pg_advisory_xact_lock(
|
||||||
kind: true,
|
('x' || left(md5(${billedUserId}), 16))::bit(64)::bigint
|
||||||
provider: true,
|
)
|
||||||
displayName: true,
|
`;
|
||||||
sourceUrl: true,
|
// Validate the reservation by checking it actually exists and belongs to the
|
||||||
providerVideoId: true,
|
// billed user. A client-supplied fake ID would delete 0 rows — in that case
|
||||||
thumbnailUrl: true,
|
// we fall back to a standard (non-locked) quota check so the bypass attempt
|
||||||
uploadedByGuestName: true,
|
// is caught rather than silently allowed.
|
||||||
createdAt: true,
|
const deleted = await tx.uploadReservation.deleteMany({
|
||||||
updatedAt: true,
|
where: { id: reservationId, billedUserId, expiresAt: { gt: new Date() } },
|
||||||
uploadedByUser: {
|
});
|
||||||
select: { id: true, name: true, image: true },
|
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(
|
const response = successResponse(shapeAssetForViewer(
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -552,6 +552,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
|||||||
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Share links with permissions</span></li>
|
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Share links with permissions</span></li>
|
||||||
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Exports (PDF/CSV)</span></li>
|
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Exports (PDF/CSV)</span></li>
|
||||||
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Unlimited YouTube Video Imports</span></li>
|
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Unlimited YouTube Video Imports</span></li>
|
||||||
|
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Download original uploaded video</span></li>
|
||||||
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Includes: 200 GB Storage</span></li>
|
<li className="flex items-start gap-3"><CheckSquare className="mt-0.5 shrink-0 h-4 w-4 text-[#06b6d4]" /><span>Includes: 200 GB Storage</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mb-8 text-sm text-muted-foreground">Need more storage? Add 100 GB for $5/mo.</p>
|
<p className="mb-8 text-sm text-muted-foreground">Need more storage? Add 100 GB for $5/mo.</p>
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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>;
|
||||||
@@ -109,6 +110,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const [youtubeUrl, setYoutubeUrl] = useState('');
|
const [youtubeUrl, setYoutubeUrl] = useState('');
|
||||||
const [youtubeTitle, setYoutubeTitle] = useState('');
|
const [youtubeTitle, setYoutubeTitle] = useState('');
|
||||||
const [bunnyTitle, setBunnyTitle] = useState('');
|
const [bunnyTitle, setBunnyTitle] = useState('');
|
||||||
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||||
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
|
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
|
||||||
const [bunnyProgress, setBunnyProgress] = useState(0);
|
const [bunnyProgress, setBunnyProgress] = useState(0);
|
||||||
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({});
|
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({});
|
||||||
@@ -290,6 +292,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsUploadingImage(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
@@ -301,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');
|
||||||
@@ -312,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('');
|
||||||
@@ -319,6 +323,8 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload image asset:', error);
|
console.error('Failed to upload image asset:', error);
|
||||||
toast.error('Failed to upload image');
|
toast.error('Failed to upload image');
|
||||||
|
} finally {
|
||||||
|
setIsUploadingImage(false);
|
||||||
}
|
}
|
||||||
}, [videoId, getGuestUploadToken, createAsset, imageTitle]);
|
}, [videoId, getGuestUploadToken, createAsset, imageTitle]);
|
||||||
|
|
||||||
@@ -582,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);
|
||||||
@@ -807,7 +814,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isCreatingAsset}
|
disabled={isUploadingImage || isCreatingAsset}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (pendingImageFile) {
|
if (pendingImageFile) {
|
||||||
void handleImageUpload(pendingImageFile);
|
void handleImageUpload(pendingImageFile);
|
||||||
@@ -816,8 +823,10 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
imageInputRef.current?.click();
|
imageInputRef.current?.click();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UploadCloud className="h-4 w-4 mr-2" />
|
{isUploadingImage || isCreatingAsset
|
||||||
{pendingImageFile ? 'Upload Image' : 'Select Image'}
|
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
: <UploadCloud className="h-4 w-4 mr-2" />}
|
||||||
|
{isUploadingImage ? 'Uploading...' : isCreatingAsset ? 'Saving...' : pendingImageFile ? 'Upload Image' : 'Select Image'}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={imageInputRef}
|
ref={imageInputRef}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -190,6 +190,16 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
onDelete={onDeleteVersion}
|
onDelete={onDeleteVersion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<DownloadControls
|
||||||
|
activeVersion={activeVersion}
|
||||||
|
videoCanDownload={videoCanDownload}
|
||||||
|
isDownloading={isDownloadingVideo}
|
||||||
|
activeDownloadTarget={activeDownloadTarget}
|
||||||
|
onDownload={onDownload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{mode === 'dashboard' && (
|
{mode === 'dashboard' && (
|
||||||
<>
|
<>
|
||||||
{canManageVideo ? (
|
{canManageVideo ? (
|
||||||
@@ -207,16 +217,6 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
) : null}
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<DownloadControls
|
|
||||||
activeVersion={activeVersion}
|
|
||||||
videoCanDownload={videoCanDownload}
|
|
||||||
isDownloading={isDownloadingVideo}
|
|
||||||
activeDownloadTarget={activeDownloadTarget}
|
|
||||||
onDownload={onDownload}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{versions.length >= 2 && (
|
{versions.length >= 2 && (
|
||||||
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
|
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
|
||||||
<GitCompareArrows className="h-4 w-4 mr-1" />
|
<GitCompareArrows className="h-4 w-4 mr-1" />
|
||||||
|
|||||||
+1
-1
@@ -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>;
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user