feat(uploads): per-kind WhatsApp media size limits, lift Server Action body cap

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 12:35:41 +08:00
parent 32319feeea
commit 82b00508f0
5 changed files with 254 additions and 7 deletions

View File

@ -19,6 +19,15 @@ const nextConfig: NextConfig = {
allowedDevOrigins: ["192.168.0.253", "test.04080616.xyz", "rexwa.04080616.xyz"], allowedDevOrigins: ["192.168.0.253", "test.04080616.xyz", "rexwa.04080616.xyz"],
experimental: { experimental: {
typedRoutes: true, 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: { turbopack: {
root: workspaceRoot, root: workspaceRoot,

View File

@ -10,8 +10,7 @@ import { db } from "@/lib/db";
import { env } from "@/env"; import { env } from "@/env";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/lib/rate-limit";
import { validateForWhatsApp } from "@/lib/whatsapp-media";
const MAX_BYTES = 50 * 1024 * 1024;
async function rateLimit(key: string) { async function rateLimit(key: string) {
const h = await headers(); const h = await headers();
@ -31,8 +30,12 @@ export async function uploadMediaAction(
await rateLimit("media-upload"); await rateLimit("media-upload");
const file = formData.get("file"); const file = formData.get("file");
if (!(file instanceof File)) return { ok: false, error: "No file uploaded" }; if (!(file instanceof File)) return { ok: false, error: "No file uploaded" };
if (file.size === 0) return { ok: false, error: "Empty file" }; // Per-kind WhatsApp size validation: 5 MB images, 16 MB video/audio,
if (file.size > MAX_BYTES) return { ok: false, error: "File too large (>50MB)" }; // 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 op = await getSeededOperator();
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@ -47,7 +50,7 @@ export async function uploadMediaAction(
.values({ .values({
operatorId: op.id, operatorId: op.id,
filenameOriginal: file.name, filenameOriginal: file.name,
mimeType: file.type || "application/octet-stream", mimeType,
sizeBytes: buffer.byteLength, sizeBytes: buffer.byteLength,
sha256, sha256,
storagePath, storagePath,
@ -58,6 +61,6 @@ export async function uploadMediaAction(
ok: true, ok: true,
mediaId: row!.id, mediaId: row!.id,
filename: file.name, filename: file.name,
mimeType: file.type || "application/octet-stream", mimeType,
}; };
} }

View File

@ -377,7 +377,7 @@ function MediaBlock({ part, onChange }: MediaBlockProps) {
<div> <div>
<p className="text-xs font-medium">Click to upload or drag & drop</p> <p className="text-xs font-medium">Click to upload or drag & drop</p>
<p className="text-[11px] text-muted-foreground mt-0.5"> <p className="text-[11px] text-muted-foreground mt-0.5">
Images, documents, audio up to 50 MB Image up to 5 MB · video / audio up to 16 MB · document up to 100 MB
</p> </p>
</div> </div>
</> </>

View File

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

View File

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