feat(media): validate upload file size limits by media type

This commit is contained in:
Stanley Cheung 2026-02-24 12:58:50 +08:00
parent 227be9ac41
commit 3a63240f46
5 changed files with 134 additions and 1 deletions

View File

@ -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

View File

@ -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",
});

View File

@ -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");
});
});

View File

@ -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<MediaType, number> = {
image: 200,
video: 2048,
audio: 500,
};
const LIMIT_DEFAULT_MB: Record<MediaType, number> = {
image: 50,
video: 500,
audio: 100,
};
const LIMITS_MB: Record<MediaType, number> = {
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;

View File

@ -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;