From 00c5acbe0c5ec08f6b467578f25135c4222242e5 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Mon, 6 Apr 2026 13:30:35 -0700 Subject: [PATCH 1/6] Add multipart upload foundation --- .../dashboard/-useVideoUploadManager.ts | 338 ++++++++++++--- convex/schema.ts | 3 + convex/videoActions.ts | 303 +++++++++++++- convex/videos.ts | 12 + plans/multipart-plan.md | 386 ++++++++++++++++++ 5 files changed, 959 insertions(+), 83 deletions(-) create mode 100644 plans/multipart-plan.md diff --git a/app/routes/dashboard/-useVideoUploadManager.ts b/app/routes/dashboard/-useVideoUploadManager.ts index cfb760d4..cfc2fb0a 100644 --- a/app/routes/dashboard/-useVideoUploadManager.ts +++ b/app/routes/dashboard/-useVideoUploadManager.ts @@ -4,6 +4,16 @@ import { api } from "@convex/_generated/api"; import { Id } from "@convex/_generated/dataModel"; import type { UploadStatus } from "@/components/upload/UploadProgress"; +const PART_UPLOAD_CONCURRENCY = 4; +const PART_URL_BATCH_SIZE = 16; + +interface MultipartUploadPart { + partNumber: number; + start: number; + end: number; + size: number; +} + export interface ManagedUploadItem { id: string; projectId: Id<"projects">; @@ -24,9 +34,121 @@ function createUploadId() { return Math.random().toString(36).slice(2); } +function createMultipartParts(file: File, partSizeBytes: number) { + const parts: MultipartUploadPart[] = []; + + for (let start = 0, partNumber = 1; start < file.size; start += partSizeBytes, partNumber += 1) { + const end = Math.min(start + partSizeBytes, file.size); + parts.push({ + partNumber, + start, + end, + size: end - start, + }); + } + + return parts; +} + +function uploadBlobPartToUrl(args: { + url: string; + blob: Blob; + abortSignal: AbortSignal; + onProgress: (loadedBytes: number) => void; +}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + const cleanup = () => { + args.abortSignal.removeEventListener("abort", handleAbort); + }; + + const handleAbort = () => { + xhr.abort(); + }; + + xhr.upload.addEventListener("progress", (event) => { + const loadedBytes = event.lengthComputable ? event.loaded : 0; + args.onProgress(Math.min(args.blob.size, loadedBytes)); + }); + + xhr.addEventListener("load", () => { + cleanup(); + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); + return; + } + + const etag = xhr.getResponseHeader("ETag") ?? xhr.getResponseHeader("etag"); + if (!etag) { + reject(new Error("Upload failed: Missing ETag from storage provider")); + return; + } + + args.onProgress(args.blob.size); + resolve(etag); + }); + + xhr.addEventListener("error", () => { + cleanup(); + reject(new Error("Upload failed: Network error")); + }); + + xhr.addEventListener("abort", () => { + cleanup(); + reject(new Error("Upload cancelled")); + }); + + args.abortSignal.addEventListener("abort", handleAbort, { once: true }); + + xhr.open("PUT", args.url); + xhr.send(args.blob); + }); +} + +async function runWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T) => Promise, +) { + let nextIndex = 0; + let firstError: unknown; + + const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (true) { + if (firstError) { + return; + } + + const currentIndex = nextIndex; + nextIndex += 1; + + if (currentIndex >= items.length) { + return; + } + + try { + await worker(items[currentIndex]); + } catch (error) { + firstError = error; + throw error; + } + } + }); + + await Promise.all(runners); +} + export function useVideoUploadManager() { const createVideo = useMutation(api.videos.create); - const getUploadUrl = useAction(api.videoActions.getUploadUrl); + const createMultipartUpload = useAction(api.videoActions.createMultipartUpload); + const getMultipartUploadPartUrls = useAction( + api.videoActions.getMultipartUploadPartUrls, + ); + const completeMultipartUpload = useAction( + api.videoActions.completeMultipartUpload, + ); + const abortMultipartUpload = useAction(api.videoActions.abortMultipartUpload); const markUploadComplete = useAction(api.videoActions.markUploadComplete); const markUploadFailed = useAction(api.videoActions.markUploadFailed); const [uploads, setUploads] = useState([]); @@ -51,6 +173,9 @@ export function useVideoUploadManager() { ]); let createdVideoId: Id<"videos"> | undefined; + let multipartKey: string | undefined; + let multipartUploadId: string | undefined; + let hasCompletedMultipartUpload = false; try { createdVideoId = await createVideo({ @@ -68,81 +193,146 @@ export function useVideoUploadManager() { ), ); - const { url } = await getUploadUrl({ + const multipartUpload = await createMultipartUpload({ videoId: createdVideoId, filename: file.name, fileSize: file.size, contentType: file.type || "video/mp4", }); - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - let lastTime = Date.now(); - let lastLoaded = 0; - const recentSpeeds: number[] = []; - - xhr.upload.addEventListener("progress", (event) => { - if (!event.lengthComputable) return; - - const percentage = Math.round((event.loaded / event.total) * 100); - const now = Date.now(); - const timeDelta = (now - lastTime) / 1000; - const bytesDelta = event.loaded - lastLoaded; - - if (timeDelta > 0.1) { - const speed = bytesDelta / timeDelta; - recentSpeeds.push(speed); - if (recentSpeeds.length > 5) recentSpeeds.shift(); - lastTime = now; - lastLoaded = event.loaded; + multipartKey = multipartUpload.key; + multipartUploadId = multipartUpload.multipartUploadId; + + const parts = createMultipartParts(file, multipartUpload.partSizeBytes); + if (parts.length !== multipartUpload.totalParts) { + throw new Error("Multipart upload was initialized with an invalid part count."); + } + + let uploadedBytes = 0; + let lastMeasuredBytes = 0; + let lastMeasuredAt = Date.now(); + const recentSpeeds: number[] = []; + const uploadedBytesByPart = new Map(); + const completedParts: Array<{ partNumber: number; etag: string }> = []; + const partUrlBatchCache = new Map>>(); + + const updateUploadMetrics = () => { + const progress = Math.min(100, Math.round((uploadedBytes / file.size) * 100)); + const now = Date.now(); + const elapsedSeconds = (now - lastMeasuredAt) / 1000; + + if (elapsedSeconds > 0.1) { + const speed = (uploadedBytes - lastMeasuredBytes) / elapsedSeconds; + recentSpeeds.push(speed); + if (recentSpeeds.length > 5) { + recentSpeeds.shift(); } + lastMeasuredAt = now; + lastMeasuredBytes = uploadedBytes; + } + + const averageBytesPerSecond = + recentSpeeds.length > 0 + ? recentSpeeds.reduce((sum, speed) => sum + speed, 0) / recentSpeeds.length + : 0; + const remainingBytes = Math.max(0, file.size - uploadedBytes); + const estimatedSecondsRemaining = + averageBytesPerSecond > 0 + ? Math.ceil(remainingBytes / averageBytesPerSecond) + : null; + + setUploads((prev) => + prev.map((upload) => + upload.id === uploadId + ? { + ...upload, + progress, + bytesPerSecond: averageBytesPerSecond, + estimatedSecondsRemaining, + } + : upload, + ), + ); + }; + + const setUploadedBytesForPart = (part: MultipartUploadPart, nextLoadedBytes: number) => { + const safeLoadedBytes = Math.max(0, Math.min(part.size, nextLoadedBytes)); + const previousLoadedBytes = uploadedBytesByPart.get(part.partNumber) ?? 0; + uploadedBytesByPart.set(part.partNumber, safeLoadedBytes); + uploadedBytes += safeLoadedBytes - previousLoadedBytes; + updateUploadMetrics(); + }; - const avgSpeed = - recentSpeeds.length > 0 - ? recentSpeeds.reduce((sum, speed) => sum + speed, 0) / - recentSpeeds.length - : 0; - const remaining = event.total - event.loaded; - const eta = avgSpeed > 0 ? Math.ceil(remaining / avgSpeed) : null; - - setUploads((prev) => - prev.map((upload) => - upload.id === uploadId - ? { - ...upload, - progress: percentage, - bytesPerSecond: avgSpeed, - estimatedSecondsRemaining: eta, - } - : upload, - ), + const getPartUploadUrl = async (partNumber: number) => { + const batchIndex = Math.floor((partNumber - 1) / PART_URL_BATCH_SIZE); + let batchPromise = partUrlBatchCache.get(batchIndex); + + if (!batchPromise) { + const batchStartPartNumber = batchIndex * PART_URL_BATCH_SIZE + 1; + const batchPartNumbers = Array.from( + { length: Math.min(PART_URL_BATCH_SIZE, multipartUpload.totalParts - batchStartPartNumber + 1) }, + (_, index) => batchStartPartNumber + index, ); - }); - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - return; - } - reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); - }); + batchPromise = getMultipartUploadPartUrls({ + videoId: createdVideoId, + key: multipartUpload.key, + multipartUploadId: multipartUpload.multipartUploadId, + partNumbers: batchPartNumbers, + }).then((result) => new Map(result.parts.map((part) => [part.partNumber, part.url]))); + partUrlBatchCache.set(batchIndex, batchPromise); + } - xhr.addEventListener("error", () => { - reject(new Error("Upload failed: Network error")); - }); + const batchUrls = await batchPromise; + const url = batchUrls.get(partNumber); + if (!url) { + throw new Error(`Missing upload URL for multipart upload part ${partNumber}.`); + } + return url; + }; - xhr.addEventListener("abort", () => { - reject(new Error("Upload cancelled")); + await runWithConcurrency(parts, PART_UPLOAD_CONCURRENCY, async (part) => { + if (abortController.signal.aborted) { + throw new Error("Upload cancelled"); + } + + const url = await getPartUploadUrl(part.partNumber); + const etag = await uploadBlobPartToUrl({ + url, + blob: file.slice(part.start, part.end), + abortSignal: abortController.signal, + onProgress: (loadedBytes) => { + setUploadedBytesForPart(part, loadedBytes); + }, }); - abortController.signal.addEventListener("abort", () => { - xhr.abort(); + completedParts.push({ + partNumber: part.partNumber, + etag, }); + }); - xhr.open("PUT", url); - xhr.setRequestHeader("Content-Type", file.type || "video/mp4"); - xhr.send(file); + await completeMultipartUpload({ + videoId: createdVideoId, + key: multipartUpload.key, + multipartUploadId: multipartUpload.multipartUploadId, + parts: completedParts.sort((a, b) => a.partNumber - b.partNumber), }); + hasCompletedMultipartUpload = true; + + setUploads((prev) => + prev.map((upload) => + upload.id === uploadId + ? { + ...upload, + progress: 100, + status: "processing", + bytesPerSecond: 0, + estimatedSecondsRemaining: null, + } + : upload, + ), + ); await markUploadComplete({ videoId: createdVideoId }); @@ -158,8 +348,24 @@ export function useVideoUploadManager() { setUploads((prev) => prev.filter((upload) => upload.id !== uploadId)); }, 3000); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Upload failed"; + if ( + createdVideoId && + multipartKey && + multipartUploadId && + !hasCompletedMultipartUpload + ) { + if (!abortController.signal.aborted) { + abortController.abort(); + } + + abortMultipartUpload({ + videoId: createdVideoId, + key: multipartKey, + multipartUploadId, + }).catch(console.error); + } + + const errorMessage = error instanceof Error ? error.message : "Upload failed"; setUploads((prev) => prev.map((upload) => @@ -175,7 +381,15 @@ export function useVideoUploadManager() { } } }, - [createVideo, getUploadUrl, markUploadComplete, markUploadFailed], + [ + abortMultipartUpload, + completeMultipartUpload, + createMultipartUpload, + createVideo, + getMultipartUploadPartUrls, + markUploadComplete, + markUploadFailed, + ], ); const cancelUpload = useCallback( diff --git a/convex/schema.ts b/convex/schema.ts index 4b2de9e1..4ed933ae 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -88,6 +88,9 @@ export default defineSchema({ thumbnailUrl: v.optional(v.string()), fileSize: v.optional(v.number()), contentType: v.optional(v.string()), + multipartUploadId: v.optional(v.string()), + uploadPartSizeBytes: v.optional(v.number()), + uploadTotalParts: v.optional(v.number()), uploadError: v.optional(v.string()), status: v.union( v.literal("uploading"), diff --git a/convex/videoActions.ts b/convex/videoActions.ts index 57a27daa..01574258 100644 --- a/convex/videoActions.ts +++ b/convex/videoActions.ts @@ -1,10 +1,13 @@ "use node"; import { + AbortMultipartUploadCommand, + CompleteMultipartUploadCommand, + CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, - PutObjectCommand, + UploadPartCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { v } from "convex/values"; @@ -20,8 +23,10 @@ import { } from "./mux"; import { BUCKET_NAME, getS3Client } from "./s3"; -const GIBIBYTE = 1024 ** 3; -const MAX_PRESIGNED_PUT_FILE_SIZE_BYTES = 5 * GIBIBYTE; +const MEBIBYTE = 1024 ** 2; +const DEFAULT_MULTIPART_PART_SIZE_BYTES = 16 * MEBIBYTE; +const MAX_MULTIPART_PARTS = 10_000; +const MAX_PART_URLS_PER_REQUEST = 32; const ALLOWED_UPLOAD_CONTENT_TYPES = new Set([ "video/mp4", "video/quicktime", @@ -152,10 +157,6 @@ function validateUploadRequestOrThrow(args: { fileSize: number; contentType: str throw new Error("Video file size must be greater than zero."); } - if (args.fileSize > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) { - throw new Error("Video file is too large for direct upload."); - } - const normalizedContentType = normalizeContentType(args.contentType); if (!isAllowedUploadContentType(normalizedContentType)) { throw new Error("Unsupported video format. Allowed: mp4, mov, webm, mkv."); @@ -164,6 +165,109 @@ function validateUploadRequestOrThrow(args: { fileSize: number; contentType: str return normalizedContentType; } +function buildUploadKey(videoId: Id<"videos">, filename: string) { + const ext = getExtensionFromKey(filename); + return `videos/${videoId}/${Date.now()}.${ext}`; +} + +function getMultipartPartSizeBytes(fileSize: number) { + const minimumSizeForPartCount = Math.ceil(fileSize / MAX_MULTIPART_PARTS); + const targetPartSize = Math.max( + DEFAULT_MULTIPART_PART_SIZE_BYTES, + minimumSizeForPartCount, + ); + return Math.ceil(targetPartSize / MEBIBYTE) * MEBIBYTE; +} + +function validatePartNumberOrThrow(partNumber: number) { + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > MAX_MULTIPART_PARTS) { + throw new Error("Invalid multipart upload part number."); + } + return partNumber; +} + +function validatePartNumbersOrThrow(partNumbers: number[]) { + if (partNumbers.length === 0) { + throw new Error("At least one multipart upload part number is required."); + } + + if (partNumbers.length > MAX_PART_URLS_PER_REQUEST) { + throw new Error("Too many multipart upload parts requested at once."); + } + + const seenPartNumbers = new Set(); + return partNumbers.map((partNumber) => { + const normalizedPartNumber = validatePartNumberOrThrow(partNumber); + if (seenPartNumbers.has(normalizedPartNumber)) { + throw new Error("Duplicate multipart upload part number."); + } + seenPartNumbers.add(normalizedPartNumber); + return normalizedPartNumber; + }); +} + +function normalizeCompletedPartsOrThrow( + parts: Array<{ partNumber: number; etag: string }>, + expectedPartCount?: number, +) { + if (parts.length === 0) { + throw new Error("At least one completed multipart upload part is required."); + } + + if (expectedPartCount !== undefined && parts.length !== expectedPartCount) { + throw new Error("Multipart upload completed with the wrong number of parts."); + } + + const seenPartNumbers = new Set(); + return [...parts] + .map((part) => { + const partNumber = validatePartNumberOrThrow(part.partNumber); + if (expectedPartCount !== undefined && partNumber > expectedPartCount) { + throw new Error("Multipart upload completed with an unexpected part number."); + } + const etag = part.etag.trim(); + if (!etag) { + throw new Error(`Missing multipart upload ETag for part ${partNumber}.`); + } + if (seenPartNumbers.has(partNumber)) { + throw new Error("Duplicate completed multipart upload part."); + } + seenPartNumbers.add(partNumber); + return { partNumber, etag }; + }) + .sort((a, b) => a.partNumber - b.partNumber); +} + +async function requireMatchingUploadKey( + ctx: ActionCtx, + params: { + videoId: Id<"videos">; + key: string; + multipartUploadId?: string; + }, +) { + const video = await ctx.runQuery(api.videos.getVideoForPlayback, { + videoId: params.videoId, + }); + + if (!video?.s3Key) { + throw new Error("Upload session not found for this video."); + } + + if (video.s3Key !== params.key) { + throw new Error("Upload session no longer matches this video."); + } + + if ( + params.multipartUploadId !== undefined && + video.multipartUploadId !== params.multipartUploadId + ) { + throw new Error("Multipart upload session no longer matches this video."); + } + + return video; +} + function shouldDeleteUploadedObjectOnFailure(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -173,6 +277,8 @@ function shouldDeleteUploadedObjectOnFailure(error: unknown): boolean { error.message.includes("Unsupported video format") || error.message.includes("Video file is too large") || error.message.includes("Uploaded video file not found") || + error.message.includes("Uploaded video file size did not match") || + error.message.includes("Multipart upload completed with") || error.message.includes("Storage limit reached") ); } @@ -233,7 +339,7 @@ async function ensurePublicPlaybackId( return resolvedPlaybackId; } -export const getUploadUrl = action({ +export const createMultipartUpload = action({ args: { videoId: v.id("videos"), filename: v.string(), @@ -241,8 +347,10 @@ export const getUploadUrl = action({ contentType: v.string(), }, returns: v.object({ - url: v.string(), - uploadId: v.string(), + key: v.string(), + multipartUploadId: v.string(), + partSizeBytes: v.number(), + totalParts: v.number(), }), handler: async (ctx, args) => { await requireVideoMemberAccess(ctx, args.videoId); @@ -251,24 +359,173 @@ export const getUploadUrl = action({ contentType: args.contentType, }); + const key = buildUploadKey(args.videoId, args.filename); + const partSizeBytes = getMultipartPartSizeBytes(args.fileSize); + const totalParts = Math.max(1, Math.ceil(args.fileSize / partSizeBytes)); const s3 = getS3Client(); - const ext = getExtensionFromKey(args.filename); - const key = `videos/${args.videoId}/${Date.now()}.${ext}`; - const command = new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - ContentType: normalizedContentType, - }); - const url = await getSignedUrl(s3, command, { expiresIn: 3600 }); + const result = await s3.send( + new CreateMultipartUploadCommand({ + Bucket: BUCKET_NAME, + Key: key, + ContentType: normalizedContentType, + }), + ); + + if (!result.UploadId) { + throw new Error("Could not create multipart upload session."); + } await ctx.runMutation(internal.videos.setUploadInfo, { videoId: args.videoId, s3Key: key, fileSize: args.fileSize, contentType: normalizedContentType, + multipartUploadId: result.UploadId, + uploadPartSizeBytes: partSizeBytes, + uploadTotalParts: totalParts, }); - return { url, uploadId: key }; + return { + key, + multipartUploadId: result.UploadId, + partSizeBytes, + totalParts, + }; + }, +}); + +export const getMultipartUploadPartUrls = action({ + args: { + videoId: v.id("videos"), + key: v.string(), + multipartUploadId: v.string(), + partNumbers: v.array(v.number()), + }, + returns: v.object({ + parts: v.array( + v.object({ + partNumber: v.number(), + url: v.string(), + }), + ), + }), + handler: async (ctx, args) => { + await requireVideoMemberAccess(ctx, args.videoId); + const video = await requireMatchingUploadKey(ctx, { + videoId: args.videoId, + key: args.key, + multipartUploadId: args.multipartUploadId, + }); + const normalizedPartNumbers = validatePartNumbersOrThrow(args.partNumbers); + const expectedTotalParts = video.uploadTotalParts; + if ( + expectedTotalParts !== undefined && + normalizedPartNumbers.some((partNumber) => partNumber > expectedTotalParts) + ) { + throw new Error("Requested multipart upload part is outside this upload session."); + } + + const s3 = getS3Client(); + const parts = await Promise.all( + normalizedPartNumbers.map(async (partNumber) => ({ + partNumber, + url: await getSignedUrl( + s3, + new UploadPartCommand({ + Bucket: BUCKET_NAME, + Key: args.key, + UploadId: args.multipartUploadId, + PartNumber: partNumber, + }), + { expiresIn: 3600 }, + ), + })), + ); + + return { parts }; + }, +}); + +export const completeMultipartUpload = action({ + args: { + videoId: v.id("videos"), + key: v.string(), + multipartUploadId: v.string(), + parts: v.array( + v.object({ + partNumber: v.number(), + etag: v.string(), + }), + ), + }, + returns: v.object({ + success: v.boolean(), + }), + handler: async (ctx, args) => { + await requireVideoMemberAccess(ctx, args.videoId); + const video = await requireMatchingUploadKey(ctx, { + videoId: args.videoId, + key: args.key, + multipartUploadId: args.multipartUploadId, + }); + + const completedParts = normalizeCompletedPartsOrThrow( + args.parts, + video.uploadTotalParts, + ); + const s3 = getS3Client(); + await s3.send( + new CompleteMultipartUploadCommand({ + Bucket: BUCKET_NAME, + Key: args.key, + UploadId: args.multipartUploadId, + MultipartUpload: { + Parts: completedParts.map((part) => ({ + PartNumber: part.partNumber, + ETag: part.etag, + })), + }, + }), + ); + + return { success: true }; + }, +}); + +export const abortMultipartUpload = action({ + args: { + videoId: v.id("videos"), + key: v.string(), + multipartUploadId: v.string(), + }, + returns: v.object({ + success: v.boolean(), + }), + handler: async (ctx, args) => { + await requireVideoMemberAccess(ctx, args.videoId); + await requireMatchingUploadKey(ctx, { + videoId: args.videoId, + key: args.key, + multipartUploadId: args.multipartUploadId, + }); + + const s3 = getS3Client(); + try { + await s3.send( + new AbortMultipartUploadCommand({ + Bucket: BUCKET_NAME, + Key: args.key, + UploadId: args.multipartUploadId, + }), + ); + } catch (error) { + if (error instanceof Error && error.name === "NoSuchUpload") { + return { success: true }; + } + throw error; + } + + return { success: true }; }, }); @@ -307,8 +564,12 @@ export const markUploadComplete = action({ throw new Error("Uploaded video file not found or empty."); } const contentLength = contentLengthRaw; - if (contentLength > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) { - throw new Error("Video file is too large for direct upload."); + if ( + typeof video.fileSize === "number" && + Number.isFinite(video.fileSize) && + contentLength !== video.fileSize + ) { + throw new Error("Uploaded video file size did not match the requested upload."); } const normalizedContentType = normalizeContentType( diff --git a/convex/videos.ts b/convex/videos.ts index 81377db1..ea486b54 100644 --- a/convex/videos.ts +++ b/convex/videos.ts @@ -327,6 +327,9 @@ export const setUploadInfo = internalMutation({ s3Key: v.string(), fileSize: v.number(), contentType: v.string(), + multipartUploadId: v.string(), + uploadPartSizeBytes: v.number(), + uploadTotalParts: v.number(), }, handler: async (ctx, args) => { await ctx.db.patch(args.videoId, { @@ -340,6 +343,9 @@ export const setUploadInfo = internalMutation({ uploadError: undefined, fileSize: args.fileSize, contentType: args.contentType, + multipartUploadId: args.multipartUploadId, + uploadPartSizeBytes: args.uploadPartSizeBytes, + uploadTotalParts: args.uploadTotalParts, status: "uploading", }); }, @@ -376,6 +382,9 @@ export const reconcileUploadedObjectMetadata = internalMutation({ await ctx.db.patch(args.videoId, { fileSize: actualSize, contentType: args.contentType, + multipartUploadId: undefined, + uploadPartSizeBytes: undefined, + uploadTotalParts: undefined, }); }, }); @@ -423,6 +432,9 @@ export const markAsFailed = internalMutation({ await ctx.db.patch(args.videoId, { muxAssetStatus: "errored", uploadError: args.uploadError, + multipartUploadId: undefined, + uploadPartSizeBytes: undefined, + uploadTotalParts: undefined, status: "failed", }); }, diff --git a/plans/multipart-plan.md b/plans/multipart-plan.md new file mode 100644 index 00000000..e7727f13 --- /dev/null +++ b/plans/multipart-plan.md @@ -0,0 +1,386 @@ +# Multipart Upload + Plan-Aware Limits Stack + +## Status + +- [x] Stack 1 — Base PR: multipart upload foundation +- [ ] Stack 2 — Follow-up PR: plan-aware limits + better upload errors +- [ ] Stack 3 — Final PR: copy and UI updates + +## Objective + +Roll out larger uploads in the correct order: + +1. **quietly add multipart upload support first** so the app can actually handle files above the current single-request ceiling +2. **layer plan-aware validation and better error messages on top** once the transport is capable +3. **finish with copy/UI updates** so the product accurately communicates the new limits + +This sequencing avoids promising 10GB / 50GB uploads before the upload pipeline can really support them. + +--- + +## Why this order + +The current upload flow uses a single presigned S3 `PUT` from the browser: + +- `convex/videoActions.ts` issues one upload URL +- `app/routes/dashboard/-useVideoUploadManager.ts` uploads the whole file with one `XMLHttpRequest` +- `convex/videoActions.ts` currently enforces a hard cap of `5 * GIBIBYTE` + +That means the app cannot truthfully support 10GB or 50GB uploads until the transport changes to multipart upload. + +So the stack should be: + +1. **Capability first** — multipart upload foundation +2. **Correctness second** — plan-aware size limits + better error messages +3. **Communication last** — pricing/settings/home/upload copy updates + +--- + +## Stack 1 — Base PR: multipart upload foundation + +**Status:** Implemented on the branch. + +### Landed changes + +- Replaced the dashboard uploader's single-request direct upload flow with S3 multipart upload. +- Added multipart Convex actions in `convex/videoActions.ts`: + - `createMultipartUpload` + - `getMultipartUploadPartUrls` + - `completeMultipartUpload` + - `abortMultipartUpload` +- Updated `app/routes/dashboard/-useVideoUploadManager.ts` to: + - split files into parts + - upload parts with bounded parallelism + - batch presigned part URL requests + - aggregate progress and ETA across multipart uploads + - abort in-flight uploads and clean up multipart sessions on failure/cancel +- Removed the old 5 GiB direct-upload bottleneck from the main upload path and from post-upload verification. +- Kept the existing `markUploadComplete` handoff into S3 verification and Mux ingest. + +## Goal + +Replace the current single-request direct upload flow with S3 multipart upload so files larger than 5 GiB are technically possible. + +This PR should be mostly infrastructural and intentionally light on visible product changes. + +## Scope + +### Backend + +Primary file: +- `convex/videoActions.ts` + +Add multipart actions for the upload lifecycle: + +1. `createMultipartUpload` + - verify member access to the video/project + - create the S3 multipart upload + - return enough metadata for the client to upload parts + - persist upload metadata if needed for cleanup/completion + +2. `getMultipartUploadPartUrl` + - presign an `UploadPart` request for a specific part number + - validate the caller can continue the upload + +3. `completeMultipartUpload` + - accept uploaded part metadata from the client + - complete the multipart upload in S3 + - return success metadata to the client + +4. `abortMultipartUpload` + - allow cancellation and cleanup of incomplete multipart uploads + - should be safe to call on cancellation/error paths + +Keep existing post-upload processing logic: +- `markUploadComplete` should remain the handoff into: + - object existence verification + - metadata reconciliation + - transition to `processing` + - Mux ingest kickoff + +### Frontend + +Primary file: +- `app/routes/dashboard/-useVideoUploadManager.ts` + +Replace the single XHR `PUT` flow with multipart upload logic: + +1. split each file into chunks +2. request part URLs from the backend +3. upload parts directly to S3 +4. collect `ETag` values per part +5. complete the multipart upload +6. call `markUploadComplete` + +### Upload behavior requirements + +- preserve current upload tray UX +- preserve cancellation support +- preserve progress percentages and ETA +- support aggregate progress across parts +- use limited parallelism for performance +- avoid obvious waterfalls where possible + +### Suggested implementation details + +- chunk size: something in the ~8–16 MiB range +- concurrency: small fixed parallelism, e.g. 3–5 parts at a time +- retries: optional for this base PR, but at least structure the code so retries can be added without rewriting the uploader +- cancellation: + - abort in-flight browser requests + - call `abortMultipartUpload` + - mark the upload failed/cleaned up consistently + +## Non-goals for this PR + +- no new pricing or marketing copy +- no new user-facing plan messaging +- no visible 10GB / 50GB promise yet +- no attempt to perfect all upload error copy yet + +## Acceptance criteria + +1. uploads no longer depend on a single presigned `PUT` +2. the old 5 GiB direct-upload bottleneck is removed from the main upload path +3. upload progress and cancellation still work in the dashboard upload tray +4. completed uploads still flow into the existing S3 verification + Mux ingestion path +5. small uploads continue to work reliably with no UX regression + +## Likely files touched + +- `convex/videoActions.ts` +- `app/routes/dashboard/-useVideoUploadManager.ts` +- possibly `convex/videos.ts` if extra upload metadata needs to be stored +- possibly `convex/schema.ts` if multipart state must be persisted + +--- + +## Stack 2 — Follow-up PR: plan-aware limits + better upload errors + +## Goal + +Now that multipart exists, enforce the requested per-plan file-size limits and return much better upload errors for the two important failure classes: + +1. **file too large for the plan** +2. **file would push the team over its storage allowance** + +## Requested product behavior + +- Basic plan: **max file size 10GB** +- Pro plan: **max file size 50GB** + +This PR should make those limits real in application logic. + +## Scope + +### Billing/domain constants + +Primary file: +- `convex/billingHelpers.ts` + +Add a new source of truth for per-file upload limits: + +- `TEAM_PLAN_MAX_FILE_SIZE_BYTES` + - `basic: 10 * GIBIBYTE` + - `pro: 50 * GIBIBYTE` + +Keep existing storage limits as separate concerns: +- `TEAM_PLAN_STORAGE_LIMIT_BYTES` + - basic storage remains 100GB + - pro storage remains 1TB + +Consider adding small shared helpers for: +- formatting bytes into readable GB/TB strings +- formatting plan labels consistently + +### Plan-aware file size validation + +Primary file: +- `convex/videos.ts` + +In `create(...)`: +- validate the incoming file size against the team’s plan-specific max file size before upload begins +- continue to validate storage availability with `assertTeamCanStoreBytes(...)` +- fail early so the user doesn’t start a huge upload only to be rejected later + +### Final backend sanity check after upload + +Primary file: +- `convex/videoActions.ts` + +In the post-upload verification path: +- verify the uploaded object actually exists +- verify the final object size is valid +- verify the uploaded object is still within the team’s per-file plan limit +- keep content-type validation in place + +This ensures the client is not the only layer enforcing limits. + +### Better storage-limit errors + +Primary file: +- `convex/billingHelpers.ts` + +Improve `assertTeamCanStoreBytes(...)` to produce richer, actionable messages. + +Instead of a generic message like: +- `Storage limit reached for the basic plan. Upgrade to continue uploading.` + +Prefer messages that include: +- plan name +- current storage usage +- storage limit +- incoming file size +- recommended action + +Example target: +- `This upload would exceed your team's Basic plan storage limit. You're using 96 GB of 100 GB, and this file is 8 GB. Upgrade to Pro or delete old videos to free up space.` + +### Better “file too large” errors + +Return plan-aware messages for file size failures. + +Example targets: + +Basic: +- `This file is too large for the Basic plan. Basic supports files up to 10 GB. Upgrade to Pro for files up to 50 GB.` + +Pro: +- `This file is too large for the Pro plan. Pro supports files up to 50 GB.` + +### Frontend error handling + +Primary file: +- `app/routes/dashboard/-useVideoUploadManager.ts` + +Improve the upload manager so upload tray errors are clearer and less raw. + +Potential improvements: +- normalize backend errors into cleaner display text when needed +- optionally add client-side prechecks before starting multipart upload for instant feedback +- keep inline upload errors attached to the relevant upload item + +Client-side prechecks are useful for convenience, but backend validation remains the source of truth. + +## Acceptance criteria + +1. Basic users cannot upload files over 10GB +2. Pro users cannot upload files over 50GB +3. users get a clearly different error for: + - per-file plan max violation + - total storage limit violation +4. post-upload verification still rejects invalid uploads even if the client misbehaves +5. upload tray surfaces actionable errors instead of generic failures + +## Likely files touched + +- `convex/billingHelpers.ts` +- `convex/videos.ts` +- `convex/videoActions.ts` +- `app/routes/dashboard/-useVideoUploadManager.ts` +- maybe `src/components/upload/UploadProgress.tsx` if tiny copy polish is needed + +--- + +## Stack 3 — Final PR: copy and UI updates + +## Goal + +Update visible plan copy so the product accurately communicates the new per-file upload limits and aligns with the backend behavior. + +This PR should mostly be presentation and messaging. + +## Scope + +### Billing/settings UI + +Primary file: +- `app/routes/dashboard/-settings.tsx` + +Update plan cards/details to show both: +- storage included +- max file size per plan + +Target messaging: +- Basic: `100GB storage` + `Max file size 10GB` +- Pro: `1TB storage` + `Max file size 50GB` + +### Pricing page + +Primary file: +- `app/routes/-pricing.tsx` + +Update pricing cards to include the new per-file max. + +### Homepage pricing section + +Primary file: +- `app/routes/-home.tsx` + +Update the homepage pricing cards / structured copy so it also reflects the new file-size limits. + +### Optional upload entry-point hints + +Possible file: +- `src/components/upload/DropZone.tsx` + +Optional lightweight UX improvement: +- show supported formats more explicitly +- optionally mention that max file size depends on plan, or show the concrete limit if team billing context is available + +This should stay small and not create extra complexity unless the data is already easy to access. + +## Acceptance criteria + +1. settings/billing UI reflects the new per-file limits +2. pricing page reflects the new per-file limits +3. homepage pricing copy reflects the new per-file limits +4. no visible copy conflicts remain between product marketing and actual app behavior + +## Likely files touched + +- `app/routes/dashboard/-settings.tsx` +- `app/routes/-pricing.tsx` +- `app/routes/-home.tsx` +- optionally `src/components/upload/DropZone.tsx` + +--- + +## Cross-stack notes + +## Data and terminology + +The codebase currently uses binary units: +- `GIBIBYTE = 1024 ** 3` +- `TEBIBYTE = 1024 ** 4` + +UI may still say `GB` / `TB` for simplicity, but implementation should remain internally consistent. + +## Important separation of concerns + +There are now two different kinds of size limits: + +1. **Per-file limit** + - Basic: 10GB + - Pro: 50GB + +2. **Total team storage limit** + - Basic: 100GB + - Pro: 1TB + +Error messages and UI copy should make these feel clearly different. + +## Rollout recommendation + +1. land **Stack 1** first with minimal product noise +2. stack **Stack 2** on top so the new capability is enforced correctly +3. stack **Stack 3** last so public and in-app copy only changes once the system is truly ready + +## Success criteria for the full stack + +1. uploads above 5 GiB are technically supported by the transport layer +2. Basic can upload up to 10GB files +3. Pro can upload up to 50GB files +4. users get clear, actionable upload errors +5. billing/pricing/home surfaces all reflect the same limits +6. upload UX remains fast and cancelable with no major regression From 04cd28d1c438ac5b9319ea110719c1d48daaffb0 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Tue, 7 Apr 2026 14:56:08 -0700 Subject: [PATCH 2/6] Add plan-aware upload limits --- .../dashboard/-useVideoUploadManager.ts | 14 ++++- convex/billingHelpers.ts | 60 ++++++++++++++++++- convex/videoActions.ts | 13 ++-- convex/videos.ts | 11 +++- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/app/routes/dashboard/-useVideoUploadManager.ts b/app/routes/dashboard/-useVideoUploadManager.ts index cfc2fb0a..0553c75e 100644 --- a/app/routes/dashboard/-useVideoUploadManager.ts +++ b/app/routes/dashboard/-useVideoUploadManager.ts @@ -34,6 +34,18 @@ function createUploadId() { return Math.random().toString(36).slice(2); } +function getUploadErrorMessage(error: unknown) { + if (!(error instanceof Error)) return "Upload failed"; + + const uncaughtErrorMatch = error.message.match(/Uncaught Error:\s*([^\n]+)/); + if (uncaughtErrorMatch?.[1]) return uncaughtErrorMatch[1].trim(); + + const serverErrorMatch = error.message.match(/Server Error\s*\n\s*(.+)$/s); + if (serverErrorMatch?.[1]) return serverErrorMatch[1].trim(); + + return error.message || "Upload failed"; +} + function createMultipartParts(file: File, partSizeBytes: number) { const parts: MultipartUploadPart[] = []; @@ -365,7 +377,7 @@ export function useVideoUploadManager() { }).catch(console.error); } - const errorMessage = error instanceof Error ? error.message : "Upload failed"; + const errorMessage = getUploadErrorMessage(error); setUploads((prev) => prev.map((upload) => diff --git a/convex/billingHelpers.ts b/convex/billingHelpers.ts index 6c85aecf..722645b5 100644 --- a/convex/billingHelpers.ts +++ b/convex/billingHelpers.ts @@ -5,6 +5,7 @@ import { MutationCtx, QueryCtx } from "./_generated/server"; export type TeamPlan = "basic" | "pro"; const GIBIBYTE = 1024 ** 3; +const TEBIBYTE = 1024 ** 4; export const TEAM_PLAN_MONTHLY_PRICE_USD: Record = { basic: 5, @@ -13,13 +14,33 @@ export const TEAM_PLAN_MONTHLY_PRICE_USD: Record = { export const TEAM_PLAN_STORAGE_LIMIT_BYTES: Record = { basic: 100 * GIBIBYTE, - pro: 1024 * GIBIBYTE, + pro: TEBIBYTE, +}; + +export const TEAM_PLAN_MAX_FILE_SIZE_BYTES: Record = { + basic: 10 * GIBIBYTE, + pro: 50 * GIBIBYTE, }; function hasText(value: string | undefined | null): value is string { return typeof value === "string" && value.trim().length > 0; } +export function formatPlanLabel(plan: TeamPlan) { + return plan === "basic" ? "Basic" : "Pro"; +} + +export function formatBytesForBilling(bytes: number) { + const safeBytes = Number.isFinite(bytes) ? Math.max(0, bytes) : 0; + if (safeBytes >= TEBIBYTE) { + const value = safeBytes / TEBIBYTE; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} TB`; + } + + const value = safeBytes / GIBIBYTE; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} GB`; +} + export function normalizeStoredTeamPlan(plan: string): TeamPlan { if (plan === "pro" || plan === "team") return "pro"; return "basic"; @@ -137,8 +158,13 @@ export async function assertTeamCanStoreBytes( const requestedBytes = Number.isFinite(incomingBytes) ? Math.max(0, incomingBytes) : 0; if (storageUsedBytes + requestedBytes > storageLimitBytes) { + const planLabel = formatPlanLabel(state.plan); + const actionCopy = + state.plan === "basic" + ? "Upgrade to Pro or delete old videos to free up space." + : "Delete old videos to free up space."; throw new Error( - `Storage limit reached for the ${state.plan} plan. Upgrade to continue uploading.`, + `This upload would exceed your team's ${planLabel} plan storage limit. You're using ${formatBytesForBilling(storageUsedBytes)} of ${formatBytesForBilling(storageLimitBytes)}, and this file is ${formatBytesForBilling(requestedBytes)}. ${actionCopy}`, ); } @@ -148,3 +174,33 @@ export async function assertTeamCanStoreBytes( storageLimitBytes, }; } + +export async function assertTeamCanUploadFileBytes( + ctx: BillingCtx, + teamId: Id<"teams">, + fileSizeBytes: number, +) { + const state = await assertTeamHasActiveSubscription(ctx, teamId); + const requestedBytes = Number.isFinite(fileSizeBytes) + ? Math.max(0, fileSizeBytes) + : 0; + const maxFileSizeBytes = TEAM_PLAN_MAX_FILE_SIZE_BYTES[state.plan]; + + if (requestedBytes > maxFileSizeBytes) { + const planLabel = formatPlanLabel(state.plan); + const maxFileSizeLabel = formatBytesForBilling(maxFileSizeBytes); + const upgradeCopy = + state.plan === "basic" + ? " Upgrade to Pro for files up to 50 GB." + : ""; + + throw new Error( + `This file is too large for the ${planLabel} plan. ${planLabel} supports files up to ${maxFileSizeLabel}.${upgradeCopy}`, + ); + } + + return { + ...state, + maxFileSizeBytes, + }; +} diff --git a/convex/videoActions.ts b/convex/videoActions.ts index 01574258..c0675a0c 100644 --- a/convex/videoActions.ts +++ b/convex/videoActions.ts @@ -273,13 +273,14 @@ function shouldDeleteUploadedObjectOnFailure(error: unknown): boolean { return false; } + const message = error.message.toLowerCase(); return ( - error.message.includes("Unsupported video format") || - error.message.includes("Video file is too large") || - error.message.includes("Uploaded video file not found") || - error.message.includes("Uploaded video file size did not match") || - error.message.includes("Multipart upload completed with") || - error.message.includes("Storage limit reached") + message.includes("unsupported video format") || + message.includes("file is too large") || + message.includes("uploaded video file not found") || + message.includes("uploaded video file size did not match") || + message.includes("multipart upload completed with") || + message.includes("storage limit") ); } diff --git a/convex/videos.ts b/convex/videos.ts index ea486b54..df99e86c 100644 --- a/convex/videos.ts +++ b/convex/videos.ts @@ -4,7 +4,10 @@ import { identityName, requireProjectAccess, requireVideoAccess } from "./auth"; import { Id } from "./_generated/dataModel"; import { generateUniqueToken } from "./security"; import { resolveActiveShareGrant } from "./shareAccess"; -import { assertTeamCanStoreBytes } from "./billingHelpers"; +import { + assertTeamCanStoreBytes, + assertTeamCanUploadFileBytes, +} from "./billingHelpers"; const workflowStatusValidator = v.union( v.literal("review"), @@ -59,7 +62,9 @@ export const create = mutation({ }, handler: async (ctx, args) => { const { user, project } = await requireProjectAccess(ctx, args.projectId, "member"); - await assertTeamCanStoreBytes(ctx, project.teamId, args.fileSize ?? 0); + const fileSize = args.fileSize ?? 0; + await assertTeamCanUploadFileBytes(ctx, project.teamId, fileSize); + await assertTeamCanStoreBytes(ctx, project.teamId, fileSize); const publicId = await generatePublicId(ctx); const videoId = await ctx.db.insert("videos", { @@ -375,6 +380,8 @@ export const reconcileUploadedObjectMetadata = internalMutation({ const actualSize = Number.isFinite(args.fileSize) ? Math.max(0, args.fileSize) : 0; const sizeDelta = actualSize - declaredSize; + await assertTeamCanUploadFileBytes(ctx, project.teamId, actualSize); + if (sizeDelta > 0) { await assertTeamCanStoreBytes(ctx, project.teamId, sizeDelta); } From 1cf18ef96d40c21f68db49249829ef005079b77a Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Tue, 7 Apr 2026 14:56:51 -0700 Subject: [PATCH 3/6] Update upload limit copy --- app/routes/-home.tsx | 10 ++++++---- app/routes/-pricing.tsx | 14 +++++++++++--- app/routes/dashboard/-settings.tsx | 23 ++++++++++++++++++++--- src/components/upload/DropZone.tsx | 2 +- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/app/routes/-home.tsx b/app/routes/-home.tsx index 7c1ffbe1..0a72265e 100644 --- a/app/routes/-home.tsx +++ b/app/routes/-home.tsx @@ -259,7 +259,8 @@ export default function Homepage() {
  • Unlimited seats
  • Unlimited projects
  • Unlimited clients
  • -
  • 100GB Storage
  • +
  • 100GB storage
  • +
  • 10GB max file size
  • Get Basic @@ -278,7 +279,8 @@ export default function Homepage() {
  • Unlimited seats
  • Unlimited projects
  • Unlimited clients
  • -
  • 1TB Storage (Whoa)
  • +
  • 1TB storage
  • +
  • 50GB max file size
  • Get Pro @@ -326,7 +328,7 @@ export default function Homepage() { price: "5.00", priceCurrency: "USD", description: - "Unlimited seats, unlimited projects, unlimited clients, 100GB storage", + "Unlimited seats, unlimited projects, unlimited clients, 100GB storage, 10GB max file size", }, { "@type": "Offer", @@ -334,7 +336,7 @@ export default function Homepage() { price: "25.00", priceCurrency: "USD", description: - "Unlimited seats, unlimited projects, unlimited clients, 1TB storage", + "Unlimited seats, unlimited projects, unlimited clients, 1TB storage, 50GB max file size", }, ], creator: { diff --git a/app/routes/-pricing.tsx b/app/routes/-pricing.tsx index 30ff30a4..eb8859de 100644 --- a/app/routes/-pricing.tsx +++ b/app/routes/-pricing.tsx @@ -48,7 +48,11 @@ export default function PricingPage() {
  • {" "} - 100GB Storage + 100GB storage +
  • +
  • + {" "} + 10GB max file size
  • @@ -91,8 +95,12 @@ export default function PricingPage() { Unlimited clients
  • - 1TB - Storage + {" "} + 1TB storage +
  • +
  • + {" "} + 50GB max file size
  • diff --git a/app/routes/dashboard/-settings.tsx b/app/routes/dashboard/-settings.tsx index 85cb6bbf..6b980a3d 100644 --- a/app/routes/dashboard/-settings.tsx +++ b/app/routes/dashboard/-settings.tsx @@ -25,6 +25,7 @@ const BILLING_PLANS: Record< label: string; monthlyPriceUsd: number; storageLimitBytes: number; + maxFileSizeBytes: number; seats: string; } > = { @@ -32,12 +33,14 @@ const BILLING_PLANS: Record< label: "Basic", monthlyPriceUsd: 5, storageLimitBytes: 100 * GIBIBYTE, + maxFileSizeBytes: 10 * GIBIBYTE, seats: "Unlimited", }, pro: { label: "Pro", monthlyPriceUsd: 25, storageLimitBytes: TEBIBYTE, + maxFileSizeBytes: 50 * GIBIBYTE, seats: "Unlimited", }, }; @@ -52,8 +55,13 @@ function normalizeTeamPlan(plan: string): BillingPlan { } function formatBytes(bytes: number): string { - if (bytes >= TEBIBYTE) return `${(bytes / TEBIBYTE).toFixed(1)} TB`; - return `${(bytes / GIBIBYTE).toFixed(1)} GB`; + if (bytes >= TEBIBYTE) { + const value = bytes / TEBIBYTE; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} TB`; + } + + const value = bytes / GIBIBYTE; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} GB`; } function formatUtcDateFromUnixSeconds(unixSeconds: number): string { @@ -324,7 +332,7 @@ export default function TeamSettingsPage() { {/* ── Stats strip ── */} -
    +

    Plan @@ -373,6 +381,14 @@ export default function TeamSettingsPage() { {planConfig.seats}

    +
    +

    + Max file +

    +

    + {formatBytes(planConfig.maxFileSizeBytes)} +

    +
    {/* ── Two-column: Plans + Members ── */} @@ -425,6 +441,7 @@ export default function TeamSettingsPage() { >

    {config.seats} seats

    {formatBytes(config.storageLimitBytes)} storage

    +

    {formatBytes(config.maxFileSizeBytes)} max file size

    {isOwner && !hasActiveSubscription && (