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:
parent
32319feeea
commit
82b00508f0
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
130
apps/web/src/lib/whatsapp-media.test.ts
Normal file
130
apps/web/src/lib/whatsapp-media.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
105
apps/web/src/lib/whatsapp-media.ts
Normal file
105
apps/web/src/lib/whatsapp-media.ts
Normal 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`;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user