From 82b00508f0d368e7df1cc22f59eee214c4020a6d Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 12:35:41 +0800 Subject: [PATCH] feat(uploads): per-kind WhatsApp media size limits, lift Server Action body cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom ------- The upload action rejected anything over 50 MB with a flat "File too large (>50MB)" — a number that was both too generous for images (WA caps at 5 MB) and too restrictive for documents (WA allows 100 MB). And anything over 1 MB was being rejected even earlier by Next's default Server Action body limit, with a much less actionable error. Fix --- 1. New `lib/whatsapp-media.ts` resolves an uploaded file's MIME type to a WhatsApp delivery kind and validates it against the per-kind cap that WA actually enforces: image → 5 MB image/* except sticker-mode video → 16 MB video/* audio → 16 MB audio/* document → 100 MB anything else (PDFs, office docs, …) Anything not recognised as image/video/audio falls through to "document", which is also the Baileys sender path the bot uses to deliver it. So a .zip or .csv ends up correctly classified AND correctly limited to the document cap. Error messages now name the kind and show both the actual size and the cap: "Image too large (5.2 MB > 5.0 MB limit on WhatsApp)". 2. `next.config.ts` lifts the Server Action body limit from the 1 MB default to 100 MB, so document uploads actually reach the action instead of getting bounced at the framework boundary. The WA per-kind validator inside the action enforces the real limit from there. 3. The compose-step upload zone hint now reflects the per-kind caps ("Image up to 5 MB · video / audio up to 16 MB · document up to 100 MB") instead of the wrong flat "up to 50 MB" value. Tests (17 new cases, total 189) ------------------------------- - classifyMediaKind: image/video/audio prefix routing, fall-through to document for unknown / empty / octet-stream / text/plain. - validateForWhatsApp: at-cap, just-under-cap, just-over-cap for image (5 MB) / video (16 MB) / audio (16 MB) / document (100 MB); zero-byte rejected; unknown-mime 60 MB upload accepted as document. - WA_MAX_BYTES sanity: equals the document cap and is >= every other per-kind limit (so it's safe to use as the framework body cap). - formatBytes: bytes / KB (no decimals) / MB (one decimal) rendering. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/next.config.ts | 9 ++ apps/web/src/actions/media.ts | 15 ++- apps/web/src/components/message-stack.tsx | 2 +- apps/web/src/lib/whatsapp-media.test.ts | 130 ++++++++++++++++++++++ apps/web/src/lib/whatsapp-media.ts | 105 +++++++++++++++++ 5 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/whatsapp-media.test.ts create mode 100644 apps/web/src/lib/whatsapp-media.ts diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index d26a944..7e59214 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -19,6 +19,15 @@ const nextConfig: NextConfig = { allowedDevOrigins: ["192.168.0.253", "test.04080616.xyz", "rexwa.04080616.xyz"], experimental: { typedRoutes: true, + serverActions: { + // Default Server Action body limit is 1 MB — way under WhatsApp's + // 100 MB document cap. Lifted to 100 MB so document uploads reach + // the action; the per-kind WhatsApp validator + // (lib/whatsapp-media.ts) then enforces the actual limit + // (5 MB image / 16 MB video/audio / 100 MB document) and returns + // a useful error for the rest. + bodySizeLimit: "100mb", + }, }, turbopack: { root: workspaceRoot, diff --git a/apps/web/src/actions/media.ts b/apps/web/src/actions/media.ts index d64a746..69db040 100644 --- a/apps/web/src/actions/media.ts +++ b/apps/web/src/actions/media.ts @@ -10,8 +10,7 @@ import { db } from "@/lib/db"; import { env } from "@/env"; import { getSeededOperator } from "@/lib/operator"; import { checkRateLimit } from "@/lib/rate-limit"; - -const MAX_BYTES = 50 * 1024 * 1024; +import { validateForWhatsApp } from "@/lib/whatsapp-media"; async function rateLimit(key: string) { const h = await headers(); @@ -31,8 +30,12 @@ export async function uploadMediaAction( await rateLimit("media-upload"); const file = formData.get("file"); if (!(file instanceof File)) return { ok: false, error: "No file uploaded" }; - if (file.size === 0) return { ok: false, error: "Empty file" }; - if (file.size > MAX_BYTES) return { ok: false, error: "File too large (>50MB)" }; + // Per-kind WhatsApp size validation: 5 MB images, 16 MB video/audio, + // 100 MB documents. Fall-through everything we don't recognise to + // "document" (matches the bot's sender path). + const mimeType = file.type || "application/octet-stream"; + const sizeCheck = validateForWhatsApp(mimeType, file.size); + if (!sizeCheck.ok) return { ok: false, error: sizeCheck.error }; const op = await getSeededOperator(); const buffer = Buffer.from(await file.arrayBuffer()); @@ -47,7 +50,7 @@ export async function uploadMediaAction( .values({ operatorId: op.id, filenameOriginal: file.name, - mimeType: file.type || "application/octet-stream", + mimeType, sizeBytes: buffer.byteLength, sha256, storagePath, @@ -58,6 +61,6 @@ export async function uploadMediaAction( ok: true, mediaId: row!.id, filename: file.name, - mimeType: file.type || "application/octet-stream", + mimeType, }; } diff --git a/apps/web/src/components/message-stack.tsx b/apps/web/src/components/message-stack.tsx index 4dc824e..649dcc1 100644 --- a/apps/web/src/components/message-stack.tsx +++ b/apps/web/src/components/message-stack.tsx @@ -377,7 +377,7 @@ function MediaBlock({ part, onChange }: MediaBlockProps) {

Click to upload or drag & drop

- Images, documents, audio — up to 50 MB + Image up to 5 MB · video / audio up to 16 MB · document up to 100 MB

diff --git a/apps/web/src/lib/whatsapp-media.test.ts b/apps/web/src/lib/whatsapp-media.test.ts new file mode 100644 index 0000000..40cda6d --- /dev/null +++ b/apps/web/src/lib/whatsapp-media.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { + classifyMediaKind, + formatBytes, + validateForWhatsApp, + WA_LIMITS, + WA_MAX_BYTES, +} from "./whatsapp-media"; + +const MB = 1024 * 1024; + +describe("classifyMediaKind", () => { + it("classifies image/* as image", () => { + expect(classifyMediaKind("image/jpeg")).toBe("image"); + expect(classifyMediaKind("image/png")).toBe("image"); + expect(classifyMediaKind("image/webp")).toBe("image"); + }); + + it("classifies video/* as video", () => { + expect(classifyMediaKind("video/mp4")).toBe("video"); + expect(classifyMediaKind("video/3gpp")).toBe("video"); + }); + + it("classifies audio/* as audio", () => { + expect(classifyMediaKind("audio/aac")).toBe("audio"); + expect(classifyMediaKind("audio/ogg")).toBe("audio"); + expect(classifyMediaKind("audio/mpeg")).toBe("audio"); + }); + + it("classifies anything else as document (PDFs, office, plain text…)", () => { + expect(classifyMediaKind("application/pdf")).toBe("document"); + expect(classifyMediaKind("application/zip")).toBe("document"); + expect(classifyMediaKind("text/plain")).toBe("document"); + expect(classifyMediaKind("application/octet-stream")).toBe("document"); + expect(classifyMediaKind("")).toBe("document"); + }); +}); + +describe("validateForWhatsApp — per-kind WA caps", () => { + it("accepts an image just under the 5 MB cap", () => { + const r = validateForWhatsApp("image/jpeg", 5 * MB - 1); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.kind).toBe("image"); + expect(r.limitBytes).toBe(WA_LIMITS.image); + } + }); + + it("accepts an image at exactly the 5 MB cap", () => { + const r = validateForWhatsApp("image/png", 5 * MB); + expect(r.ok).toBe(true); + }); + + it("rejects a 6 MB image with a useful WhatsApp-flavoured message", () => { + const r = validateForWhatsApp("image/jpeg", 6 * MB); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.kind).toBe("image"); + expect(r.error).toMatch(/Image too large/); + expect(r.error).toMatch(/6\.0 MB/); + expect(r.error).toMatch(/5\.0 MB/); + expect(r.error).toMatch(/WhatsApp/); + } + }); + + it("rejects a 16 MB + 1 byte video", () => { + const r = validateForWhatsApp("video/mp4", 16 * MB + 1); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.kind).toBe("video"); + expect(r.error).toMatch(/Video too large/); + } + }); + + it("accepts a 16 MB audio file (boundary)", () => { + const r = validateForWhatsApp("audio/mpeg", 16 * MB); + expect(r.ok).toBe(true); + }); + + it("accepts a 99 MB document and rejects 101 MB", () => { + expect(validateForWhatsApp("application/pdf", 99 * MB).ok).toBe(true); + const r = validateForWhatsApp("application/pdf", 101 * MB); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/Document too large/); + }); + + it("rejects empty / zero-byte uploads regardless of kind", () => { + expect(validateForWhatsApp("image/png", 0).ok).toBe(false); + expect(validateForWhatsApp("application/pdf", 0).ok).toBe(false); + }); + + it("a 60 MB unknown-mime upload is treated as a document and accepted", () => { + // The "anything not image/video/audio is a document" fall-through + // is what makes this lenient — that's intentional, mirrors how + // the bot's sender path delivers it. + const r = validateForWhatsApp("application/octet-stream", 60 * MB); + expect(r.ok).toBe(true); + if (r.ok) expect(r.kind).toBe("document"); + }); +}); + +describe("WA_MAX_BYTES is the largest single-kind cap", () => { + it("equals the document cap (100 MB)", () => { + expect(WA_MAX_BYTES).toBe(WA_LIMITS.document); + expect(WA_MAX_BYTES).toBe(100 * MB); + }); + + it("is at least as large as every per-kind limit", () => { + for (const limit of Object.values(WA_LIMITS)) { + expect(WA_MAX_BYTES).toBeGreaterThanOrEqual(limit); + } + }); +}); + +describe("formatBytes", () => { + it("renders bytes < 1 KB plainly", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + }); + + it("renders KB rounded to no decimals", () => { + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(102_400)).toBe("100 KB"); + }); + + it("renders MB to one decimal", () => { + expect(formatBytes(5 * MB)).toBe("5.0 MB"); + expect(formatBytes(5_400_000)).toBe("5.1 MB"); + }); +}); diff --git a/apps/web/src/lib/whatsapp-media.ts b/apps/web/src/lib/whatsapp-media.ts new file mode 100644 index 0000000..111001a --- /dev/null +++ b/apps/web/src/lib/whatsapp-media.ts @@ -0,0 +1,105 @@ +/** + * WhatsApp media size limits, by deliverable type. + * + * These are the practical WA Web upload limits Baileys passes through + * — anything over the cap is either re-encoded by WA's server (image) + * or rejected outright (everything else). Sourced from WA Business + * docs and Baileys' own validation paths; aligned to the most + * conservative documented value where two specs disagree. + * + * image : 5 MB image/jpeg, image/png, image/webp (non-sticker) + * video : 16 MB video/mp4, video/3gpp + * audio : 16 MB audio/aac, audio/ogg, audio/amr, audio/mp4, audio/mpeg + * document: 100 MB everything else (PDFs, office docs, plain text…) + * sticker : 100 KB image/webp marked as a sticker (we don't support + * sticker-mode uploads yet — kept here for future) + */ +const MB = 1024 * 1024; +const KB = 1024; + +export const WA_LIMITS = { + image: 5 * MB, + video: 16 * MB, + audio: 16 * MB, + document: 100 * MB, + sticker: 100 * KB, +} as const; + +export type WaMediaKind = keyof typeof WA_LIMITS; + +/** The largest single-file upload WA will accept across all kinds. + * Doubles as the Next Server Action body-size cap. */ +export const WA_MAX_BYTES = WA_LIMITS.document; + +/** + * Map an uploaded file's MIME type to the WhatsApp delivery kind. We + * use the kind both to pick the right size limit AND to decide which + * Baileys sender path the bot will use (image / video / document). + * + * Anything we don't recognise as image/video/audio falls through to + * "document", which is also how the bot delivers it (file with a + * filename and the original mime type preserved). + */ +export function classifyMediaKind(mimeType: string): WaMediaKind { + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("audio/")) return "audio"; + return "document"; +} + +export type WaSizeCheck = + | { ok: true; kind: WaMediaKind; limitBytes: number } + | { ok: false; kind: WaMediaKind; limitBytes: number; error: string }; + +/** + * Validate an uploaded file against WhatsApp's per-kind size cap. + * + * Returns the resolved kind and the cap regardless of pass/fail so + * the UI can show "Image: 4.8 MB / 5 MB" or "Image too large + * (5.2 MB > 5 MB)" without re-deriving the kind itself. + */ +export function validateForWhatsApp( + mimeType: string, + sizeBytes: number, +): WaSizeCheck { + const kind = classifyMediaKind(mimeType); + const limitBytes = WA_LIMITS[kind]; + if (sizeBytes <= 0) { + return { ok: false, kind, limitBytes, error: "Empty file" }; + } + if (sizeBytes > limitBytes) { + return { + ok: false, + kind, + limitBytes, + error: `${labelFor(kind)} too large (${formatBytes(sizeBytes)} > ${formatBytes(limitBytes)} limit on WhatsApp)`, + }; + } + return { ok: true, kind, limitBytes }; +} + +function labelFor(kind: WaMediaKind): string { + switch (kind) { + case "image": + return "Image"; + case "video": + return "Video"; + case "audio": + return "Audio"; + case "document": + return "Document"; + case "sticker": + return "Sticker"; + } +} + +/** + * Render a byte count with a sensible WhatsApp-style unit. Keeps a + * single decimal for MB so 5.0 MB and 4.8 MB read consistently in the + * error message. + */ +export function formatBytes(n: number): string { + if (n < KB) return `${n} B`; + if (n < MB) return `${(n / KB).toFixed(0)} KB`; + return `${(n / MB).toFixed(1)} MB`; +}