mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
+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