From 3a63240f462b2f904609259f7edc5a2a1a71f3a5 Mon Sep 17 00:00:00 2001 From: Stanley Cheung Date: Tue, 24 Feb 2026 12:58:50 +0800 Subject: [PATCH] feat(media): validate upload file size limits by media type --- apps/web/.env.example | 3 + apps/web/src/env/web.ts | 14 +++- .../lib/media/file-size-validation.test.ts | 40 +++++++++++ .../web/src/lib/media/file-size-validation.ts | 67 +++++++++++++++++++ apps/web/src/lib/media/processing.ts | 11 +++ 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/media/file-size-validation.test.ts create mode 100644 apps/web/src/lib/media/file-size-validation.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index 7b7b9e9b..230cc774 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,2 +1,5 @@ # Public NEXT_PUBLIC_API_URL=https://localhost:3000/api +NEXT_PUBLIC_MAX_IMAGE_UPLOAD_MB=50 +NEXT_PUBLIC_MAX_VIDEO_UPLOAD_MB=500 +NEXT_PUBLIC_MAX_AUDIO_UPLOAD_MB=100 diff --git a/apps/web/src/env/web.ts b/apps/web/src/env/web.ts index e3e5bcaf..3bf90393 100644 --- a/apps/web/src/env/web.ts +++ b/apps/web/src/env/web.ts @@ -2,10 +2,22 @@ import { z } from "zod"; const webEnvSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + NEXT_PUBLIC_MAX_IMAGE_UPLOAD_MB: z.coerce.number().positive().max(200).default(50), + NEXT_PUBLIC_MAX_VIDEO_UPLOAD_MB: z.coerce + .number() + .positive() + .max(2048) + .default(500), + NEXT_PUBLIC_MAX_AUDIO_UPLOAD_MB: z.coerce.number().positive().max(500).default(100), }); export const webEnv = webEnvSchema.parse({ ...process.env, NODE_ENV: process.env.NODE_ENV ?? "development", + NEXT_PUBLIC_MAX_IMAGE_UPLOAD_MB: + process.env.NEXT_PUBLIC_MAX_IMAGE_UPLOAD_MB ?? "50", + NEXT_PUBLIC_MAX_VIDEO_UPLOAD_MB: + process.env.NEXT_PUBLIC_MAX_VIDEO_UPLOAD_MB ?? "500", + NEXT_PUBLIC_MAX_AUDIO_UPLOAD_MB: + process.env.NEXT_PUBLIC_MAX_AUDIO_UPLOAD_MB ?? "100", }); - diff --git a/apps/web/src/lib/media/file-size-validation.test.ts b/apps/web/src/lib/media/file-size-validation.test.ts new file mode 100644 index 00000000..4c27f658 --- /dev/null +++ b/apps/web/src/lib/media/file-size-validation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { + formatFileSize, + getFileSizeLimitBytes, + getFileSizeLimitError, +} from "@/lib/media/file-size-validation"; + +const MB = 1024 * 1024; + +describe("media file size validation", () => { + test("uses the configured default upload limits", () => { + expect(getFileSizeLimitBytes({ mediaType: "image" })).toBe(50 * MB); + expect(getFileSizeLimitBytes({ mediaType: "video" })).toBe(500 * MB); + expect(getFileSizeLimitBytes({ mediaType: "audio" })).toBe(100 * MB); + }); + + test("returns no error when file size is at the limit", () => { + expect( + getFileSizeLimitError({ + fileName: "clip.mp4", + fileSize: 500 * MB, + mediaType: "video", + }), + ).toBeNull(); + }); + + test("returns a clear message when file exceeds limit", () => { + expect( + getFileSizeLimitError({ + fileName: "huge.wav", + fileSize: 101 * MB, + mediaType: "audio", + }), + ).toBe("huge.wav is 101 MB. audio files must be 100 MB or smaller."); + }); + + test("formats small values using KB", () => { + expect(formatFileSize({ bytes: 1500 })).toBe("1.5 KB"); + }); +}); diff --git a/apps/web/src/lib/media/file-size-validation.ts b/apps/web/src/lib/media/file-size-validation.ts new file mode 100644 index 00000000..17238555 --- /dev/null +++ b/apps/web/src/lib/media/file-size-validation.ts @@ -0,0 +1,67 @@ +import { webEnv } from "@/env/web"; +import type { MediaType } from "@/types/assets"; + +const MB_IN_BYTES = 1024 * 1024; + +const LIMIT_CAP_MB: Record = { + image: 200, + video: 2048, + audio: 500, +}; + +const LIMIT_DEFAULT_MB: Record = { + image: 50, + video: 500, + audio: 100, +}; + +const LIMITS_MB: Record = { + image: Math.min(webEnv.NEXT_PUBLIC_MAX_IMAGE_UPLOAD_MB, LIMIT_CAP_MB.image), + video: Math.min(webEnv.NEXT_PUBLIC_MAX_VIDEO_UPLOAD_MB, LIMIT_CAP_MB.video), + audio: Math.min(webEnv.NEXT_PUBLIC_MAX_AUDIO_UPLOAD_MB, LIMIT_CAP_MB.audio), +}; + +export function getFileSizeLimitBytes({ + mediaType, +}: { + mediaType: MediaType; +}): number { + return LIMITS_MB[mediaType] * MB_IN_BYTES; +} + +export function formatFileSize({ + bytes, +}: { + bytes: number; +}): string { + if (bytes < MB_IN_BYTES) { + const kb = bytes / 1024; + return `${kb.toFixed(kb >= 100 ? 0 : 1)} KB`; + } + + const mb = bytes / MB_IN_BYTES; + if (mb < 1024) { + return `${mb.toFixed(mb >= 100 ? 0 : 1)} MB`; + } + + const gb = mb / 1024; + return `${gb.toFixed(gb >= 100 ? 0 : 1)} GB`; +} + +export function getFileSizeLimitError({ + fileName, + fileSize, + mediaType, +}: { + fileName: string; + fileSize: number; + mediaType: MediaType; +}): string | null { + const limitBytes = getFileSizeLimitBytes({ mediaType }); + if (fileSize <= limitBytes) return null; + + return `${fileName} is ${formatFileSize({ bytes: fileSize })}. ${mediaType} files must be ${formatFileSize({ bytes: limitBytes })} or smaller.`; +} + +export const mediaUploadLimitDefaultsMb = LIMIT_DEFAULT_MB; +export const mediaUploadLimitCapsMb = LIMIT_CAP_MB; diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts index fbe29acd..fad07208 100644 --- a/apps/web/src/lib/media/processing.ts +++ b/apps/web/src/lib/media/processing.ts @@ -1,6 +1,7 @@ import { toast } from "sonner"; import type { MediaAsset } from "@/types/assets"; import { getMediaTypeFromFile } from "@/lib/media/media-utils"; +import { getFileSizeLimitError } from "@/lib/media/file-size-validation"; import { getVideoInfo } from "./mediabunny"; import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; @@ -166,6 +167,16 @@ export async function processMediaAssets({ continue; } + const fileSizeLimitError = getFileSizeLimitError({ + fileName: file.name, + fileSize: file.size, + mediaType: fileType, + }); + if (fileSizeLimitError) { + toast.error(fileSizeLimitError); + continue; + } + const url = URL.createObjectURL(file); let thumbnailUrl: string | undefined; let duration: number | undefined;