Merge pull request #61 from yusufipk/feat/upload-ceiling-from-quota

feat(uploads): size one upload against the account's own quota
This commit is contained in:
Yusuf İpek
2026-08-20 09:04:32 +03:00
committed by GitHub
15 changed files with 241 additions and 60 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ NODE_ENV="production"
OPENFRAME_ENABLE_STRIPE="false" OPENFRAME_ENABLE_STRIPE="false"
OPENFRAME_ENABLE_BUNNY_UPLOADS="false" OPENFRAME_ENABLE_BUNNY_UPLOADS="false"
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="true" OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="true"
OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120" # OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
OPENFRAME_REQUIRE_INVITE_CODE="false" OPENFRAME_REQUIRE_INVITE_CODE="false"
SELF_HOSTED_AUTO_CREATE_BUCKET="true" SELF_HOSTED_AUTO_CREATE_BUCKET="true"
+6 -2
View File
@@ -22,8 +22,12 @@ OPENFRAME_ENABLE_BUNNY_UPLOADS="true"
# Self-hosted direct video uploads to your S3-compatible storage (R2_* vars below). # Self-hosted direct video uploads to your S3-compatible storage (R2_* vars below).
# Mutually exclusive with Bunny: set OPENFRAME_ENABLE_BUNNY_UPLOADS=false when enabling this. # Mutually exclusive with Bunny: set OPENFRAME_ENABLE_BUNNY_UPLOADS=false when enabling this.
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="false" OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="false"
# Max size per uploaded video file in bytes (default 5GB if unset) # An absolute per-file ceiling for uploaded videos, in bytes. Leave it unset on a
OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120" # billed instance: the ceiling is then 80% of the account's own storage quota,
# leaving room for the renditions the provider derives from the file. When set,
# the lower of the two applies. Instances running without billing have no quota
# to divide and fall back to 5 GiB.
# OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
# Files larger than this use chunked (S3 multipart) uploads instead of a single PUT. # Files larger than this use chunked (S3 multipart) uploads instead of a single PUT.
# Default 90MiB keeps each request under the common 100MB Cloudflare proxy/tunnel cap. # Default 90MiB keeps each request under the common 100MB Cloudflare proxy/tunnel cap.
# Lower it if your proxy enforces a stricter request-body limit. # Lower it if your proxy enforces a stricter request-body limit.
@@ -6,10 +6,11 @@ import { rateLimit } from '@/lib/rate-limit';
import crypto from 'crypto'; import crypto from 'crypto';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup'; import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { createBunnyUploadToken, readBunnyUploadGrant } from '@/lib/bunny-upload-token'; import { createBunnyUploadToken, readBunnyUploadGrant } from '@/lib/bunny-upload-token';
import { getMaxVideoUploadBytes, isBunnyUploadsEnabled } from '@/lib/feature-flags'; import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { import {
enforceStorageQuota, enforceStorageQuota,
getMaxVideoUploadBytesForUser,
releaseStorageReservation, releaseStorageReservation,
reserveStorageQuota, reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES, UPLOAD_RESERVATION_PURPOSES,
@@ -84,12 +85,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// halfway through, and the reservation written below makes concurrent // halfway through, and the reservation written below makes concurrent
// uploads visible to each other, where previously every request in the same // uploads visible to each other, where previously every request in the same
// two-minute window read the same stale total and every one of them passed. // two-minute window read the same stale total and every one of them passed.
const declaredSize = parseDeclaredUploadSize(body?.sizeBytes, getMaxVideoUploadBytes()); const billedUserId = project.workspace.ownerId;
const declaredSize = parseDeclaredUploadSize(
body?.sizeBytes,
await getMaxVideoUploadBytesForUser(billedUserId)
);
if ('error' in declaredSize) { if ('error' in declaredSize) {
return apiErrors.badRequest(declaredSize.error); return apiErrors.badRequest(declaredSize.error);
} }
const billedUserId = project.workspace.ownerId;
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes); const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
if (quotaError) return quotaError; if (quotaError) return quotaError;
@@ -19,7 +19,6 @@ import {
deleteVideoObject, deleteVideoObject,
} from '@/lib/r2'; } from '@/lib/r2';
import { import {
getMaxVideoUploadBytes,
getR2MultipartPartSizeBytes, getR2MultipartPartSizeBytes,
getR2MultipartThresholdBytes, getR2MultipartThresholdBytes,
isS3VideoUploadsEnabled, isS3VideoUploadsEnabled,
@@ -33,10 +32,12 @@ import {
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { import {
enforceStorageQuota, enforceStorageQuota,
getMaxVideoUploadBytesForUser,
releaseStorageReservation, releaseStorageReservation,
reserveStorageQuota, reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES, UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota'; } from '@/lib/storage-quota';
import { uploadTooLargeMessage } from '@/lib/upload-size';
import { createR2UploadSession } from '@/lib/r2-upload-session'; import { createR2UploadSession } from '@/lib/r2-upload-session';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
@@ -106,9 +107,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('sizeBytes must be a positive integer'); return apiErrors.badRequest('sizeBytes must be a positive integer');
} }
const maxBytes = getMaxVideoUploadBytes(); const maxBytes = await getMaxVideoUploadBytesForUser(project.workspace.ownerId);
if (sizeBytes > maxBytes) { if (sizeBytes > maxBytes) {
return apiErrors.badRequest('Video file exceeds the maximum allowed upload size'); return apiErrors.badRequest(uploadTooLargeMessage(maxBytes));
} }
const contentType = resolveVideoContentType(fileName, contentTypeInput); const contentType = resolveVideoContentType(fileName, contentTypeInput);
@@ -15,12 +15,13 @@ import {
readGuestUploadGrant, readGuestUploadGrant,
type GuestUploadGrant, type GuestUploadGrant,
} from '@/lib/guest-upload-token'; } from '@/lib/guest-upload-token';
import { getMaxVideoUploadBytes, isBunnyUploadsEnabled } from '@/lib/feature-flags'; import { isBunnyUploadsEnabled } 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 { import {
enforceStorageQuota, enforceStorageQuota,
getMaxVideoUploadBytesForUser,
releaseStorageReservation, releaseStorageReservation,
reserveStorageQuota, reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES, UPLOAD_RESERVATION_PURPOSES,
@@ -69,12 +70,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// See the project video route for why the client's declared size is asked for // See the project video route for why the client's declared size is asked for
// and what it is worth: it buys an honest refusal before the upload starts, // and what it is worth: it buys an honest refusal before the upload starts,
// and a reservation that concurrent uploads can see. // and a reservation that concurrent uploads can see.
const declaredSize = parseDeclaredUploadSize(body?.sizeBytes, getMaxVideoUploadBytes()); const billedUserId = context.video.project.workspace.ownerId;
const declaredSize = parseDeclaredUploadSize(
body?.sizeBytes,
await getMaxVideoUploadBytesForUser(billedUserId)
);
if ('error' in declaredSize) { if ('error' in declaredSize) {
return apiErrors.badRequest(declaredSize.error); return apiErrors.badRequest(declaredSize.error);
} }
const billedUserId = context.video.project.workspace.ownerId;
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes); const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
if (quotaError) return quotaError; if (quotaError) return quotaError;
@@ -14,7 +14,7 @@ import {
deleteR2Object, deleteR2Object,
deleteVideoObject, deleteVideoObject,
} from '@/lib/r2'; } from '@/lib/r2';
import { getMaxVideoUploadBytes, isS3VideoUploadsEnabled } from '@/lib/feature-flags'; import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import { import {
buildVideoObjectKey, buildVideoObjectKey,
getVideoExtensionFromMime, getVideoExtensionFromMime,
@@ -24,10 +24,12 @@ import {
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { import {
enforceStorageQuota, enforceStorageQuota,
getMaxVideoUploadBytesForUser,
releaseStorageReservation, releaseStorageReservation,
reserveStorageQuota, reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES, UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota'; } from '@/lib/storage-quota';
import { uploadTooLargeMessage } from '@/lib/upload-size';
import { createR2UploadSession } from '@/lib/r2-upload-session'; import { createR2UploadSession } from '@/lib/r2-upload-session';
import { getVideoAssetAccessContext } from '@/lib/video-assets'; import { getVideoAssetAccessContext } from '@/lib/video-assets';
@@ -74,9 +76,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('sizeBytes must be a positive integer'); return apiErrors.badRequest('sizeBytes must be a positive integer');
} }
const maxBytes = getMaxVideoUploadBytes(); const billedUserId = context.video.project.workspace.ownerId;
const projectId = context.video.projectId;
const maxBytes = await getMaxVideoUploadBytesForUser(billedUserId);
if (sizeBytes > maxBytes) { if (sizeBytes > maxBytes) {
return apiErrors.badRequest('Video file exceeds the maximum allowed upload size'); return apiErrors.badRequest(uploadTooLargeMessage(maxBytes));
} }
const contentType = resolveVideoContentType(fileName, contentTypeInput); const contentType = resolveVideoContentType(fileName, contentTypeInput);
@@ -89,9 +94,6 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Unsupported video format'); return apiErrors.badRequest('Unsupported video format');
} }
const billedUserId = context.video.project.workspace.ownerId;
const projectId = context.video.projectId;
const quotaError = await enforceStorageQuota(billedUserId, sizeBytes + THUMBNAIL_RESERVE_BYTES); const quotaError = await enforceStorageQuota(billedUserId, sizeBytes + THUMBNAIL_RESERVE_BYTES);
if (quotaError) return quotaError; if (quotaError) return quotaError;
+20 -9
View File
@@ -77,20 +77,31 @@ export function isDirectFileUploadEnabled() {
return isS3VideoUploadsEnabled() || isBunnyUploadsEnabled(); return isS3VideoUploadsEnabled() || isBunnyUploadsEnabled();
} }
export function getMaxVideoUploadBytes(): bigint { /**
* The per-file ceiling for a host that has no billing, and therefore no per
* account quota to derive one from.
*/
export const DEFAULT_MAX_VIDEO_UPLOAD_BYTES =
BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
/**
* A flat per-file ceiling this host has pinned, or null when it has not.
*
* Null is the ordinary case. The ceiling that applies to a paying account is a
* share of that account's own storage quota, which one number in the
* environment cannot express (see `getMaxVideoUploadBytesForUser`). A host that
* wants an absolute cap on top of that still sets this, and the lower of the
* two wins.
*/
export function getConfiguredMaxVideoUploadBytes(): bigint | null {
const raw = process.env.OPENFRAME_MAX_VIDEO_UPLOAD_BYTES?.trim(); const raw = process.env.OPENFRAME_MAX_VIDEO_UPLOAD_BYTES?.trim();
if (!raw) { if (!raw) return null;
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
}
try { try {
const parsed = BigInt(raw); const parsed = BigInt(raw);
if (parsed <= BigInt(0)) { return parsed > BigInt(0) ? parsed : null;
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
}
return parsed;
} catch { } catch {
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024); return null;
} }
} }
+7 -2
View File
@@ -1,5 +1,5 @@
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getMaxVideoUploadBytes } from '@/lib/feature-flags'; import { getConfiguredMaxVideoUploadBytes } from '@/lib/feature-flags';
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2'; import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
import { parseR2UploadToken, verifyR2UploadToken } from '@/lib/r2-upload-token'; import { parseR2UploadToken, verifyR2UploadToken } from '@/lib/r2-upload-token';
import { import {
@@ -154,7 +154,12 @@ export async function finalizeR2VideoUpload(
return cancelPendingUpload('Uploaded video was not found in storage'); return cancelPendingUpload('Uploaded video was not found in storage');
} }
if (head.contentLength > getMaxVideoUploadBytes()) { // Only the host's absolute cap is re-checked here. The account's own ceiling
// was applied when the upload was initiated, and re-deriving it now would
// delete a finished upload over a plan that lapsed while the bytes were in
// flight. Anything larger than what was declared is caught on the next line.
const hostCeiling = getConfiguredMaxVideoUploadBytes();
if (hostCeiling !== null && head.contentLength > hostCeiling) {
return cancelPendingUpload('Uploaded video exceeds the maximum allowed upload size'); return cancelPendingUpload('Uploaded video exceeds the maximum allowed upload size');
} }
+42 -8
View File
@@ -1,10 +1,15 @@
import type { NextResponse } from 'next/server'; import type { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { apiErrors } from '@/lib/api-response'; import { apiErrors } from '@/lib/api-response';
import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import {
DEFAULT_MAX_VIDEO_UPLOAD_BYTES,
getConfiguredMaxVideoUploadBytes,
isStripeFeatureEnabled,
} from '@/lib/feature-flags';
import { getUserBunnyStorageBytes } from '@/lib/admin-stats'; import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
import { isPaidTier } from '@/lib/billing'; import { isPaidTier } from '@/lib/billing';
import { getStorageLimitBytes } from '@/lib/trial-limits'; import { getStorageLimitBytes } from '@/lib/trial-limits';
import { formatSizeLimit } from '@/lib/upload-size';
// 200 GB expressed in bytes // 200 GB expressed in bytes
export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024) * BigInt(1024); export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024) * BigInt(1024);
@@ -46,12 +51,41 @@ export async function getStorageContextForUser(userId: string): Promise<StorageC
} }
/** /**
* Whole gigabytes where the number is whole, which every ceiling we ship is. * The share of an account's storage ceiling one single upload may take.
* A host that sets an odd one gets a decimal rather than a rounded lie. *
* A video costs more than the file that was handed to us: the provider derives
* its own renditions from it (1080p, 720p and down) and those land in the same
* account's usage. A file allowed to fill the whole quota would therefore be
* over the quota by the time it finished processing, so a fifth of the ceiling
* is left free for what the upload turns into.
*/ */
function formatStorageLimit(bytes: bigint): string { export const MAX_UPLOAD_SHARE_PERCENT = BigInt(80);
const gigabytes = Number(bytes) / 1024 ** 3;
return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)} GB`; /** The largest single file that fits under `limitBytes` with room to transcode. */
export function getMaxUploadBytesForLimit(limitBytes: bigint): bigint {
return (limitBytes * MAX_UPLOAD_SHARE_PERCENT) / BigInt(100);
}
/**
* The largest single file this account may upload.
*
* Derived from the account's own ceiling rather than fixed, so the answer moves
* with the plan: 200 GB of storage allows a 160 GB file, and a cardless trial's
* 3 GB allows 2.4 GB. A host that has pinned an absolute cap still wins where
* it is the lower of the two, and a host running without billing has no quota
* to divide, so it falls back to the flat default.
*/
export async function getMaxVideoUploadBytesForUser(userId: string): Promise<bigint> {
const hostCeiling = getConfiguredMaxVideoUploadBytes();
if (!isStripeFeatureEnabled()) {
return hostCeiling ?? DEFAULT_MAX_VIDEO_UPLOAD_BYTES;
}
const { limitBytes } = await getStorageContextForUser(userId);
const quotaCeiling = getMaxUploadBytesForLimit(limitBytes);
return hostCeiling !== null && hostCeiling < quotaCeiling ? hostCeiling : quotaCeiling;
} }
/** /**
@@ -68,8 +102,8 @@ export function storageExceededResponse(context: StorageContext): NextResponse {
} }
return apiErrors.trialStorageExceeded( return apiErrors.trialStorageExceeded(
`Your free trial includes ${formatStorageLimit(context.limitBytes)} of storage. ` + `Your free trial includes ${formatSizeLimit(context.limitBytes)} of storage. ` +
`Upgrade to get ${formatStorageLimit(PLAN_STORAGE_LIMIT_BYTES)}.` `Upgrade to get ${formatSizeLimit(PLAN_STORAGE_LIMIT_BYTES)}.`
) as NextResponse; ) as NextResponse;
} }
+4 -4
View File
@@ -26,10 +26,10 @@ export const TRIAL_PROJECT_LIMIT = 1;
*/ */
export const TRIAL_STORAGE_LIMIT_BYTES = BigInt(3) * BigInt(1024) * BigInt(1024) * BigInt(1024); export const TRIAL_STORAGE_LIMIT_BYTES = BigInt(3) * BigInt(1024) * BigInt(1024) * BigInt(1024);
// There is deliberately no separate per-file ceiling for trials. The default // There is deliberately no separate per-file ceiling for trials. The per-file
// per-file limit is 5 GiB and the trial's total is 3 GiB, so the quota check // ceiling is a share of whatever limit applies to the account (see
// already refuses anything bigger, and a second limit would only add a second // `getMaxVideoUploadBytesForUser`), so a trial is already held to 80% of these
// way to be told no. // 3 GiB without a second number to keep in step with this one.
export function getStorageLimitBytes(isPaid: boolean, planLimitBytes: bigint): bigint { export function getStorageLimitBytes(isPaid: boolean, planLimitBytes: bigint): bigint {
if (isPaid) return planLimitBytes; if (isPaid) return planLimitBytes;
+21 -2
View File
@@ -6,9 +6,28 @@
export type DeclaredUploadSize = { sizeBytes: bigint } | { error: string }; export type DeclaredUploadSize = { sizeBytes: bigint } | { error: string };
/**
* A byte count in the unit a person reading a limit expects.
*
* Whole gigabytes where the number is whole, which most ceilings we ship are,
* and one decimal otherwise so a share of a small quota is not rounded into a
* lie. Lives here rather than next to the quota because the upload routes need
* it too, and this module imports nothing.
*/
export function formatSizeLimit(bytes: bigint): string {
const gigabytes = Number(bytes) / 1024 ** 3;
if (gigabytes < 1) return `${Math.round(Number(bytes) / 1024 ** 2)} MB`;
return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)} GB`;
}
/** The refusal, with the ceiling in it, so the client knows what would fit. */
export function uploadTooLargeMessage(maxBytes: bigint): string {
return `File exceeds the maximum allowed upload size (${formatSizeLimit(maxBytes)})`;
}
/** /**
* Reads a declared upload size, refusing anything that is not a whole positive * Reads a declared upload size, refusing anything that is not a whole positive
* number of bytes within the host's per-file ceiling. * number of bytes within the ceiling this account is held to.
* *
* The number is the client's word and is treated as such. Overstating it only * The number is the client's word and is treated as such. Overstating it only
* spends the caller's own quota, and understating it is caught where the bytes * spends the caller's own quota, and understating it is caught where the bytes
@@ -34,7 +53,7 @@ export function parseDeclaredUploadSize(raw: unknown, maxBytes: bigint): Declare
} }
if (sizeBytes > maxBytes) { if (sizeBytes > maxBytes) {
return { error: 'File exceeds the maximum allowed upload size' }; return { error: uploadTooLargeMessage(maxBytes) };
} }
return { sizeBytes }; return { sizeBytes };
+44 -6
View File
@@ -100,16 +100,50 @@ describe('POST /api/projects/[projectId]/videos/bunny-init', () => {
expect(await readError(response)).toContain('maximum allowed upload size'); expect(await readError(response)).toContain('maximum allowed upload size');
}); });
// The trial ceiling is 3 GiB, so this is refused on the way in rather than // One file may take 80% of the account's ceiling, not all of it: what the
// after four gigabytes have been pushed to Bunny. // provider transcodes the upload into is billed to the same account, so a file
it('refuses an upload the remaining quota cannot hold', async () => { // that filled the quota would be over it by the time it finished processing.
// 2.5 GiB fits inside a trial's 3 GiB and is still refused.
it('refuses a size beyond the share of the quota one file may take', async () => {
const scenario = await seedProject(); const scenario = await seedProject();
signedInAs(scenario.owner); signedInAs(scenario.owner);
const response = await initUpload(scenario.project.id, BigInt(4) * GIB); const response = await initUpload(scenario.project.id, (BigInt(5) * GIB) / BigInt(2));
expect(response.status).toBe(400);
expect(await readError(response)).toContain('2.4 GB');
expect(await db.uploadReservation.count()).toBe(0);
});
// And the same rule read against the paid ceiling, where 80% of 200 GiB is a
// number no fixed per-file limit would have allowed.
it('lets a paying account send a file far past what a trial could', async () => {
const scenario = await seedProject({ ownerUser: await createSubscribedUser() });
signedInAs(scenario.owner);
const overCeiling = await initUpload(scenario.project.id, BigInt(161) * GIB);
const underCeiling = await initUpload(scenario.project.id, BigInt(150) * GIB);
expect(overCeiling.status).toBe(400);
expect(await readError(overCeiling)).toContain('160 GB');
expect(underCeiling.status).toBe(200);
});
// The trial ceiling is 3 GiB, so this is refused on the way in rather than
// after the bytes have been pushed to Bunny. The declared size is inside the
// per-file ceiling, so it is the quota refusing it and not the size check.
it('refuses an upload the remaining quota cannot hold', async () => {
const scenario = await seedProject();
signedInAs(scenario.owner);
await createUploadReservation({
billedUserId: scenario.owner.id,
sizeBytes: BigInt(2) * GIB,
});
const response = await initUpload(scenario.project.id, BigInt(2) * GIB);
expect(response.status).toBe(507); expect(response.status).toBe(507);
expect(await db.uploadReservation.count()).toBe(0); expect(await db.uploadReservation.count()).toBe(1);
}); });
it('holds the declared size as a reservation for the workspace owner', async () => { it('holds the declared size as a reservation for the workspace owner', async () => {
@@ -278,8 +312,12 @@ describe('what the storage refusal says', () => {
it('names the trial ceiling and its own code for an unpaid account', async () => { it('names the trial ceiling and its own code for an unpaid account', async () => {
const scenario = await seedProject(); const scenario = await seedProject();
signedInAs(scenario.owner); signedInAs(scenario.owner);
await createUploadReservation({
billedUserId: scenario.owner.id,
sizeBytes: BigInt(2) * GIB,
});
const response = await initUpload(scenario.project.id, BigInt(4) * GIB); const response = await initUpload(scenario.project.id, BigInt(2) * GIB);
expect(response.status).toBe(507); expect(response.status).toBe(507);
const body = (await response.json()) as { error: string; code: string }; const body = (await response.json()) as { error: string; code: string };
+43
View File
@@ -17,6 +17,7 @@ import {
PLAN_STORAGE_LIMIT_BYTES, PLAN_STORAGE_LIMIT_BYTES,
UPLOAD_RESERVATION_PURPOSES, UPLOAD_RESERVATION_PURPOSES,
enforceStorageQuota, enforceStorageQuota,
getMaxVideoUploadBytesForUser,
getUserStorageInfo, getUserStorageInfo,
getUserTotalStorageBytes, getUserTotalStorageBytes,
releaseStorageReservation, releaseStorageReservation,
@@ -107,6 +108,48 @@ describe('the trial ceiling', () => {
}); });
}); });
// One upload may take 80% of whatever ceiling the account is held to. The fifth
// left free is for what the upload turns into: the provider derives its own
// renditions (1080p, 720p and down) from the file and bills them to the same
// account, so a file that filled the quota exactly would put the account over it
// once processing finished.
describe('getMaxVideoUploadBytesForUser', () => {
it('is 80% of the plan ceiling for a paying account', async () => {
const user = await createSubscribedUser();
expect(await getMaxVideoUploadBytesForUser(user.id)).toBe(BigInt(160) * GIB);
});
it('is 80% of the trial ceiling for an unpaid one', async () => {
const user = await createUser();
expect(await getMaxVideoUploadBytesForUser(user.id)).toBe(
(BigInt(3) * GIB * BigInt(80)) / BigInt(100)
);
});
it('drops to a host ceiling that is stricter than the account share', async () => {
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', (BigInt(5) * GIB).toString());
const user = await createSubscribedUser();
expect(await getMaxVideoUploadBytesForUser(user.id)).toBe(BigInt(5) * GIB);
});
it('ignores a host ceiling looser than the account share, which the quota would refuse anyway', async () => {
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', (BigInt(500) * GIB).toString());
const user = await createSubscribedUser();
expect(await getMaxVideoUploadBytesForUser(user.id)).toBe(BigInt(160) * GIB);
});
it('falls back to the flat default where there is no billing, and so no quota to divide', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const user = await createUser();
expect(await getMaxVideoUploadBytesForUser(user.id)).toBe(BigInt(5) * GIB);
});
});
describe('getUserTotalStorageBytes', () => { describe('getUserTotalStorageBytes', () => {
it('is zero for a user with nothing stored', async () => { it('is zero for a user with nothing stored', async () => {
const user = await createSubscribedUser(); const user = await createSubscribedUser();
+8 -8
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
getMaxVideoUploadBytes, getConfiguredMaxVideoUploadBytes,
getR2MultipartPartSizeBytes, getR2MultipartPartSizeBytes,
getR2MultipartThresholdBytes, getR2MultipartThresholdBytes,
hasBunnyUploadsConfig, hasBunnyUploadsConfig,
@@ -264,26 +264,26 @@ describe('direct upload precedence', () => {
}); });
}); });
describe('getMaxVideoUploadBytes', () => { describe('getConfiguredMaxVideoUploadBytes', () => {
it('defaults to 5 GiB', () => { it('is null when unset, leaving the ceiling to the account quota', () => {
expect(getMaxVideoUploadBytes()).toBe(BigInt(5) * GIB); expect(getConfiguredMaxVideoUploadBytes()).toBeNull();
}); });
it('uses a valid explicit byte count', () => { it('uses a valid explicit byte count', () => {
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '1073741824'); vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '1073741824');
expect(getMaxVideoUploadBytes()).toBe(GIB); expect(getConfiguredMaxVideoUploadBytes()).toBe(GIB);
}); });
it('trims surrounding whitespace before parsing', () => { it('trims surrounding whitespace before parsing', () => {
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', ' 1073741824 '); vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', ' 1073741824 ');
expect(getMaxVideoUploadBytes()).toBe(GIB); expect(getConfiguredMaxVideoUploadBytes()).toBe(GIB);
}); });
it.each(['0', '-1', '-1073741824', 'abc', '1.5', '1e9', '1_000', ' '])( it.each(['0', '-1', '-1073741824', 'abc', '1.5', '1e9', '1_000', ' '])(
'falls back to 5 GiB for the invalid value %s', 'reads the invalid value %s as no ceiling rather than as a number',
(raw) => { (raw) => {
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', raw); vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', raw);
expect(getMaxVideoUploadBytes()).toBe(BigInt(5) * GIB); expect(getConfiguredMaxVideoUploadBytes()).toBeNull();
} }
); );
}); });
+19 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseDeclaredUploadSize } from '@/lib/upload-size'; import { formatSizeLimit, parseDeclaredUploadSize } from '@/lib/upload-size';
const MAX = BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024); const MAX = BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
@@ -48,9 +48,9 @@ describe('parseDeclaredUploadSize', () => {
}); });
}); });
it('rejects a size over the ceiling', () => { it('rejects a size over the ceiling, naming the ceiling so the client knows what fits', () => {
expect(parseDeclaredUploadSize(MAX + BigInt(1), MAX)).toEqual({ expect(parseDeclaredUploadSize(MAX + BigInt(1), MAX)).toEqual({
error: 'File exceeds the maximum allowed upload size', error: 'File exceeds the maximum allowed upload size (5 GB)',
}); });
}); });
@@ -58,3 +58,19 @@ describe('parseDeclaredUploadSize', () => {
expect(size(parseDeclaredUploadSize(MAX, MAX))).toBe(MAX); expect(size(parseDeclaredUploadSize(MAX, MAX))).toBe(MAX);
}); });
}); });
describe('formatSizeLimit', () => {
it('drops the decimal on a whole number of gigabytes', () => {
expect(formatSizeLimit(BigInt(160) * BigInt(1024) ** BigInt(3))).toBe('160 GB');
});
it('keeps one decimal where the share of a small quota is not whole', () => {
expect(
formatSizeLimit((BigInt(3) * BigInt(1024) ** BigInt(3) * BigInt(80)) / BigInt(100))
).toBe('2.4 GB');
});
it('falls back to megabytes below a gigabyte, where "0.0 GB" would say nothing', () => {
expect(formatSizeLimit(BigInt(500) * BigInt(1024) * BigInt(1024))).toBe('500 MB');
});
});