mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #55 from yusufipk/feat/storage-upgrade-nudge
fix(storage): count a finished Bunny upload, and offer the upgrade when a trial is full
This commit is contained in:
@@ -77,6 +77,8 @@ interface StorageInfo {
|
||||
usedBytes: string;
|
||||
limitBytes: string;
|
||||
percentage: number;
|
||||
/** False on the free trial, where the way out is subscribing rather than deleting. */
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
@@ -501,7 +503,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
Storage
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Combined usage across video files and media attachments (200 GB limit)
|
||||
Combined usage across video files and media attachments
|
||||
{storageInfo ? ` (${formatBytes(storageInfo.limitBytes)} limit)` : ''}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -541,11 +544,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
{storageInfo.percentage >= 90 && (
|
||||
<p className="text-xs text-destructive">
|
||||
Storage is almost full. Delete unused files or contact support.
|
||||
</p>
|
||||
)}
|
||||
{storageInfo.percentage >= 90 &&
|
||||
(storageInfo.isPaid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Storage is almost full. Delete unused files or contact support.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">
|
||||
Your free trial storage is almost full. Subscribe above for more room, or
|
||||
delete unused files.
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { getUserStorageInfo } from '@/lib/storage-quota';
|
||||
import { getStorageContextForUser, getUserStorageInfo } from '@/lib/storage-quota';
|
||||
import { hasBillingAccess } from '@/lib/billing';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
@@ -27,12 +27,19 @@ export async function GET() {
|
||||
return apiErrors.forbidden();
|
||||
}
|
||||
|
||||
const info = await getUserStorageInfo(session.user.id);
|
||||
const [info, storage] = await Promise.all([
|
||||
getUserStorageInfo(session.user.id),
|
||||
getStorageContextForUser(session.user.id),
|
||||
]);
|
||||
|
||||
const response = successResponse({
|
||||
usedBytes: info.usedBytes.toString(),
|
||||
limitBytes: info.limitBytes.toString(),
|
||||
percentage: info.percentage,
|
||||
// Which ceiling this is, so the card can name it and say what to do about it.
|
||||
// A trial has 3 GB because it has not subscribed; deleting files is the wrong
|
||||
// advice there, and "200 GB limit" was the wrong caption.
|
||||
isPaid: storage.isPaid,
|
||||
});
|
||||
|
||||
// Cache for 60s — stale data is acceptable for a usage meter
|
||||
|
||||
@@ -32,11 +32,13 @@ import {
|
||||
enforceStorageQuota,
|
||||
reserveStorageQuota,
|
||||
releaseStorageReservation,
|
||||
getStorageLimitForUser,
|
||||
getStorageContextForUser,
|
||||
storageExceededResponse,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
type StorageContext,
|
||||
type UploadReservationPurpose,
|
||||
} from '@/lib/storage-quota';
|
||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||
import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
|
||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
|
||||
// Sentinel thrown inside a Prisma transaction when a fake reservationId is
|
||||
@@ -307,6 +309,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
// was still in flight.
|
||||
let reservationPurpose: UploadReservationPurpose | null = null;
|
||||
let reservationBilledUserId: string | null = null;
|
||||
// Carried out of the try so the quota refusal in the catch can be worded for
|
||||
// the account it is refusing, rather than telling a trial to delete files.
|
||||
let storageForRefusal: StorageContext | null = null;
|
||||
let finalizedR2AssetSession: {
|
||||
sessionId: string;
|
||||
reservationId: string | null;
|
||||
@@ -582,14 +587,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
// quota check below, Bunny included. Leaving Bunny out read its own storage as
|
||||
// zero, and on an account whose storage is all Bunny that made the fallback a
|
||||
// check that could not fail.
|
||||
const preFetchedBunnyData =
|
||||
provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage();
|
||||
const preFetchedBunnyBytes =
|
||||
provider === VideoAssetProvider.YOUTUBE ? null : await getUserBunnyStorageBytes(billedUserId);
|
||||
|
||||
// The ceiling this account is actually held to, read for the fallback below.
|
||||
// It used to compare against the plan limit, which is 200 GiB whoever is
|
||||
// asking: a caller who quoted a reservation id that no longer existed was
|
||||
// measured against the paid ceiling even on a trial worth 3 GiB.
|
||||
const storageLimitBytes = await getStorageLimitForUser(billedUserId);
|
||||
const storage = await getStorageContextForUser(billedUserId);
|
||||
storageForRefusal = storage;
|
||||
|
||||
// Create the VideoAsset and atomically consume the upload reservation (if any)
|
||||
// so the spot is never double-counted.
|
||||
@@ -631,12 +637,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
WHERE "billedUserId" = ${billedUserId}
|
||||
AND "expiresAt" > NOW()
|
||||
`;
|
||||
const bunnyData = preFetchedBunnyData ?? {};
|
||||
const totalUsed =
|
||||
(r2Row?.total ?? BigInt(0)) +
|
||||
(resRow?.total ?? BigInt(0)) +
|
||||
BigInt(bunnyData[billedUserId] ?? 0);
|
||||
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) {
|
||||
BigInt(preFetchedBunnyBytes ?? 0);
|
||||
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storage.limitBytes) {
|
||||
throw new QuotaExceededInTxError();
|
||||
}
|
||||
}
|
||||
@@ -712,7 +717,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
if (error instanceof QuotaExceededInTxError) {
|
||||
return apiErrors.storageExceeded() as NextResponse;
|
||||
return storageForRefusal
|
||||
? storageExceededResponse(storageForRefusal)
|
||||
: (apiErrors.storageExceeded() as NextResponse);
|
||||
}
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as tus from 'tus-js-client';
|
||||
import { toast } from 'sonner';
|
||||
import { toastApiError } from '@/lib/client/api-error';
|
||||
import {
|
||||
Download,
|
||||
FileVideo,
|
||||
@@ -56,6 +57,7 @@ function formatTime(seconds: number): string {
|
||||
type UploadAudioResponse = {
|
||||
data?: { url?: string; reservationId?: string | null };
|
||||
error?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
function getAudioUploadValidationError(file: Blob): string | null {
|
||||
@@ -369,10 +371,11 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
const uploadPayload = (await uploadRes.json().catch(() => null)) as {
|
||||
data?: { url?: string; reservationId?: string | null };
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
const uploadedImageUrl = uploadPayload?.data?.url;
|
||||
if (!uploadRes.ok || !uploadedImageUrl) {
|
||||
toast.error(`${file.name}: ${uploadPayload?.error || 'Failed to upload image'}`);
|
||||
toastApiError(uploadPayload, 'Failed to upload image', { prefix: file.name });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -385,7 +388,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
return !!created;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image asset:', error);
|
||||
toast.error(`${file.name}: Failed to upload image`);
|
||||
toastApiError(error, 'Failed to upload image', { prefix: file.name });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -523,10 +526,11 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
uploadToken: string;
|
||||
};
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
|
||||
if (!initRes.ok || !initPayload?.data) {
|
||||
toast.error(`${file.name}: ${initPayload?.error || 'Failed to initialize Bunny upload'}`);
|
||||
toastApiError(initPayload, 'Failed to initialize Bunny upload', { prefix: file.name });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -578,7 +582,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload Bunny asset:', error);
|
||||
toast.error(`${file.name}: Failed to upload Bunny video`);
|
||||
toastApiError(error, 'Failed to upload Bunny video', { prefix: file.name });
|
||||
if (uploadedVideoId && uploadToken) {
|
||||
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
@@ -631,7 +635,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload R2 asset video:', error);
|
||||
toast.error(`${file.name}: Failed to upload video`);
|
||||
toastApiError(error, 'Failed to upload video', { prefix: file.name });
|
||||
return false;
|
||||
} finally {
|
||||
setIsUploadingBunny(false);
|
||||
@@ -801,12 +805,12 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
const uploadPayload = await readUploadAudioResponse(uploadRes);
|
||||
const uploadedUrl = uploadPayload?.data?.url;
|
||||
if (!uploadRes.ok || !uploadedUrl) {
|
||||
toast.error(
|
||||
`${fileName}: ${
|
||||
uploadPayload?.error ||
|
||||
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
|
||||
'Failed to upload voice recording'
|
||||
}`
|
||||
toastApiError(
|
||||
uploadPayload,
|
||||
uploadRes.status === 413
|
||||
? MAX_AUDIO_UPLOAD_SIZE_MESSAGE
|
||||
: 'Failed to upload voice recording',
|
||||
{ prefix: fileName }
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -821,7 +825,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
return !!created;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload voice asset:', error);
|
||||
toast.error(`${fileName}: Failed to upload voice recording`);
|
||||
toastApiError(error, 'Failed to upload voice recording', { prefix: fileName });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@/components/video-page/image-upload-utils';
|
||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||
import { withWebmDuration } from '@/lib/webm-duration';
|
||||
import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||
|
||||
interface UseCommentActionsParams extends CommentActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
@@ -61,17 +62,6 @@ function getAudioUploadFilename(blob: Blob): string {
|
||||
return 'recording.webm';
|
||||
}
|
||||
|
||||
/**
|
||||
* A step of the submit that failed with something worth reading out.
|
||||
*
|
||||
* The attachment goes up before the comment does, so a full account fails on the
|
||||
* image and never reaches the comment at all. Reporting that as "failed to add
|
||||
* comment" tells the uploader to try again, which is the one thing that cannot
|
||||
* work. Carried as its own error type so a network fault, which has no message
|
||||
* anybody wants to see, still falls back to the generic line.
|
||||
*/
|
||||
class CommentSubmitError extends Error {}
|
||||
|
||||
export function useCommentActions({
|
||||
videoId,
|
||||
setVideo,
|
||||
@@ -192,7 +182,7 @@ export function useCommentActions({
|
||||
} | null;
|
||||
const token = payload?.data?.token;
|
||||
if (!response.ok || !token) {
|
||||
throw new Error(payload?.error || 'Failed to prepare upload');
|
||||
throw apiRequestError(payload, 'Failed to prepare upload');
|
||||
}
|
||||
return token;
|
||||
},
|
||||
@@ -274,10 +264,14 @@ export function useCommentActions({
|
||||
});
|
||||
|
||||
if (!imageRes.ok) {
|
||||
// The attachment goes up before the comment does, so a full account
|
||||
// fails here and never reaches the comment at all. Thrown with the
|
||||
// code attached so the catch below can offer the way out.
|
||||
const imagePayload = (await imageRes.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
throw new CommentSubmitError(imagePayload?.error || 'Failed to upload image');
|
||||
throw apiRequestError(imagePayload, 'Failed to upload image');
|
||||
}
|
||||
const imageDataResponse = await imageRes.json();
|
||||
imageData = { url: imageDataResponse.data.url };
|
||||
@@ -336,8 +330,11 @@ export function useCommentActions({
|
||||
),
|
||||
};
|
||||
});
|
||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
toast.error(payload?.error || 'Failed to add comment');
|
||||
const payload = (await res.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
toastApiError(payload, 'Failed to add comment');
|
||||
}
|
||||
} catch (error) {
|
||||
setVideo((prev) => {
|
||||
@@ -351,7 +348,9 @@ export function useCommentActions({
|
||||
),
|
||||
};
|
||||
});
|
||||
toast.error(error instanceof CommentSubmitError ? error.message : 'Failed to add comment');
|
||||
// A network fault has no message anybody wants to see, so only an
|
||||
// ApiRequestError speaks for itself; toastApiError falls back for the rest.
|
||||
toastApiError(error instanceof ApiRequestError ? error : null, 'Failed to add comment');
|
||||
} finally {
|
||||
setIsSubmittingComment(false);
|
||||
setIsUploadingImage(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||
|
||||
/** What a failed version upload has to undo, depending on which provider it started on. */
|
||||
type PendingVersionCleanup =
|
||||
@@ -113,7 +114,13 @@ export function useVersionActions({
|
||||
body: JSON.stringify({ title, sizeBytes: file.size.toString() }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||
if (!initRes.ok) {
|
||||
const initPayload = (await initRes.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
throw apiRequestError(initPayload, 'Failed to initialize upload');
|
||||
}
|
||||
const {
|
||||
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
@@ -236,7 +243,7 @@ export function useVersionActions({
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Failed to create version');
|
||||
throw apiRequestError(data, 'Failed to create version');
|
||||
}
|
||||
|
||||
const versionData = await res.json();
|
||||
@@ -277,7 +284,7 @@ export function useVersionActions({
|
||||
}
|
||||
}
|
||||
console.error('Failed to create version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to create version');
|
||||
toastApiError(errorObj, 'Failed to create version');
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
setNewVersionUploadProgress(0);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||
|
||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||
|
||||
@@ -43,6 +44,7 @@ interface AssetsListResponse {
|
||||
interface AssetCreateResponse {
|
||||
data?: VideoAsset;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const ASSET_PAGE_SIZE = 40;
|
||||
@@ -185,7 +187,7 @@ export function useVideoAssets({
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
|
||||
if (!res.ok || !body?.data) {
|
||||
toast.error(body?.error || 'Failed to create asset');
|
||||
toastApiError(body, 'Failed to create asset');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -288,7 +290,7 @@ export function useVideoAssets({
|
||||
} | null;
|
||||
const token = payload?.data?.token;
|
||||
if (!response.ok || !token) {
|
||||
throw new Error(payload?.error || 'Failed to prepare upload');
|
||||
throw apiRequestError(payload, 'Failed to prepare upload');
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
+89
-23
@@ -221,12 +221,97 @@ export const getCachedBunnyStorageStats = unstable_cache(
|
||||
{ revalidate: STORAGE_CACHE_SECONDS }
|
||||
);
|
||||
|
||||
/**
|
||||
* What this video costs us, as the larger of the two numbers we have.
|
||||
*
|
||||
* Bunny reports nothing for a video until it starts encoding, and what it reports
|
||||
* while encoding is partial: `storageSize` counts what has been written so far and
|
||||
* climbs as each rendition lands. A six minute cut uploaded at 2.5 GB read as
|
||||
* 475 MB midway through and settled above 3 GB once it finished, because Bunny
|
||||
* keeps the original alongside every rendition it makes.
|
||||
*
|
||||
* Both halves of the rule follow from that. Taking Bunny's figure whenever it is
|
||||
* non-zero would hand back most of the quota in the middle of an encode, which is
|
||||
* the hole the declared size exists to close. Taking the declared size forever
|
||||
* would ignore the renditions, which are the actual bill and end up larger than
|
||||
* the source. The larger of the two is right at every point: the declared size
|
||||
* covers the encode, and Bunny's own number takes over the moment it passes it.
|
||||
*/
|
||||
function chargeableSize(reported: number, declared: bigint | null): number {
|
||||
const declaredBytes = declared === null ? 0 : Number(declared);
|
||||
return reported > declaredBytes ? reported : declaredBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bunny's reported sizes, or an empty map when the call to Bunny failed.
|
||||
*
|
||||
* A failed stats call is not a reason to bill an account for nothing. Bunny's own
|
||||
* figure is unavailable; the sizes declared at upload are sitting in our database
|
||||
* either way, and reading the whole account as empty is how a full account gets
|
||||
* waved through. Used to be an early return that skipped the rows entirely.
|
||||
*/
|
||||
function reportedSizes(stats: BunnyStorageStats): Record<string, number> {
|
||||
return stats.totalBytes < 0 ? {} : stats.byVideoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one account's Bunny videos cost, read fresh.
|
||||
*
|
||||
* This deliberately does not come from the cached per-user map. The declared size
|
||||
* lands on the row at the moment an upload finalizes, and a map computed up to two
|
||||
* minutes earlier does not have that row in it. For those two minutes the
|
||||
* reservation is already gone and the row is not yet visible, so an upload that
|
||||
* just succeeded reads as zero: the uploader watches their usage fall back to
|
||||
* nothing, and the next upload is measured against a total that ignores the one
|
||||
* before it.
|
||||
*
|
||||
* The call to Bunny stays cached. It is the slow half and its answer is the same
|
||||
* for everybody. Only the join against our own rows has to be current.
|
||||
*/
|
||||
export async function getUserBunnyStorageBytes(userId: string): Promise<number> {
|
||||
try {
|
||||
const [bunnyStats, bunnyVersions, bunnyAssets] = await Promise.all([
|
||||
getCachedBunnyStorageStats(),
|
||||
db.videoVersion.findMany({
|
||||
where: {
|
||||
providerId: 'bunny',
|
||||
// The workspace owner, not the project owner: this feeds
|
||||
// getUserTotalStorageBytes, which bills every other provider the same way.
|
||||
video: { project: { workspace: { ownerId: userId } } },
|
||||
},
|
||||
select: { videoId: true, sizeBytes: true },
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: { provider: 'BUNNY', providerVideoId: { not: null }, billedUserId: userId },
|
||||
select: { providerVideoId: true, sizeBytes: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const reported = reportedSizes(bunnyStats);
|
||||
const seenVideoIds = new Set<string>();
|
||||
let total = 0;
|
||||
|
||||
for (const row of [
|
||||
...bunnyVersions.map((v) => ({ videoId: v.videoId, sizeBytes: v.sizeBytes })),
|
||||
...bunnyAssets.map((a) => ({ videoId: a.providerVideoId!, sizeBytes: a.sizeBytes })),
|
||||
]) {
|
||||
if (!row.videoId || seenVideoIds.has(row.videoId)) continue;
|
||||
seenVideoIds.add(row.videoId);
|
||||
total += chargeableSize(reported[row.videoId] || 0, row.sizeBytes);
|
||||
}
|
||||
|
||||
return total;
|
||||
} catch (err) {
|
||||
logError('Failed to calculate Bunny storage for user:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const getCachedUserBunnyStorage = unstable_cache(
|
||||
async () => {
|
||||
const perUserStorage: Record<string, number> = {};
|
||||
try {
|
||||
const bunnyStats = await getCachedBunnyStorageStats();
|
||||
if (bunnyStats.totalBytes < 0) return perUserStorage;
|
||||
|
||||
const [bunnyVersions, bunnyAssets] = await Promise.all([
|
||||
db.videoVersion.findMany({
|
||||
@@ -262,23 +347,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* What this video costs us, as the larger of the two numbers we have.
|
||||
*
|
||||
* Bunny reports nothing for a video until it has finished encoding it,
|
||||
* which on a half-hour source is most of an hour, and reading that zero
|
||||
* literally meant an upload was free for as long as it was being
|
||||
* processed: it did not show on the uploader's storage page and it did not
|
||||
* count against the next upload's quota check. The size declared when the
|
||||
* upload was admitted stands in until Bunny has a figure of its own, and
|
||||
* Bunny's wins once it arrives, because the renditions it makes are the
|
||||
* real bill and they are larger than the source.
|
||||
*/
|
||||
const chargeableSize = (reported: number, declared: bigint | null): number => {
|
||||
const declaredBytes = declared === null ? 0 : Number(declared);
|
||||
return reported > declaredBytes ? reported : declaredBytes;
|
||||
};
|
||||
|
||||
const reported = reportedSizes(bunnyStats);
|
||||
const seenVideoIds = new Set<string>();
|
||||
for (const version of bunnyVersions) {
|
||||
const ownerId = version.video.project.workspace.ownerId;
|
||||
@@ -286,7 +355,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
||||
if (seenVideoIds.has(dedupeKey)) continue;
|
||||
seenVideoIds.add(dedupeKey);
|
||||
|
||||
const size = chargeableSize(bunnyStats.byVideoId[version.videoId] || 0, version.sizeBytes);
|
||||
const size = chargeableSize(reported[version.videoId] || 0, version.sizeBytes);
|
||||
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
|
||||
}
|
||||
|
||||
@@ -297,10 +366,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
||||
if (seenVideoIds.has(dedupeKey)) continue;
|
||||
seenVideoIds.add(dedupeKey);
|
||||
|
||||
const size = chargeableSize(
|
||||
bunnyStats.byVideoId[asset.providerVideoId] || 0,
|
||||
asset.sizeBytes
|
||||
);
|
||||
const size = chargeableSize(reported[asset.providerVideoId] || 0, asset.sizeBytes);
|
||||
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -67,6 +67,12 @@ export const ErrorCode = {
|
||||
|
||||
// Storage errors
|
||||
STORAGE_LIMIT_EXCEEDED: 'STORAGE_LIMIT_EXCEEDED',
|
||||
/**
|
||||
* Out of room because the account has not paid, rather than because the plan
|
||||
* is full. Its own code so the client can offer the upgrade, which is the
|
||||
* actual remedy here and is no help at all on the paid ceiling.
|
||||
*/
|
||||
TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -175,4 +181,13 @@ export const apiErrors = {
|
||||
storageExceeded: (
|
||||
message = 'Storage limit exceeded. Please delete some files to free up space.'
|
||||
) => errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED),
|
||||
|
||||
/**
|
||||
* The same 507, for an account that is out of room because it is on the free
|
||||
* trial. Telling this caller to delete files is advice that does not apply:
|
||||
* they have three gigabytes because they have not subscribed, not because they
|
||||
* have filled two hundred.
|
||||
*/
|
||||
trialStorageExceeded: (message: string) =>
|
||||
errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.TRIAL_STORAGE_LIMIT_EXCEEDED),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// What a failed API call is worth showing the person who made it.
|
||||
//
|
||||
// Every route answers with `{ error, code? }`, and the code is the part that
|
||||
// says whether there is anything the caller can do. Losing it on the way to a
|
||||
// toast is how a full trial account ends up reading "Failed to upload" and
|
||||
// trying the same upload again.
|
||||
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/** The machine-readable half of an error response. Mirrors ErrorCode in lib/api-response.ts. */
|
||||
export const API_ERROR_CODES = {
|
||||
/** Out of room because the account has not subscribed, not because the plan is full. */
|
||||
TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED',
|
||||
} as const;
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure that came back from our own API, with the code still attached.
|
||||
*
|
||||
* The upload helpers throw rather than return, so without this the code is gone
|
||||
* by the time anything is in a position to show it.
|
||||
*/
|
||||
export class ApiRequestError extends Error {
|
||||
readonly code: string | null;
|
||||
|
||||
constructor(message: string, code?: string | null) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
this.code = code ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the error to throw from a parsed `{ error, code }` body. */
|
||||
export function apiRequestError(
|
||||
payload: ApiErrorPayload | null,
|
||||
fallback: string
|
||||
): ApiRequestError {
|
||||
return new ApiRequestError(payload?.error || fallback, payload?.code);
|
||||
}
|
||||
|
||||
function codeOf(source: unknown): string | null {
|
||||
if (source instanceof ApiRequestError) return source.code;
|
||||
if (source && typeof source === 'object' && 'code' in source) {
|
||||
const code = (source as ApiErrorPayload).code;
|
||||
return typeof code === 'string' ? code : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function messageOf(source: unknown, fallback: string): string {
|
||||
if (source instanceof Error) return source.message || fallback;
|
||||
if (source && typeof source === 'object' && 'error' in source) {
|
||||
const message = (source as ApiErrorPayload).error;
|
||||
if (typeof message === 'string' && message) return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface ToastApiErrorOptions {
|
||||
/** Prepended as `${prefix}: ${message}`, for per-file failures in a batch. */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an API failure, with a way out attached when there is one.
|
||||
*
|
||||
* Takes either a parsed response body or a thrown error, so the same call works
|
||||
* whether the caller checked `res.ok` itself or caught what an upload helper
|
||||
* threw. On the trial storage ceiling it adds a link to the billing settings,
|
||||
* because subscribing is the only thing that makes the upload possible and the
|
||||
* uploader has no way to know that from "storage limit exceeded".
|
||||
*/
|
||||
export function toastApiError(
|
||||
source: unknown,
|
||||
fallback: string,
|
||||
options: ToastApiErrorOptions = {}
|
||||
): void {
|
||||
const message = messageOf(source, fallback);
|
||||
const text = options.prefix ? `${options.prefix}: ${message}` : message;
|
||||
|
||||
if (codeOf(source) === API_ERROR_CODES.TRIAL_STORAGE_LIMIT_EXCEEDED) {
|
||||
toast.error(text, {
|
||||
// Longer than a plain error: this one is asking for a decision rather than
|
||||
// just reporting, and it disappears under the cursor at the usual timing.
|
||||
duration: 12000,
|
||||
action: {
|
||||
label: 'See plans',
|
||||
onClick: () => {
|
||||
window.location.href = '/settings';
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(text);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
import { apiRequestError } from '@/lib/client/api-error';
|
||||
|
||||
export const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
||||
|
||||
@@ -143,7 +144,7 @@ export async function uploadProjectVideo(
|
||||
} | null;
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(createPayload?.error || 'Failed to create video');
|
||||
throw apiRequestError(createPayload, 'Failed to create video');
|
||||
}
|
||||
|
||||
pendingCleanup = null;
|
||||
@@ -171,7 +172,7 @@ export async function uploadProjectVideo(
|
||||
} | null;
|
||||
|
||||
if (!initResponse.ok || !initPayload?.data) {
|
||||
throw new Error(initPayload?.error || 'Failed to initialize upload');
|
||||
throw apiRequestError(initPayload, 'Failed to initialize upload');
|
||||
}
|
||||
|
||||
const { videoId, libraryId, signature, expirationTime, uploadToken } = initPayload.data;
|
||||
@@ -243,7 +244,7 @@ export async function uploadProjectVideo(
|
||||
} | null;
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(createPayload?.error || 'Failed to create video');
|
||||
throw apiRequestError(createPayload, 'Failed to create video');
|
||||
}
|
||||
|
||||
pendingCleanup = null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
||||
import { apiRequestError } from '@/lib/client/api-error';
|
||||
import { uploadBytesWithProgress, type UploadProgressHandler } from '@/lib/client/r2-video-upload';
|
||||
|
||||
export type R2AssetVideoInitResponse = {
|
||||
@@ -39,7 +40,7 @@ export async function initR2AssetVideoUpload(
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!initRes.ok || !initPayload?.data) {
|
||||
throw new Error(initPayload?.error || 'Failed to initialize video upload');
|
||||
throw apiRequestError(initPayload, 'Failed to initialize video upload');
|
||||
}
|
||||
|
||||
return initPayload.data;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
||||
import { apiRequestError } from '@/lib/client/api-error';
|
||||
import {
|
||||
getMultipartProgressPercent,
|
||||
getPartByteRange,
|
||||
@@ -151,7 +152,7 @@ async function completeMultipartUpload(
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(payload?.error || 'Failed to complete multipart upload');
|
||||
throw apiRequestError(payload, 'Failed to complete multipart upload');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +244,7 @@ export async function initR2VideoUpload(
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!initRes.ok || !initPayload?.data) {
|
||||
throw new Error(initPayload?.error || 'Failed to initialize video upload');
|
||||
throw apiRequestError(initPayload, 'Failed to initialize video upload');
|
||||
}
|
||||
|
||||
return initPayload.data;
|
||||
|
||||
+62
-15
@@ -2,7 +2,7 @@ import type { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||
import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
|
||||
import { isPaidTier } from '@/lib/billing';
|
||||
import { getStorageLimitBytes } from '@/lib/trial-limits';
|
||||
|
||||
@@ -18,12 +18,59 @@ export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024
|
||||
* forget to pass it.
|
||||
*/
|
||||
export async function getStorageLimitForUser(userId: string): Promise<bigint> {
|
||||
return (await getStorageContextForUser(userId)).limitBytes;
|
||||
}
|
||||
|
||||
export interface StorageContext {
|
||||
/** The ceiling this account is held to. */
|
||||
limitBytes: bigint;
|
||||
/** Whether that ceiling is the plan's or the trial's. */
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceiling and the reason for it, read together.
|
||||
*
|
||||
* The two travel as a pair because a refusal has to say which one it is: an
|
||||
* unpaid account is out of room because it has not subscribed, and telling it to
|
||||
* delete files is advice that does not apply.
|
||||
*/
|
||||
export async function getStorageContextForUser(userId: string): Promise<StorageContext> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||
});
|
||||
|
||||
return getStorageLimitBytes(user ? isPaidTier(user) : false, PLAN_STORAGE_LIMIT_BYTES);
|
||||
const isPaid = user ? isPaidTier(user) : false;
|
||||
return { limitBytes: getStorageLimitBytes(isPaid, PLAN_STORAGE_LIMIT_BYTES), isPaid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole gigabytes where the number is whole, which every ceiling we ship is.
|
||||
* A host that sets an odd one gets a decimal rather than a rounded lie.
|
||||
*/
|
||||
function formatStorageLimit(bytes: bigint): string {
|
||||
const gigabytes = Number(bytes) / 1024 ** 3;
|
||||
return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal, in the words that fit the account it is being given to.
|
||||
*
|
||||
* A paying account that has filled 200 GB has to delete something. An unpaid one
|
||||
* has three gigabytes because it has not subscribed, so the way out is the
|
||||
* subscription, and the response says so under its own error code rather than
|
||||
* leaving the client to guess from the number.
|
||||
*/
|
||||
export function storageExceededResponse(context: StorageContext): NextResponse {
|
||||
if (context.isPaid) {
|
||||
return apiErrors.storageExceeded() as NextResponse;
|
||||
}
|
||||
|
||||
return apiErrors.trialStorageExceeded(
|
||||
`Your free trial includes ${formatStorageLimit(context.limitBytes)} of storage. ` +
|
||||
`Upgrade to get ${formatStorageLimit(PLAN_STORAGE_LIMIT_BYTES)}.`
|
||||
) as NextResponse;
|
||||
}
|
||||
|
||||
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
|
||||
@@ -66,7 +113,7 @@ class QuotaExceededError extends Error {}
|
||||
* every upload.
|
||||
*/
|
||||
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
|
||||
const [r2AssetRows, r2VideoRows, bunnyByUser, reservationRows] = await Promise.all([
|
||||
const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_assets
|
||||
@@ -82,7 +129,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
||||
WHERE w."ownerId" = ${userId}
|
||||
AND vv."providerId" = 'r2'
|
||||
`,
|
||||
getCachedUserBunnyStorage(),
|
||||
getUserBunnyStorageBytes(userId),
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
|
||||
FROM upload_reservations
|
||||
@@ -93,7 +140,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
||||
|
||||
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
|
||||
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
|
||||
const bunnyBytes = BigInt(bunnyByUser[userId] ?? 0);
|
||||
const bunnyBytes = BigInt(bunnyUserBytes);
|
||||
const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
|
||||
|
||||
return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes;
|
||||
@@ -136,13 +183,13 @@ export async function enforceStorageQuota(
|
||||
return null;
|
||||
}
|
||||
|
||||
const [usedBytes, limitBytes] = await Promise.all([
|
||||
const [usedBytes, storage] = await Promise.all([
|
||||
getUserTotalStorageBytes(userId),
|
||||
getStorageLimitForUser(userId),
|
||||
getStorageContextForUser(userId),
|
||||
]);
|
||||
|
||||
if (usedBytes + incomingSizeBytes >= limitBytes) {
|
||||
return apiErrors.storageExceeded() as NextResponse;
|
||||
if (usedBytes + incomingSizeBytes >= storage.limitBytes) {
|
||||
return storageExceededResponse(storage);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -176,11 +223,11 @@ export async function reserveStorageQuota(
|
||||
// Fetch Bunny storage and the account's ceiling BEFORE entering the transaction,
|
||||
// to avoid holding the advisory lock during a potentially slow/failing HTTP call
|
||||
// on cache miss or an extra round trip to Postgres.
|
||||
const [bunnyData, limitBytes] = await Promise.all([
|
||||
getCachedUserBunnyStorage(),
|
||||
getStorageLimitForUser(userId),
|
||||
const [bunnyUserBytes, storage] = await Promise.all([
|
||||
getUserBunnyStorageBytes(userId),
|
||||
getStorageContextForUser(userId),
|
||||
]);
|
||||
const bunnyBytes = BigInt(bunnyData[userId] ?? 0);
|
||||
const bunnyBytes = BigInt(bunnyUserBytes);
|
||||
|
||||
try {
|
||||
const reservationId = await db.$transaction(async (tx) => {
|
||||
@@ -222,7 +269,7 @@ export async function reserveStorageQuota(
|
||||
const reservedBytes = resRow?.total ?? BigInt(0);
|
||||
|
||||
const totalUsed = r2Bytes + reservedBytes + bunnyBytes;
|
||||
if (totalUsed + incomingSizeBytes >= limitBytes) {
|
||||
if (totalUsed + incomingSizeBytes >= storage.limitBytes) {
|
||||
throw new QuotaExceededError();
|
||||
}
|
||||
|
||||
@@ -237,7 +284,7 @@ export async function reserveStorageQuota(
|
||||
return { reservationId };
|
||||
} catch (e) {
|
||||
if (e instanceof QuotaExceededError) {
|
||||
return { error: apiErrors.storageExceeded() as NextResponse };
|
||||
return { error: storageExceededResponse(storage) };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,15 @@ import {
|
||||
} from '@/app/api/projects/[projectId]/videos/bunny-init/route';
|
||||
import { POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
|
||||
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||
import { PLAN_STORAGE_LIMIT_BYTES } from '@/lib/storage-quota';
|
||||
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
||||
import { signedInAs } from '../helpers/session';
|
||||
import { createVideo, seedProject } from '../factories';
|
||||
import {
|
||||
createSubscribedUser,
|
||||
createUploadReservation,
|
||||
createVideo,
|
||||
seedProject,
|
||||
} from '../factories';
|
||||
|
||||
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
|
||||
@@ -261,3 +267,65 @@ describe('a Bunny hold cannot be consumed by another flow', () => {
|
||||
expect(second.status).toBe(507);
|
||||
});
|
||||
});
|
||||
|
||||
// What a refusal says, and to whom.
|
||||
//
|
||||
// A trial account is out of room because it has not subscribed, so "delete some
|
||||
// files" is advice that does not apply and the upgrade is the only way through.
|
||||
// The two cases carry different codes because the client offers a link on one of
|
||||
// them and must not on the other.
|
||||
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);
|
||||
|
||||
const response = await initUpload(scenario.project.id, BigInt(4) * GIB);
|
||||
|
||||
expect(response.status).toBe(507);
|
||||
const body = (await response.json()) as { error: string; code: string };
|
||||
expect(body.code).toBe('TRIAL_STORAGE_LIMIT_EXCEEDED');
|
||||
expect(body.error).toContain('3 GB');
|
||||
expect(body.error).toContain('Upgrade');
|
||||
});
|
||||
|
||||
it('tells a paying account to free up space instead', async () => {
|
||||
const scenario = await seedProject({ ownerUser: await createSubscribedUser() });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
// Full at the paid ceiling rather than the trial one.
|
||||
await createUploadReservation({
|
||||
billedUserId: scenario.owner.id,
|
||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||
});
|
||||
|
||||
const response = await initUpload(scenario.project.id, BigInt(1) * GIB);
|
||||
|
||||
expect(response.status).toBe(507);
|
||||
const body = (await response.json()) as { error: string; code: string };
|
||||
expect(body.code).toBe('STORAGE_LIMIT_EXCEEDED');
|
||||
expect(body.error).toContain('delete some files');
|
||||
});
|
||||
});
|
||||
|
||||
// Asked directly: five one-gigabyte uploads started at once against a three
|
||||
// gigabyte trial. Whether they go up in one piece or in parts makes no
|
||||
// difference, because r2-init takes the reservation before it decides on
|
||||
// multipart, and bunny-init holds one for the size the client declared.
|
||||
describe('several uploads started at once', () => {
|
||||
it('grants only the ones that fit and refuses the rest', async () => {
|
||||
const scenario = await seedProject();
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () => initUpload(scenario.project.id, BigInt(1) * GIB))
|
||||
);
|
||||
|
||||
const granted = results.filter((response) => response.status === 200);
|
||||
const refused = results.filter((response) => response.status === 507);
|
||||
|
||||
// Two fit: the check is >=, so the third would land exactly on 3 GiB.
|
||||
expect(granted).toHaveLength(2);
|
||||
expect(refused).toHaveLength(3);
|
||||
expect(await db.uploadReservation.count()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getCachedUserBunnyStorage,
|
||||
getCachedUserDownloadEgress,
|
||||
getCachedUserMediaStorage,
|
||||
getUserBunnyStorageBytes,
|
||||
refreshR2StorageSnapshot,
|
||||
} from '@/lib/admin-stats';
|
||||
import {
|
||||
@@ -603,21 +604,23 @@ describe('getCachedUserBunnyStorage', () => {
|
||||
});
|
||||
|
||||
// The -1 sentinel means "Bunny did not answer", and the module must not turn
|
||||
// that into "this user stores nothing", because lib/storage-quota.ts would
|
||||
// then hand out headroom the user does not have. An empty map at least leaves
|
||||
// the R2 figures intact.
|
||||
it('answers an empty map when the Bunny library could not be read', async () => {
|
||||
// that into "this user stores nothing", because lib/storage-quota.ts would then
|
||||
// hand out headroom the user does not have. Bunny's figure is gone; the size
|
||||
// declared when the upload was admitted is still in our own rows, so that is
|
||||
// what the account is charged until Bunny can be reached again.
|
||||
it('falls back to the declared sizes when the Bunny library could not be read', async () => {
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-first',
|
||||
sizeBytes: BigInt(2048),
|
||||
});
|
||||
bunnyCredentials();
|
||||
stubBunnyPages([{ status: 503, body: {} }]);
|
||||
|
||||
expect(await getCachedUserBunnyStorage()).toEqual({});
|
||||
expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 2048 });
|
||||
});
|
||||
|
||||
it('is empty on a database with no videos at all', async () => {
|
||||
@@ -987,3 +990,126 @@ describe('getCachedStripeStats', () => {
|
||||
expect(await getCachedStripeStats()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The per-user figure the quota checks read, which deliberately does not come
|
||||
// from the cached map above.
|
||||
//
|
||||
// The bug this exists for: the declared size lands on the row at the moment an
|
||||
// upload finalizes and the reservation is deleted in the same transaction. A map
|
||||
// computed up to two minutes earlier does not have that row in it, so for those
|
||||
// two minutes the upload that just succeeded counted as nothing. The uploader
|
||||
// watched their usage fall back to zero and the next upload was measured against
|
||||
// a total that ignored the one before it.
|
||||
describe('getUserBunnyStorageBytes', () => {
|
||||
it('counts a finished upload straight away, without waiting for Bunny', async () => {
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
// Bunny has the video but reports nothing for it yet, which is what an
|
||||
// encode in progress looks like.
|
||||
stubBunnyLibrary({ 'bunny-encoding': 0 });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-encoding',
|
||||
sizeBytes: BigInt(2_500_000_000),
|
||||
});
|
||||
|
||||
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000);
|
||||
});
|
||||
|
||||
// What Bunny reports mid-encode is partial: it counts what has been written so
|
||||
// far and climbs as each rendition lands. A 2.5 GB source read as 475 MB
|
||||
// halfway through and settled above 3 GB once it finished. Letting the partial
|
||||
// figure displace the declared size would hand most of the quota back in the
|
||||
// middle of an encode, which is exactly what the declared size is there to stop.
|
||||
it('keeps the declared size while Bunny figure is still climbing', async () => {
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-encoding',
|
||||
sizeBytes: BigInt(2_500_000_000),
|
||||
});
|
||||
stubBunnyLibrary({ 'bunny-encoding': 474_900_000 });
|
||||
|
||||
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000);
|
||||
});
|
||||
|
||||
// And gets out of the way once the renditions are all there, because they are
|
||||
// the actual bill and they add up to more than the source.
|
||||
it('takes Bunny own figure once it passes the declared size', async () => {
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-encoded',
|
||||
sizeBytes: BigInt(2_500_000_000),
|
||||
});
|
||||
stubBunnyLibrary({ 'bunny-encoded': 3_600_000_000 });
|
||||
|
||||
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(3_600_000_000);
|
||||
});
|
||||
|
||||
it('counts only the videos billed to the user asked about', async () => {
|
||||
const mine = await seedProject();
|
||||
const theirs = await seedProject();
|
||||
const myVideo = await createVideo({ projectId: mine.project.id });
|
||||
const theirVideo = await createVideo({ projectId: theirs.project.id });
|
||||
await createVersion({
|
||||
videoParentId: myVideo.id,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-mine',
|
||||
sizeBytes: BigInt(700),
|
||||
});
|
||||
await createVersion({
|
||||
videoParentId: theirVideo.id,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-theirs',
|
||||
sizeBytes: BigInt(900),
|
||||
});
|
||||
stubBunnyLibrary({ 'bunny-mine': 0, 'bunny-theirs': 0 });
|
||||
|
||||
expect(await getUserBunnyStorageBytes(mine.owner.id)).toBe(700);
|
||||
expect(await getUserBunnyStorageBytes(theirs.owner.id)).toBe(900);
|
||||
});
|
||||
|
||||
it('adds Bunny assets to the user they are billed to, deduped against versions', async () => {
|
||||
const scenario = await seedProject();
|
||||
const video = await createVideo({ projectId: scenario.project.id });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'bunny',
|
||||
providerVideoId: 'bunny-shared',
|
||||
sizeBytes: BigInt(500),
|
||||
});
|
||||
await createVideoAsset({
|
||||
videoId: video.id,
|
||||
billedUserId: scenario.owner.id,
|
||||
kind: VideoAssetKind.VIDEO,
|
||||
provider: VideoAssetProvider.BUNNY,
|
||||
providerVideoId: 'bunny-shared',
|
||||
sizeBytes: BigInt(500),
|
||||
});
|
||||
await createVideoAsset({
|
||||
videoId: video.id,
|
||||
billedUserId: scenario.owner.id,
|
||||
kind: VideoAssetKind.VIDEO,
|
||||
provider: VideoAssetProvider.BUNNY,
|
||||
providerVideoId: 'bunny-asset-only',
|
||||
sizeBytes: BigInt(300),
|
||||
});
|
||||
stubBunnyLibrary({ 'bunny-shared': 0, 'bunny-asset-only': 0 });
|
||||
|
||||
// The shared id is one video however many rows point at it.
|
||||
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(800);
|
||||
});
|
||||
|
||||
it('is zero for a user with no Bunny videos', async () => {
|
||||
const scenario = await seedProject();
|
||||
stubBunnyLibrary({ 'bunny-someone-else': 999 });
|
||||
|
||||
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '@/lib/db';
|
||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||
import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
|
||||
import {
|
||||
PLAN_STORAGE_LIMIT_BYTES,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
|
||||
function bunnyStorage(map: Record<string, number>): void {
|
||||
vi.mocked(getCachedUserBunnyStorage).mockResolvedValue(map);
|
||||
vi.mocked(getUserBunnyStorageBytes).mockImplementation(async (userId) => map[userId] ?? 0);
|
||||
}
|
||||
|
||||
// The mock implementation is module state, so it survives afterEach. Reset it so
|
||||
|
||||
+4
-3
@@ -146,14 +146,15 @@ vi.mock('@/lib/stripe', async (importOriginal) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bunny storage stats
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCachedUserBunnyStorage() is an HTTP call to the Bunny API, and it sits in
|
||||
// the middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota
|
||||
// suite overrides it to prove Bunny usage counts against the limit.
|
||||
// Both of these reach the Bunny API, and getUserBunnyStorageBytes() sits in the
|
||||
// middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota suite
|
||||
// overrides them to prove Bunny usage counts against the limit.
|
||||
vi.mock('@/lib/admin-stats', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/admin-stats')>();
|
||||
return {
|
||||
...actual,
|
||||
getCachedUserBunnyStorage: vi.fn(async () => ({}) as Record<string, number>),
|
||||
getUserBunnyStorageBytes: vi.fn(async () => 0),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user