mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(uploads): size one upload against the account's own quota
The per-file ceiling was a flat 5 GiB from the environment, which is both too small for a paying account with 200 GB of storage and unaware of what an upload actually costs. The provider derives its own renditions from the file (1080p, 720p and down) and bills them to the same account, so a file allowed to fill the quota exactly is over it by the time it finishes processing. The ceiling is now 80% of whatever limit the account is held to: 160 GB on the plan, 2.4 GB on a cardless trial, and it moves on its own when either number changes. OPENFRAME_MAX_VIDEO_UPLOAD_BYTES keeps working as an absolute cap for a host that wants one, where the lower of the two applies, and an instance running without billing has no quota to divide and falls back to the flat 5 GiB. The refusal now names the ceiling, which the old one left the client to guess. Finalize re-checks only the host cap. Re-deriving the account's ceiling there would delete a finished upload over a plan that lapsed while the bytes were in flight, and an upload larger than what was declared is already caught by the declared-size check beside it.
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user