diff --git a/.env.docker.example b/.env.docker.example index 7b74ecd..cab4e31 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -19,7 +19,7 @@ NODE_ENV="production" OPENFRAME_ENABLE_STRIPE="false" OPENFRAME_ENABLE_BUNNY_UPLOADS="false" OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="true" -OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120" +# OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120" OPENFRAME_REQUIRE_INVITE_CODE="false" SELF_HOSTED_AUTO_CREATE_BUCKET="true" diff --git a/.env.example b/.env.example index 2f7d1d8..d300e3d 100644 --- a/.env.example +++ b/.env.example @@ -22,8 +22,12 @@ OPENFRAME_ENABLE_BUNNY_UPLOADS="true" # 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. OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="false" -# Max size per uploaded video file in bytes (default 5GB if unset) -OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120" +# An absolute per-file ceiling for uploaded videos, in bytes. Leave it unset on a +# 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. # Default 90MiB keeps each request under the common 100MB Cloudflare proxy/tunnel cap. # Lower it if your proxy enforces a stricter request-body limit. diff --git a/app/api/projects/[projectId]/videos/bunny-init/route.ts b/app/api/projects/[projectId]/videos/bunny-init/route.ts index 5a4231d..133d2e2 100644 --- a/app/api/projects/[projectId]/videos/bunny-init/route.ts +++ b/app/api/projects/[projectId]/videos/bunny-init/route.ts @@ -6,10 +6,11 @@ import { rateLimit } from '@/lib/rate-limit'; import crypto from 'crypto'; import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup'; 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 { enforceStorageQuota, + getMaxVideoUploadBytesForUser, releaseStorageReservation, reserveStorageQuota, UPLOAD_RESERVATION_PURPOSES, @@ -84,12 +85,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // halfway through, and the reservation written below makes concurrent // 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. - const declaredSize = parseDeclaredUploadSize(body?.sizeBytes, getMaxVideoUploadBytes()); + const billedUserId = project.workspace.ownerId; + const declaredSize = parseDeclaredUploadSize( + body?.sizeBytes, + await getMaxVideoUploadBytesForUser(billedUserId) + ); if ('error' in declaredSize) { return apiErrors.badRequest(declaredSize.error); } - const billedUserId = project.workspace.ownerId; const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes); if (quotaError) return quotaError; diff --git a/app/api/projects/[projectId]/videos/r2-init/route.ts b/app/api/projects/[projectId]/videos/r2-init/route.ts index 88bc7af..338383b 100644 --- a/app/api/projects/[projectId]/videos/r2-init/route.ts +++ b/app/api/projects/[projectId]/videos/r2-init/route.ts @@ -19,7 +19,6 @@ import { deleteVideoObject, } from '@/lib/r2'; import { - getMaxVideoUploadBytes, getR2MultipartPartSizeBytes, getR2MultipartThresholdBytes, isS3VideoUploadsEnabled, @@ -33,10 +32,12 @@ import { import { logError } from '@/lib/logger'; import { enforceStorageQuota, + getMaxVideoUploadBytesForUser, releaseStorageReservation, reserveStorageQuota, UPLOAD_RESERVATION_PURPOSES, } from '@/lib/storage-quota'; +import { uploadTooLargeMessage } from '@/lib/upload-size'; import { createR2UploadSession } from '@/lib/r2-upload-session'; 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'); } - const maxBytes = getMaxVideoUploadBytes(); + const maxBytes = await getMaxVideoUploadBytesForUser(project.workspace.ownerId); if (sizeBytes > maxBytes) { - return apiErrors.badRequest('Video file exceeds the maximum allowed upload size'); + return apiErrors.badRequest(uploadTooLargeMessage(maxBytes)); } const contentType = resolveVideoContentType(fileName, contentTypeInput); diff --git a/app/api/videos/[videoId]/assets/bunny-init/route.ts b/app/api/videos/[videoId]/assets/bunny-init/route.ts index da10aab..e825fd6 100644 --- a/app/api/videos/[videoId]/assets/bunny-init/route.ts +++ b/app/api/videos/[videoId]/assets/bunny-init/route.ts @@ -15,12 +15,13 @@ import { readGuestUploadGrant, type GuestUploadGrant, } 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 { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets'; import { logError } from '@/lib/logger'; import { enforceStorageQuota, + getMaxVideoUploadBytesForUser, releaseStorageReservation, reserveStorageQuota, 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 // and what it is worth: it buys an honest refusal before the upload starts, // 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) { return apiErrors.badRequest(declaredSize.error); } - const billedUserId = context.video.project.workspace.ownerId; const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes); if (quotaError) return quotaError; diff --git a/app/api/videos/[videoId]/assets/r2-init/route.ts b/app/api/videos/[videoId]/assets/r2-init/route.ts index 899bac2..b815ed0 100644 --- a/app/api/videos/[videoId]/assets/r2-init/route.ts +++ b/app/api/videos/[videoId]/assets/r2-init/route.ts @@ -14,7 +14,7 @@ import { deleteR2Object, deleteVideoObject, } from '@/lib/r2'; -import { getMaxVideoUploadBytes, isS3VideoUploadsEnabled } from '@/lib/feature-flags'; +import { isS3VideoUploadsEnabled } from '@/lib/feature-flags'; import { buildVideoObjectKey, getVideoExtensionFromMime, @@ -24,10 +24,12 @@ import { import { logError } from '@/lib/logger'; import { enforceStorageQuota, + getMaxVideoUploadBytesForUser, releaseStorageReservation, reserveStorageQuota, UPLOAD_RESERVATION_PURPOSES, } from '@/lib/storage-quota'; +import { uploadTooLargeMessage } from '@/lib/upload-size'; import { createR2UploadSession } from '@/lib/r2-upload-session'; 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'); } - const maxBytes = getMaxVideoUploadBytes(); + const billedUserId = context.video.project.workspace.ownerId; + const projectId = context.video.projectId; + + const maxBytes = await getMaxVideoUploadBytesForUser(billedUserId); if (sizeBytes > maxBytes) { - return apiErrors.badRequest('Video file exceeds the maximum allowed upload size'); + return apiErrors.badRequest(uploadTooLargeMessage(maxBytes)); } const contentType = resolveVideoContentType(fileName, contentTypeInput); @@ -89,9 +94,6 @@ export async function POST(request: NextRequest, { params }: RouteParams) { 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); if (quotaError) return quotaError; diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 9bea836..6e3a9c3 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -77,20 +77,31 @@ export function isDirectFileUploadEnabled() { 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(); - if (!raw) { - return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024); - } + if (!raw) return null; try { const parsed = BigInt(raw); - if (parsed <= BigInt(0)) { - return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024); - } - return parsed; + return parsed > BigInt(0) ? parsed : null; } catch { - return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024); + return null; } } diff --git a/lib/r2-video-finalize.ts b/lib/r2-video-finalize.ts index 2b6bc5a..4ffffb6 100644 --- a/lib/r2-video-finalize.ts +++ b/lib/r2-video-finalize.ts @@ -1,5 +1,5 @@ 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 { parseR2UploadToken, verifyR2UploadToken } from '@/lib/r2-upload-token'; import { @@ -154,7 +154,12 @@ export async function finalizeR2VideoUpload( 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'); } diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts index fcdf088..6888415 100644 --- a/lib/storage-quota.ts +++ b/lib/storage-quota.ts @@ -1,10 +1,15 @@ import type { NextResponse } from 'next/server'; import { db } from '@/lib/db'; 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 { isPaidTier } from '@/lib/billing'; import { getStorageLimitBytes } from '@/lib/trial-limits'; +import { formatSizeLimit } from '@/lib/upload-size'; // 200 GB expressed in bytes 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 { + 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( - `Your free trial includes ${formatStorageLimit(context.limitBytes)} of storage. ` + - `Upgrade to get ${formatStorageLimit(PLAN_STORAGE_LIMIT_BYTES)}.` + `Your free trial includes ${formatSizeLimit(context.limitBytes)} of storage. ` + + `Upgrade to get ${formatSizeLimit(PLAN_STORAGE_LIMIT_BYTES)}.` ) as NextResponse; } diff --git a/lib/trial-limits.ts b/lib/trial-limits.ts index 687ab55..5cf992e 100644 --- a/lib/trial-limits.ts +++ b/lib/trial-limits.ts @@ -26,10 +26,10 @@ export const TRIAL_PROJECT_LIMIT = 1; */ 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 -// per-file limit is 5 GiB and the trial's total is 3 GiB, so the quota check -// already refuses anything bigger, and a second limit would only add a second -// way to be told no. +// There is deliberately no separate per-file ceiling for trials. The per-file +// ceiling is a share of whatever limit applies to the account (see +// `getMaxVideoUploadBytesForUser`), so a trial is already held to 80% of these +// 3 GiB without a second number to keep in step with this one. export function getStorageLimitBytes(isPaid: boolean, planLimitBytes: bigint): bigint { if (isPaid) return planLimitBytes; diff --git a/lib/upload-size.ts b/lib/upload-size.ts index ab5b4b8..4d28e1c 100644 --- a/lib/upload-size.ts +++ b/lib/upload-size.ts @@ -6,9 +6,28 @@ 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 - * 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 * 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) { - return { error: 'File exceeds the maximum allowed upload size' }; + return { error: uploadTooLargeMessage(maxBytes) }; } return { sizeBytes }; diff --git a/tests/api/bunny-upload-reservation.test.ts b/tests/api/bunny-upload-reservation.test.ts index ce6811d..e96f799 100644 --- a/tests/api/bunny-upload-reservation.test.ts +++ b/tests/api/bunny-upload-reservation.test.ts @@ -100,16 +100,50 @@ describe('POST /api/projects/[projectId]/videos/bunny-init', () => { 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 - // after four gigabytes have been pushed to Bunny. - it('refuses an upload the remaining quota cannot hold', async () => { + // One file may take 80% of the account's ceiling, not all of it: what the + // provider transcodes the upload into is billed to the same account, so a file + // 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(); 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(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 () => { @@ -278,8 +312,12 @@ describe('what the storage refusal says', () => { it('names the trial ceiling and its own code for an unpaid account', 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(4) * GIB); + const response = await initUpload(scenario.project.id, BigInt(2) * GIB); expect(response.status).toBe(507); const body = (await response.json()) as { error: string; code: string }; diff --git a/tests/api/storage-quota.test.ts b/tests/api/storage-quota.test.ts index b41c2aa..dd5c075 100644 --- a/tests/api/storage-quota.test.ts +++ b/tests/api/storage-quota.test.ts @@ -17,6 +17,7 @@ import { PLAN_STORAGE_LIMIT_BYTES, UPLOAD_RESERVATION_PURPOSES, enforceStorageQuota, + getMaxVideoUploadBytesForUser, getUserStorageInfo, getUserTotalStorageBytes, 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', () => { it('is zero for a user with nothing stored', async () => { const user = await createSubscribedUser(); diff --git a/tests/unit/lib/feature-flags.test.ts b/tests/unit/lib/feature-flags.test.ts index 4414382..3330377 100644 --- a/tests/unit/lib/feature-flags.test.ts +++ b/tests/unit/lib/feature-flags.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - getMaxVideoUploadBytes, + getConfiguredMaxVideoUploadBytes, getR2MultipartPartSizeBytes, getR2MultipartThresholdBytes, hasBunnyUploadsConfig, @@ -264,26 +264,26 @@ describe('direct upload precedence', () => { }); }); -describe('getMaxVideoUploadBytes', () => { - it('defaults to 5 GiB', () => { - expect(getMaxVideoUploadBytes()).toBe(BigInt(5) * GIB); +describe('getConfiguredMaxVideoUploadBytes', () => { + it('is null when unset, leaving the ceiling to the account quota', () => { + expect(getConfiguredMaxVideoUploadBytes()).toBeNull(); }); it('uses a valid explicit byte count', () => { vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '1073741824'); - expect(getMaxVideoUploadBytes()).toBe(GIB); + expect(getConfiguredMaxVideoUploadBytes()).toBe(GIB); }); it('trims surrounding whitespace before parsing', () => { 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', ' '])( - 'falls back to 5 GiB for the invalid value %s', + 'reads the invalid value %s as no ceiling rather than as a number', (raw) => { vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', raw); - expect(getMaxVideoUploadBytes()).toBe(BigInt(5) * GIB); + expect(getConfiguredMaxVideoUploadBytes()).toBeNull(); } ); }); diff --git a/tests/unit/lib/upload-size.test.ts b/tests/unit/lib/upload-size.test.ts index 0b6dd6f..ddf8f25 100644 --- a/tests/unit/lib/upload-size.test.ts +++ b/tests/unit/lib/upload-size.test.ts @@ -1,5 +1,5 @@ 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); @@ -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({ - 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); }); }); + +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'); + }); +});