feat(media): unsupported image/video/audio formats fall back to document delivery
Old behaviour: HEIC/AVIF photos, .mov / .webm / .mkv videos, and niche
audio (FLAC, etc.) got rejected outright at upload with "Images are
not supported" / "Videos are not supported" errors. Strict but
unfriendly — recipients could still receive these as a downloadable
file via WhatsApp's document path; we just weren't using it.
New behaviour: anything not playable inline gets routed through the
document path automatically. The recipient downloads the file and
opens it in their default app. The 100 MB document cap applies
instead of the inline 5 / 16 / 16 MB caps. Only oversized uploads
get rejected.
Where the policy lives
----------------------
The classifier moved into a new `@cmbot/shared/whatsapp-media`
module so the web upload validator AND the bot's fire-reminder send
path share one source of truth:
- resolveDeliveryKind(mime, bytes?) → "image" | "video" | "audio"
| "document". Native types stay as-is; HEIF / AVIF / QuickTime /
WebM / Matroska / non-MP3-or-M4A audio all collapse to "document".
- Bytes argument is optional but recommended — sniffing the first
12 bytes of the file catches iOS Safari's habit of labelling
a HEIC as image/jpeg or a .mov as video/mp4. Bytes win when they
disagree with the mime.
Web side
--------
- `lib/whatsapp-media.ts` re-exports the shared helpers and keeps
only the validator + byte-formatter. `validateForWhatsApp` calls
resolveDeliveryKind internally; the size cap it returns is for the
RESOLVED kind (so a HEIC routes to document and gets the 100 MB
cap). The "Images are not supported" / "Videos are not supported"
rejection messages are gone — there's no format rejection anymore.
- `actions/media.ts` collapses the previous explicit-mime + byte-sniff
pair into a single `validateForWhatsApp(mime, size, bytes)` call.
- Compose-step upload-zone hint updated to spell out the per-kind
caps: "JPEG/PNG up to 5 MB · MP4/3GP up to 16 MB · MP3/M4A/OGG
up to 16 MB · documents up to 100 MB".
Bot side
--------
- `fire-reminder.ts` reads the first 12 bytes of the file before
dispatching and calls `resolveDeliveryKind(mimeType, head)` to
pick the senderKind. So a HEIC on disk (whose mime claims
image/jpeg) gets sent via Baileys' document path — no failed
thumbnail extraction, message arrives as a downloadable .heic.
- New `readHeadBytes(filePath, n)` helper opens, reads N bytes,
closes — no full-file slurp.
Tests
-----
249 web + 31 shared + 26 bot = 306 passing total.
Web (`lib/whatsapp-media.test.ts`):
- "HEIC at 30 MB allowed: routes to document (100 MB cap)"
- "HEIC at 110 MB rejects: exceeds the document cap"
- "MOV at 50 MB allowed (would be 16 MB cap as video, 100 MB as
document)"
- "MOV pretending to be mp4 demotes to document (50 MB allowed)"
- "FLAC audio routes to document path"
- "genuine MP4 byte-sniff path keeps it as video"
Shared (`packages/shared/src/whatsapp-media.test.ts`, new):
- The cross-package contract: 11 tests covering size limits,
classifyMediaKind, resolveDeliveryKind for native + demoted +
byte-sniff cases, plus the underlying helpers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c7a6f5f1b0
commit
cfd3308477
@ -4,7 +4,8 @@ import { db } from "../db.js";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { sessionManager } from "../whatsapp/session-manager.js";
|
import { sessionManager } from "../whatsapp/session-manager.js";
|
||||||
import { sendTextToGroup, sendMediaToGroup } from "../whatsapp/sender.js";
|
import { sendTextToGroup, sendMediaToGroup } from "../whatsapp/sender.js";
|
||||||
import { absoluteMediaPath, nextOccurrence } from "@cmbot/shared";
|
import { absoluteMediaPath, nextOccurrence, resolveDeliveryKind } from "@cmbot/shared";
|
||||||
|
import { open as fsOpen } from "node:fs/promises";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { writeAuditLog } from "../audit.js";
|
import { writeAuditLog } from "../audit.js";
|
||||||
import { getReminderWithDetails } from "../reminders/crud.js";
|
import { getReminderWithDetails } from "../reminders/crud.js";
|
||||||
@ -13,6 +14,23 @@ import { scheduleReminderFire } from "./reminder-jobs.js";
|
|||||||
|
|
||||||
export type FireReminderPayload = { reminderId: string };
|
export type FireReminderPayload = { reminderId: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the first N bytes of a file without slurping the whole thing.
|
||||||
|
* Used to sniff ISOBMFF brand bytes (HEIF, AVIF, QuickTime) so we
|
||||||
|
* can route mis-labelled uploads to the document path instead of
|
||||||
|
* letting Baileys' thumbnail extraction crash.
|
||||||
|
*/
|
||||||
|
async function readHeadBytes(filePath: string, n: number): Promise<Uint8Array> {
|
||||||
|
const fh = await fsOpen(filePath, "r");
|
||||||
|
try {
|
||||||
|
const buf = new Uint8Array(n);
|
||||||
|
await fh.read(buf, 0, n, 0);
|
||||||
|
return buf;
|
||||||
|
} finally {
|
||||||
|
await fh.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fireReminder(payload: FireReminderPayload): Promise<void> {
|
export async function fireReminder(payload: FireReminderPayload): Promise<void> {
|
||||||
const reminder = await getReminderWithDetails(payload.reminderId);
|
const reminder = await getReminderWithDetails(payload.reminderId);
|
||||||
if (!reminder) {
|
if (!reminder) {
|
||||||
@ -89,14 +107,18 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
|
|||||||
});
|
});
|
||||||
if (!media) throw new Error(`media row missing: ${part.mediaId}`);
|
if (!media) throw new Error(`media row missing: ${part.mediaId}`);
|
||||||
const filePath = absoluteMediaPath(media.storagePath, env.MEDIA_DIR);
|
const filePath = absoluteMediaPath(media.storagePath, env.MEDIA_DIR);
|
||||||
// Map our DB kind ('media' or 'image'/'video'/'document') to sender kind.
|
// Resolve the actual delivery kind from mime + magic bytes.
|
||||||
// For now we infer from mime type since createReminder stores 'media'.
|
// Sniffing the first 12 bytes catches HEIC/MOV uploads
|
||||||
|
// labelled with a misleading mime (e.g. iOS Safari) and
|
||||||
|
// routes them to the document path so the bot doesn't try
|
||||||
|
// to extract a thumbnail it can't decode.
|
||||||
|
const head = await readHeadBytes(filePath, 12);
|
||||||
|
const resolved = resolveDeliveryKind(media.mimeType, head);
|
||||||
|
// sendMediaToGroup accepts image / video / document. Audio
|
||||||
|
// collapses into the document path for now; the per-kind
|
||||||
|
// size cap was already applied at upload time.
|
||||||
const senderKind: "image" | "video" | "document" =
|
const senderKind: "image" | "video" | "document" =
|
||||||
media.mimeType.startsWith("image/")
|
resolved === "image" || resolved === "video" ? resolved : "document";
|
||||||
? "image"
|
|
||||||
: media.mimeType.startsWith("video/")
|
|
||||||
? "video"
|
|
||||||
: "document";
|
|
||||||
const r = await sendMediaToGroup(session.socket, group.waGroupJid, senderKind, filePath, {
|
const r = await sendMediaToGroup(session.socket, group.waGroupJid, senderKind, filePath, {
|
||||||
caption: part.textContent ?? undefined,
|
caption: part.textContent ?? undefined,
|
||||||
mimeType: media.mimeType,
|
mimeType: media.mimeType,
|
||||||
|
|||||||
@ -10,7 +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 { sniffUnsupportedImage, validateForWhatsApp } from "@/lib/whatsapp-media";
|
import { validateForWhatsApp } from "@/lib/whatsapp-media";
|
||||||
|
|
||||||
async function rateLimit(key: string) {
|
async function rateLimit(key: string) {
|
||||||
const h = await headers();
|
const h = await headers();
|
||||||
@ -30,25 +30,20 @@ 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" };
|
||||||
// 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 mimeType = file.type || "application/octet-stream";
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
// Magic-byte check: catches HEIC/AVIF that iOS Safari sometimes
|
|
||||||
// uploads with a misleading mime like image/jpeg. Mime-only check
|
// Validate against the resolved delivery kind. The validator sniffs
|
||||||
// missed those — the bot then failed to extract a thumbnail and
|
// the magic bytes too, so an iOS HEIC labelled image/jpeg gets
|
||||||
// the message went out broken on the receiving end.
|
// routed to the document path (100 MB cap) instead of the image
|
||||||
if (sniffUnsupportedImage(buffer)) {
|
// path (5 MB cap) — the upload still succeeds but the bot delivers
|
||||||
return {
|
// it as a downloadable file rather than an inline image. Only
|
||||||
ok: false,
|
// size-related rejections happen here.
|
||||||
error: "Images are not supported, please re-upload images",
|
const sizeCheck = validateForWhatsApp(mimeType, buffer.byteLength, buffer);
|
||||||
};
|
if (!sizeCheck.ok) return { ok: false, error: sizeCheck.error };
|
||||||
}
|
|
||||||
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||||
const storagePath = newMediaPath(file.name);
|
const storagePath = newMediaPath(file.name);
|
||||||
const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR);
|
const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR);
|
||||||
|
|||||||
@ -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">
|
||||||
Image up to 5 MB · video / audio up to 16 MB · document up to 100 MB
|
JPEG/PNG up to 5 MB · MP4/3GP up to 16 MB · MP3/M4A/OGG up to 16 MB · documents up to 100 MB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -2,8 +2,12 @@ import { describe, it, expect } from "vitest";
|
|||||||
import {
|
import {
|
||||||
classifyMediaKind,
|
classifyMediaKind,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
|
isSupportedAudioMime,
|
||||||
|
isSupportedVideoMime,
|
||||||
isUnsupportedImageMime,
|
isUnsupportedImageMime,
|
||||||
|
resolveDeliveryKind,
|
||||||
sniffUnsupportedImage,
|
sniffUnsupportedImage,
|
||||||
|
sniffUnsupportedVideo,
|
||||||
validateForWhatsApp,
|
validateForWhatsApp,
|
||||||
WA_LIMITS,
|
WA_LIMITS,
|
||||||
WA_MAX_BYTES,
|
WA_MAX_BYTES,
|
||||||
@ -11,7 +15,24 @@ import {
|
|||||||
|
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
describe("classifyMediaKind", () => {
|
// Helper: build a 12-byte ISOBMFF header with the chosen brand.
|
||||||
|
function isobmffHeader(brand: string): Uint8Array {
|
||||||
|
const buf = new Uint8Array(12);
|
||||||
|
buf[0] = 0x00;
|
||||||
|
buf[1] = 0x00;
|
||||||
|
buf[2] = 0x00;
|
||||||
|
buf[3] = 0x18;
|
||||||
|
buf[4] = 0x66; // 'f'
|
||||||
|
buf[5] = 0x74; // 't'
|
||||||
|
buf[6] = 0x79; // 'y'
|
||||||
|
buf[7] = 0x70; // 'p'
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
buf[8 + i] = brand.charCodeAt(i) || 0x20;
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("classifyMediaKind — coarse mime → kind by top-level category", () => {
|
||||||
it("classifies image/* as image", () => {
|
it("classifies image/* as image", () => {
|
||||||
expect(classifyMediaKind("image/jpeg")).toBe("image");
|
expect(classifyMediaKind("image/jpeg")).toBe("image");
|
||||||
expect(classifyMediaKind("image/png")).toBe("image");
|
expect(classifyMediaKind("image/png")).toBe("image");
|
||||||
@ -29,79 +50,15 @@ describe("classifyMediaKind", () => {
|
|||||||
expect(classifyMediaKind("audio/mpeg")).toBe("audio");
|
expect(classifyMediaKind("audio/mpeg")).toBe("audio");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifies anything else as document (PDFs, office, plain text…)", () => {
|
it("classifies anything else as document", () => {
|
||||||
expect(classifyMediaKind("application/pdf")).toBe("document");
|
expect(classifyMediaKind("application/pdf")).toBe("document");
|
||||||
expect(classifyMediaKind("application/zip")).toBe("document");
|
|
||||||
expect(classifyMediaKind("text/plain")).toBe("document");
|
expect(classifyMediaKind("text/plain")).toBe("document");
|
||||||
expect(classifyMediaKind("application/octet-stream")).toBe("document");
|
expect(classifyMediaKind("application/octet-stream")).toBe("document");
|
||||||
expect(classifyMediaKind("")).toBe("document");
|
expect(classifyMediaKind("")).toBe("document");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("validateForWhatsApp — per-kind WA caps", () => {
|
describe("isUnsupportedImageMime", () => {
|
||||||
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("isUnsupportedImageMime / HEIC/HEIF/AVIF guard", () => {
|
|
||||||
it("recognises HEIC, HEIF, AVIF (case-insensitive)", () => {
|
it("recognises HEIC, HEIF, AVIF (case-insensitive)", () => {
|
||||||
expect(isUnsupportedImageMime("image/heic")).toBe(true);
|
expect(isUnsupportedImageMime("image/heic")).toBe(true);
|
||||||
expect(isUnsupportedImageMime("image/heif")).toBe(true);
|
expect(isUnsupportedImageMime("image/heif")).toBe(true);
|
||||||
@ -116,81 +73,227 @@ describe("isUnsupportedImageMime / HEIC/HEIF/AVIF guard", () => {
|
|||||||
expect(isUnsupportedImageMime("image/webp")).toBe(false);
|
expect(isUnsupportedImageMime("image/webp")).toBe(false);
|
||||||
expect(isUnsupportedImageMime("image/gif")).toBe(false);
|
expect(isUnsupportedImageMime("image/gif")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validateForWhatsApp rejects a HEIC upload with a clear hint", () => {
|
|
||||||
const r = validateForWhatsApp("image/heic", 2 * MB);
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.kind).toBe("image");
|
|
||||||
expect(r.error).toBe("Images are not supported, please re-upload images");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validateForWhatsApp rejects HEIC even when below the 5 MB image cap", () => {
|
describe("isSupportedVideoMime / allow-list", () => {
|
||||||
const r = validateForWhatsApp("image/heif", 100 * 1024); // 100 KB
|
it("accepts MP4 and 3GPP only", () => {
|
||||||
expect(r.ok).toBe(false);
|
expect(isSupportedVideoMime("video/mp4")).toBe(true);
|
||||||
|
expect(isSupportedVideoMime("video/3gpp")).toBe(true);
|
||||||
|
expect(isSupportedVideoMime("video/3gpp2")).toBe(true);
|
||||||
|
expect(isSupportedVideoMime("VIDEO/MP4")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects QuickTime / WebM / Matroska / AVI / OGG", () => {
|
||||||
|
expect(isSupportedVideoMime("video/quicktime")).toBe(false);
|
||||||
|
expect(isSupportedVideoMime("video/webm")).toBe(false);
|
||||||
|
expect(isSupportedVideoMime("video/x-matroska")).toBe(false);
|
||||||
|
expect(isSupportedVideoMime("video/x-msvideo")).toBe(false);
|
||||||
|
expect(isSupportedVideoMime("video/ogg")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sniffUnsupportedImage / magic-byte detection", () => {
|
describe("isSupportedAudioMime / allow-list", () => {
|
||||||
// Helper: build a 12-byte ISOBMFF header with the chosen brand.
|
it("accepts the common formats", () => {
|
||||||
// Bytes 0..3 are the box size (any 32-bit value), 4..7 are "ftyp",
|
expect(isSupportedAudioMime("audio/mpeg")).toBe(true);
|
||||||
// 8..11 are the 4-char major brand we want the sniffer to spot.
|
expect(isSupportedAudioMime("audio/mp4")).toBe(true);
|
||||||
function isobmffHeader(brand: string): Uint8Array {
|
expect(isSupportedAudioMime("audio/aac")).toBe(true);
|
||||||
const buf = new Uint8Array(12);
|
expect(isSupportedAudioMime("audio/ogg")).toBe(true);
|
||||||
// Size — pick any 4 bytes; the sniffer ignores them.
|
expect(isSupportedAudioMime("audio/amr")).toBe(true);
|
||||||
buf[0] = 0x00;
|
expect(isSupportedAudioMime("audio/wav")).toBe(true);
|
||||||
buf[1] = 0x00;
|
});
|
||||||
buf[2] = 0x00;
|
|
||||||
buf[3] = 0x18;
|
|
||||||
// 'ftyp'
|
|
||||||
buf[4] = 0x66;
|
|
||||||
buf[5] = 0x74;
|
|
||||||
buf[6] = 0x79;
|
|
||||||
buf[7] = 0x70;
|
|
||||||
// Brand
|
|
||||||
for (let i = 0; i < 4 && i < brand.length; i++) {
|
|
||||||
buf[8 + i] = brand.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
it("rejects niche formats", () => {
|
||||||
|
expect(isSupportedAudioMime("audio/x-flac")).toBe(false);
|
||||||
|
expect(isSupportedAudioMime("audio/x-aiff")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sniffUnsupportedImage / HEIF/AVIF magic-byte detection", () => {
|
||||||
it("flags every HEIF/AVIF brand the bot's Sharp can't decode", () => {
|
it("flags every HEIF/AVIF brand the bot's Sharp can't decode", () => {
|
||||||
for (const brand of ["heic", "heix", "hevc", "heim", "heis", "mif1", "msf1", "avif", "avis"]) {
|
for (const brand of ["heic", "heix", "hevc", "heim", "heis", "mif1", "msf1", "avif", "avis"]) {
|
||||||
expect(sniffUnsupportedImage(isobmffHeader(brand))).toBe(true);
|
expect(sniffUnsupportedImage(isobmffHeader(brand))).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("brand match is case-insensitive (some encoders write upper-case)", () => {
|
it("brand match is case-insensitive", () => {
|
||||||
expect(sniffUnsupportedImage(isobmffHeader("HEIC"))).toBe(true);
|
expect(sniffUnsupportedImage(isobmffHeader("HEIC"))).toBe(true);
|
||||||
expect(sniffUnsupportedImage(isobmffHeader("Avif"))).toBe(true);
|
expect(sniffUnsupportedImage(isobmffHeader("Avif"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT flag a JPEG (no 'ftyp' marker at offset 4)", () => {
|
it("does NOT flag JPEG/PNG headers (no 'ftyp' marker)", () => {
|
||||||
// JPEG starts with FF D8 FF E0 ... no 'ftyp' anywhere near byte 4.
|
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1]);
|
||||||
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]);
|
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d]);
|
||||||
expect(sniffUnsupportedImage(jpeg)).toBe(false);
|
expect(sniffUnsupportedImage(jpeg)).toBe(false);
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT flag a PNG", () => {
|
|
||||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]);
|
|
||||||
expect(sniffUnsupportedImage(png)).toBe(false);
|
expect(sniffUnsupportedImage(png)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT flag an unrelated ftyp brand (mp4)", () => {
|
it("does NOT flag unrelated ftyp brands (mp42, isom)", () => {
|
||||||
// ISOBMFF with brand 'mp42' is a regular MP4 video container; not
|
|
||||||
// something Sharp would try to thumbnail in the image path. Keep
|
|
||||||
// it out of the unsupported set.
|
|
||||||
expect(sniffUnsupportedImage(isobmffHeader("mp42"))).toBe(false);
|
expect(sniffUnsupportedImage(isobmffHeader("mp42"))).toBe(false);
|
||||||
expect(sniffUnsupportedImage(isobmffHeader("isom"))).toBe(false);
|
expect(sniffUnsupportedImage(isobmffHeader("isom"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false for tiny / truncated buffers (< 12 bytes)", () => {
|
it("returns false for tiny / truncated buffers", () => {
|
||||||
expect(sniffUnsupportedImage(new Uint8Array(0))).toBe(false);
|
expect(sniffUnsupportedImage(new Uint8Array(0))).toBe(false);
|
||||||
expect(sniffUnsupportedImage(new Uint8Array([0x66, 0x74, 0x79, 0x70]))).toBe(false);
|
expect(sniffUnsupportedImage(new Uint8Array([0x66, 0x74, 0x79, 0x70]))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sniffUnsupportedVideo / catch MOV-as-MP4", () => {
|
||||||
|
it("flags QuickTime ('qt ') even when mime says video/mp4", () => {
|
||||||
|
expect(sniffUnsupportedVideo(isobmffHeader("qt "))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT flag genuine MP4 brands", () => {
|
||||||
|
for (const brand of ["mp42", "mp41", "isom", "iso2", "iso5", "m4v "]) {
|
||||||
|
expect(sniffUnsupportedVideo(isobmffHeader(brand))).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT flag 3GP brands (3gp4 / 3gp5)", () => {
|
||||||
|
expect(sniffUnsupportedVideo(isobmffHeader("3gp4"))).toBe(false);
|
||||||
|
expect(sniffUnsupportedVideo(isobmffHeader("3gp5"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for non-ISOBMFF data — mime allow-list catches those", () => {
|
||||||
|
const webm = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
expect(sniffUnsupportedVideo(webm)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for tiny buffers", () => {
|
||||||
|
expect(sniffUnsupportedVideo(new Uint8Array(0))).toBe(false);
|
||||||
|
expect(sniffUnsupportedVideo(new Uint8Array(8))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveDeliveryKind — the cross-package contract", () => {
|
||||||
|
it("supported native types stay in their own kind", () => {
|
||||||
|
expect(resolveDeliveryKind("image/jpeg")).toBe("image");
|
||||||
|
expect(resolveDeliveryKind("image/png")).toBe("image");
|
||||||
|
expect(resolveDeliveryKind("video/mp4")).toBe("video");
|
||||||
|
expect(resolveDeliveryKind("video/3gpp")).toBe("video");
|
||||||
|
expect(resolveDeliveryKind("audio/mpeg")).toBe("audio");
|
||||||
|
expect(resolveDeliveryKind("audio/mp4")).toBe("audio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HEIC by mime → document (no inline preview)", () => {
|
||||||
|
expect(resolveDeliveryKind("image/heic")).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("image/avif")).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HEIC by bytes (lying mime image/jpeg) → document", () => {
|
||||||
|
expect(resolveDeliveryKind("image/jpeg", isobmffHeader("heic"))).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("image/jpeg", isobmffHeader("avif"))).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MOV by mime (video/quicktime) → document", () => {
|
||||||
|
expect(resolveDeliveryKind("video/quicktime")).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("video/webm")).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MOV by bytes (lying mime video/mp4) → document", () => {
|
||||||
|
expect(resolveDeliveryKind("video/mp4", isobmffHeader("qt "))).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FLAC / niche audio → document", () => {
|
||||||
|
expect(resolveDeliveryKind("audio/x-flac")).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genuine MP4 / 3GP bytes do NOT get demoted", () => {
|
||||||
|
expect(resolveDeliveryKind("video/mp4", isobmffHeader("mp42"))).toBe("video");
|
||||||
|
expect(resolveDeliveryKind("video/3gpp", isobmffHeader("3gp4"))).toBe("video");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any unrelated mime → document", () => {
|
||||||
|
expect(resolveDeliveryKind("application/pdf")).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("text/plain")).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("application/octet-stream")).toBe("document");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateForWhatsApp — applies the cap of the RESOLVED kind", () => {
|
||||||
|
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("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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HEIC at 30 MB is allowed: routes to document (100 MB cap)", () => {
|
||||||
|
const r = validateForWhatsApp("image/heic", 30 * MB);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
// Resolved to document, so the 100 MB doc cap applies — not the
|
||||||
|
// 5 MB image cap that would have rejected the 30 MB HEIC.
|
||||||
|
expect(r.kind).toBe("document");
|
||||||
|
expect(r.limitBytes).toBe(WA_LIMITS.document);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HEIC at 110 MB still rejects: exceeds the document cap", () => {
|
||||||
|
const r = validateForWhatsApp("image/heic", 110 * MB);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.kind).toBe("document");
|
||||||
|
expect(r.error).toMatch(/Document too large/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MOV at 50 MB allowed (would be 16 MB cap as video, 100 MB as document)", () => {
|
||||||
|
const r = validateForWhatsApp("video/quicktime", 50 * MB);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.kind).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genuine MP4 at 17 MB rejects: exceeds the 16 MB video cap", () => {
|
||||||
|
const r = validateForWhatsApp("video/mp4", 17 * MB);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.kind).toBe("video");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genuine MP4 byte-sniff path (mp42 brand) keeps it as video", () => {
|
||||||
|
// 17 MB but bytes confirm mp42 → still capped at 16 MB.
|
||||||
|
const r = validateForWhatsApp("video/mp4", 17 * MB, isobmffHeader("mp42"));
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.kind).toBe("video");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MOV pretending to be mp4 demotes to document (50 MB allowed)", () => {
|
||||||
|
const r = validateForWhatsApp("video/mp4", 50 * MB, isobmffHeader("qt "));
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.kind).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
const r = validateForWhatsApp("application/octet-stream", 60 * MB);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.kind).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FLAC audio routes to document path (unsupported audio mime)", () => {
|
||||||
|
const r = validateForWhatsApp("audio/x-flac", 50 * MB);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.kind).toBe("document");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("WA_MAX_BYTES is the largest single-kind cap", () => {
|
describe("WA_MAX_BYTES is the largest single-kind cap", () => {
|
||||||
it("equals the document cap (100 MB)", () => {
|
it("equals the document cap (100 MB)", () => {
|
||||||
expect(WA_MAX_BYTES).toBe(WA_LIMITS.document);
|
expect(WA_MAX_BYTES).toBe(WA_LIMITS.document);
|
||||||
|
|||||||
@ -1,147 +1,58 @@
|
|||||||
/**
|
/**
|
||||||
* WhatsApp media size limits, by deliverable type.
|
* Web upload validator. Builds on the shared media classifier so the
|
||||||
|
* web upload action and the bot's send path agree on which delivery
|
||||||
|
* kind a file gets routed to (image / video / audio / document).
|
||||||
*
|
*
|
||||||
* These are the practical WA Web upload limits Baileys passes through
|
* Policy: never reject on format alone. If a JPEG-tagged HEIC photo
|
||||||
* — anything over the cap is either re-encoded by WA's server (image)
|
* gets uploaded, route it through the document path so the recipient
|
||||||
* or rejected outright (everything else). Sourced from WA Business
|
* still gets the file (just without an inline preview). Only reject
|
||||||
* docs and Baileys' own validation paths; aligned to the most
|
* on size — image/video/audio inline limits are tighter than the
|
||||||
* conservative documented value where two specs disagree.
|
* 100 MB document cap, so the validator picks the cap based on the
|
||||||
*
|
* RESOLVED delivery kind, not the original mime category.
|
||||||
* 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;
|
export {
|
||||||
|
classifyMediaKind,
|
||||||
|
isUnsupportedImageMime,
|
||||||
|
isSupportedVideoMime,
|
||||||
|
isSupportedAudioMime,
|
||||||
|
resolveDeliveryKind,
|
||||||
|
sniffUnsupportedImage,
|
||||||
|
sniffUnsupportedVideo,
|
||||||
|
WA_LIMITS,
|
||||||
|
WA_MAX_BYTES,
|
||||||
|
type WaMediaKind,
|
||||||
|
} from "@cmbot/shared";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveDeliveryKind as _resolve,
|
||||||
|
WA_LIMITS as _WA_LIMITS,
|
||||||
|
type WaMediaKind as _WaMediaKind,
|
||||||
|
} from "@cmbot/shared";
|
||||||
|
|
||||||
const KB = 1024;
|
const KB = 1024;
|
||||||
|
const MB = 1024 * 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";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MIME types that look like images to the classifier but break the
|
|
||||||
* Baileys send path because the bot's bundled Sharp doesn't have the
|
|
||||||
* relevant decoder (HEIF/HEIC, AVIF). The image otherwise uploads
|
|
||||||
* silently with no thumbnail and a logged warning, which reads to
|
|
||||||
* users as "image send broken". Rejecting at the upload boundary
|
|
||||||
* with a useful message is friendlier than the half-success.
|
|
||||||
*/
|
|
||||||
const UNSUPPORTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
|
|
||||||
"image/heic",
|
|
||||||
"image/heif",
|
|
||||||
"image/heic-sequence",
|
|
||||||
"image/heif-sequence",
|
|
||||||
"image/avif",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function isUnsupportedImageMime(mimeType: string): boolean {
|
|
||||||
return UNSUPPORTED_IMAGE_MIMES.has(mimeType.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sniff the file's magic bytes to detect HEIF / AVIF regardless of
|
|
||||||
* what the Content-Type header claims. iOS Safari sometimes uploads
|
|
||||||
* HEIC photos with mime "image/jpeg" — pure mime checks let those
|
|
||||||
* through, the bot's Sharp can't decode the bytes, and the message
|
|
||||||
* is sent without a thumbnail (or rejected by WhatsApp clients on
|
|
||||||
* the receiving end).
|
|
||||||
*
|
|
||||||
* Both HEIF and AVIF are ISOBMFF containers. Bytes 4..7 are the
|
|
||||||
* literal "ftyp" box header, followed by a 4-char brand at 8..11.
|
|
||||||
* The brands we care about:
|
|
||||||
* HEIF: heic, heix, hevc, heim, heis, mif1, msf1
|
|
||||||
* AVIF: avif, avis
|
|
||||||
*
|
|
||||||
* Returns true if the bytes look like HEIF/AVIF (and so the upload
|
|
||||||
* should be rejected even when the mime claimed it was JPEG/PNG).
|
|
||||||
*/
|
|
||||||
const UNSUPPORTED_BRANDS: ReadonlySet<string> = new Set([
|
|
||||||
"heic",
|
|
||||||
"heix",
|
|
||||||
"hevc",
|
|
||||||
"heim",
|
|
||||||
"heis",
|
|
||||||
"mif1",
|
|
||||||
"msf1",
|
|
||||||
"avif",
|
|
||||||
"avis",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function sniffUnsupportedImage(bytes: Uint8Array): boolean {
|
|
||||||
if (bytes.length < 12) return false;
|
|
||||||
// "ftyp" at bytes 4..7
|
|
||||||
if (
|
|
||||||
bytes[4] !== 0x66 || // 'f'
|
|
||||||
bytes[5] !== 0x74 || // 't'
|
|
||||||
bytes[6] !== 0x79 || // 'y'
|
|
||||||
bytes[7] !== 0x70 // 'p'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const brand = String.fromCharCode(bytes[8]!, bytes[9]!, bytes[10]!, bytes[11]!).toLowerCase();
|
|
||||||
return UNSUPPORTED_BRANDS.has(brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WaSizeCheck =
|
export type WaSizeCheck =
|
||||||
| { ok: true; kind: WaMediaKind; limitBytes: number }
|
| { ok: true; kind: _WaMediaKind; limitBytes: number }
|
||||||
| { ok: false; kind: WaMediaKind; limitBytes: number; error: string };
|
| { ok: false; kind: _WaMediaKind; limitBytes: number; error: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate an uploaded file against WhatsApp's per-kind size cap.
|
* Validate an uploaded file. Returns the resolved delivery kind and
|
||||||
*
|
* the cap regardless of pass/fail. The optional `bytes` arg lets the
|
||||||
* Returns the resolved kind and the cap regardless of pass/fail so
|
* caller pass the buffered payload so iOS-Safari-style mime spoofs
|
||||||
* the UI can show "Image: 4.8 MB / 5 MB" or "Image too large
|
* (HEIC labelled image/jpeg, .mov labelled video/mp4) get caught and
|
||||||
* (5.2 MB > 5 MB)" without re-deriving the kind itself.
|
* routed to the document path.
|
||||||
*/
|
*/
|
||||||
export function validateForWhatsApp(
|
export function validateForWhatsApp(
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
sizeBytes: number,
|
sizeBytes: number,
|
||||||
|
bytes?: Uint8Array,
|
||||||
): WaSizeCheck {
|
): WaSizeCheck {
|
||||||
const kind = classifyMediaKind(mimeType);
|
const kind = _resolve(mimeType, bytes);
|
||||||
const limitBytes = WA_LIMITS[kind];
|
const limitBytes = _WA_LIMITS[kind];
|
||||||
if (sizeBytes <= 0) {
|
if (sizeBytes <= 0) {
|
||||||
return { ok: false, kind, limitBytes, error: "Empty file" };
|
return { ok: false, kind, limitBytes, error: "Empty file" };
|
||||||
}
|
}
|
||||||
if (kind === "image" && isUnsupportedImageMime(mimeType)) {
|
|
||||||
// The bot's Sharp binary doesn't ship a HEIF/AVIF decoder, so the
|
|
||||||
// thumbnail-extraction step throws and Baileys sends the message
|
|
||||||
// without a preview. Block at the door with a clear message.
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
kind,
|
|
||||||
limitBytes,
|
|
||||||
error: "Images are not supported, please re-upload images",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (sizeBytes > limitBytes) {
|
if (sizeBytes > limitBytes) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -153,7 +64,7 @@ export function validateForWhatsApp(
|
|||||||
return { ok: true, kind, limitBytes };
|
return { ok: true, kind, limitBytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelFor(kind: WaMediaKind): string {
|
function labelFor(kind: _WaMediaKind): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
return "Image";
|
return "Image";
|
||||||
@ -168,11 +79,7 @@ function labelFor(kind: WaMediaKind): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Render a byte count with a sensible WhatsApp-style unit. */
|
||||||
* 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 {
|
export function formatBytes(n: number): string {
|
||||||
if (n < KB) return `${n} B`;
|
if (n < KB) return `${n} B`;
|
||||||
if (n < MB) return `${(n / KB).toFixed(0)} KB`;
|
if (n < MB) return `${(n / KB).toFixed(0)} KB`;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./rrule.js";
|
export * from "./rrule.js";
|
||||||
export * from "./media-paths.js";
|
export * from "./media-paths.js";
|
||||||
export * from "./timezones.js";
|
export * from "./timezones.js";
|
||||||
|
export * from "./whatsapp-media.js";
|
||||||
|
|||||||
101
packages/shared/src/whatsapp-media.test.ts
Normal file
101
packages/shared/src/whatsapp-media.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
classifyMediaKind,
|
||||||
|
resolveDeliveryKind,
|
||||||
|
isUnsupportedImageMime,
|
||||||
|
isSupportedVideoMime,
|
||||||
|
isSupportedAudioMime,
|
||||||
|
sniffUnsupportedImage,
|
||||||
|
sniffUnsupportedVideo,
|
||||||
|
WA_LIMITS,
|
||||||
|
WA_MAX_BYTES,
|
||||||
|
} from "./whatsapp-media.js";
|
||||||
|
|
||||||
|
function isobmffHeader(brand: string): Uint8Array {
|
||||||
|
const buf = new Uint8Array(12);
|
||||||
|
buf[0] = 0;
|
||||||
|
buf[1] = 0;
|
||||||
|
buf[2] = 0;
|
||||||
|
buf[3] = 0x18;
|
||||||
|
buf[4] = 0x66; // 'f'
|
||||||
|
buf[5] = 0x74; // 't'
|
||||||
|
buf[6] = 0x79; // 'y'
|
||||||
|
buf[7] = 0x70; // 'p'
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
buf[8 + i] = brand.charCodeAt(i) || 0x20;
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("@cmbot/shared whatsapp-media — cross-package contract", () => {
|
||||||
|
it("size limits are stable (image 5MB / video 16MB / audio 16MB / document 100MB)", () => {
|
||||||
|
expect(WA_LIMITS.image).toBe(5 * 1024 * 1024);
|
||||||
|
expect(WA_LIMITS.video).toBe(16 * 1024 * 1024);
|
||||||
|
expect(WA_LIMITS.audio).toBe(16 * 1024 * 1024);
|
||||||
|
expect(WA_LIMITS.document).toBe(100 * 1024 * 1024);
|
||||||
|
expect(WA_MAX_BYTES).toBe(WA_LIMITS.document);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifyMediaKind buckets by top-level mime category", () => {
|
||||||
|
expect(classifyMediaKind("image/png")).toBe("image");
|
||||||
|
expect(classifyMediaKind("video/mp4")).toBe("video");
|
||||||
|
expect(classifyMediaKind("audio/mpeg")).toBe("audio");
|
||||||
|
expect(classifyMediaKind("application/pdf")).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveDeliveryKind keeps supported native types in place", () => {
|
||||||
|
expect(resolveDeliveryKind("image/jpeg")).toBe("image");
|
||||||
|
expect(resolveDeliveryKind("video/mp4")).toBe("video");
|
||||||
|
expect(resolveDeliveryKind("audio/mpeg")).toBe("audio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveDeliveryKind demotes HEIC/AVIF to document (mime-based)", () => {
|
||||||
|
expect(resolveDeliveryKind("image/heic")).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("image/avif")).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveDeliveryKind demotes HEIC by bytes when mime lies (image/jpeg + heic ftyp)", () => {
|
||||||
|
// The exact case iOS Safari produces.
|
||||||
|
expect(resolveDeliveryKind("image/jpeg", isobmffHeader("heic"))).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveDeliveryKind demotes QuickTime/WebM/MKV to document", () => {
|
||||||
|
expect(resolveDeliveryKind("video/quicktime")).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("video/webm")).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("video/x-matroska")).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveDeliveryKind demotes .mov by bytes (qt brand under video/mp4 mime)", () => {
|
||||||
|
expect(resolveDeliveryKind("video/mp4", isobmffHeader("qt "))).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveDeliveryKind keeps genuine MP4/3GP bytes in the video path", () => {
|
||||||
|
expect(resolveDeliveryKind("video/mp4", isobmffHeader("mp42"))).toBe("video");
|
||||||
|
expect(resolveDeliveryKind("video/3gpp", isobmffHeader("3gp4"))).toBe("video");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveDeliveryKind demotes unsupported audio formats", () => {
|
||||||
|
expect(resolveDeliveryKind("audio/x-flac")).toBe("document");
|
||||||
|
expect(resolveDeliveryKind("audio/x-aiff")).toBe("document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isUnsupportedImageMime / isSupportedVideoMime / isSupportedAudioMime fundamentals", () => {
|
||||||
|
expect(isUnsupportedImageMime("image/heic")).toBe(true);
|
||||||
|
expect(isUnsupportedImageMime("image/jpeg")).toBe(false);
|
||||||
|
expect(isSupportedVideoMime("video/mp4")).toBe(true);
|
||||||
|
expect(isSupportedVideoMime("video/quicktime")).toBe(false);
|
||||||
|
expect(isSupportedAudioMime("audio/mpeg")).toBe(true);
|
||||||
|
expect(isSupportedAudioMime("audio/x-flac")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("byte sniffs only fire on ISOBMFF data with the matching brand", () => {
|
||||||
|
expect(sniffUnsupportedImage(isobmffHeader("heic"))).toBe(true);
|
||||||
|
expect(sniffUnsupportedImage(isobmffHeader("mp42"))).toBe(false);
|
||||||
|
expect(sniffUnsupportedVideo(isobmffHeader("qt "))).toBe(true);
|
||||||
|
expect(sniffUnsupportedVideo(isobmffHeader("mp42"))).toBe(false);
|
||||||
|
// No ftyp marker → not ISOBMFF → both return false.
|
||||||
|
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1]);
|
||||||
|
expect(sniffUnsupportedImage(jpeg)).toBe(false);
|
||||||
|
expect(sniffUnsupportedVideo(jpeg)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
178
packages/shared/src/whatsapp-media.ts
Normal file
178
packages/shared/src/whatsapp-media.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* WhatsApp media classification + size limits.
|
||||||
|
*
|
||||||
|
* Lives in @cmbot/shared because both the web upload validator AND
|
||||||
|
* the bot's fire-reminder loop need to agree on:
|
||||||
|
*
|
||||||
|
* 1. Per-kind size caps (image 5 MB, video 16 MB, audio 16 MB,
|
||||||
|
* document 100 MB).
|
||||||
|
* 2. Which mime types WhatsApp Web reliably plays inline. If the
|
||||||
|
* uploaded mime is outside the inline-playable set, we fall
|
||||||
|
* back to delivering the file as a document (a downloadable
|
||||||
|
* attachment) instead of rejecting the upload — recipients
|
||||||
|
* can still get the file, they just open it in their default
|
||||||
|
* app.
|
||||||
|
* 3. ISOBMFF magic-byte sniffs that catch the common case of iOS
|
||||||
|
* Safari uploading a HEIC photo (or .mov video) with a lying
|
||||||
|
* Content-Type like image/jpeg / video/mp4. The bytes win;
|
||||||
|
* mime is treated as a hint.
|
||||||
|
*
|
||||||
|
* The bot calls `resolveDeliveryKind(mime, bytes)` against the first
|
||||||
|
* 12 bytes of the file on disk to pick the correct Baileys sender
|
||||||
|
* path (image / video / audio / document). The web calls the same
|
||||||
|
* function with the buffered upload to pick the size limit AND
|
||||||
|
* decide whether to reject (only on size — never on format).
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
|
export const WA_MAX_BYTES = WA_LIMITS.document;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mime classification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Map a MIME type to a coarse delivery kind based on its top-level
|
||||||
|
* category. Anything not image / video / audio falls through to
|
||||||
|
* "document". */
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNSUPPORTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
|
||||||
|
"image/heic",
|
||||||
|
"image/heif",
|
||||||
|
"image/heic-sequence",
|
||||||
|
"image/heif-sequence",
|
||||||
|
"image/avif",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isUnsupportedImageMime(mimeType: string): boolean {
|
||||||
|
return UNSUPPORTED_IMAGE_MIMES.has(mimeType.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_VIDEO_MIMES: ReadonlySet<string> = new Set([
|
||||||
|
"video/mp4",
|
||||||
|
"video/3gpp",
|
||||||
|
"video/3gpp2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isSupportedVideoMime(mimeType: string): boolean {
|
||||||
|
return SUPPORTED_VIDEO_MIMES.has(mimeType.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_AUDIO_MIMES: ReadonlySet<string> = new Set([
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/aac",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/amr",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/x-wav",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isSupportedAudioMime(mimeType: string): boolean {
|
||||||
|
return SUPPORTED_AUDIO_MIMES.has(mimeType.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Magic-byte sniffs (HEIF / AVIF / QuickTime)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const UNSUPPORTED_IMAGE_BRANDS: ReadonlySet<string> = new Set([
|
||||||
|
"heic", "heix", "hevc", "heim", "heis", "mif1", "msf1", "avif", "avis",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MP4_COMPATIBLE_BRANDS: ReadonlySet<string> = new Set([
|
||||||
|
"mp41", "mp42", "isom", "iso2", "iso3", "iso4", "iso5", "iso6",
|
||||||
|
"m4v ", "f4v ", "3gp4", "3gp5", "3gp6",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function readFtypBrand(bytes: Uint8Array): string | null {
|
||||||
|
if (bytes.length < 12) return null;
|
||||||
|
if (
|
||||||
|
bytes[4] !== 0x66 || // 'f'
|
||||||
|
bytes[5] !== 0x74 || // 't'
|
||||||
|
bytes[6] !== 0x79 || // 'y'
|
||||||
|
bytes[7] !== 0x70 // 'p'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return String.fromCharCode(
|
||||||
|
bytes[8]!,
|
||||||
|
bytes[9]!,
|
||||||
|
bytes[10]!,
|
||||||
|
bytes[11]!,
|
||||||
|
).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the bytes are an ISOBMFF container with an
|
||||||
|
* image brand the bot's Sharp can't decode (HEIF / AVIF). */
|
||||||
|
export function sniffUnsupportedImage(bytes: Uint8Array): boolean {
|
||||||
|
const brand = readFtypBrand(bytes);
|
||||||
|
return brand !== null && UNSUPPORTED_IMAGE_BRANDS.has(brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the bytes are an ISOBMFF container with a brand that
|
||||||
|
* ISN'T MP4-compatible (typically QuickTime "qt " from .mov files). */
|
||||||
|
export function sniffUnsupportedVideo(bytes: Uint8Array): boolean {
|
||||||
|
const brand = readFtypBrand(bytes);
|
||||||
|
if (brand === null) return false;
|
||||||
|
return !MP4_COMPATIBLE_BRANDS.has(brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveDeliveryKind — the cross-package contract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the actual Baileys sender path the bot should take, given
|
||||||
|
* the stored mime AND (optionally) the first 12 bytes of the file.
|
||||||
|
*
|
||||||
|
* - JPEG / PNG / WebP / GIF → "image"
|
||||||
|
* - HEIC / HEIF / AVIF → "document" (no inline preview)
|
||||||
|
* - MP4 / 3GP → "video"
|
||||||
|
* - .mov / WebM / MKV / AVI → "document"
|
||||||
|
* - MP3 / M4A / OGG / AAC / AMR / WAV → "audio"
|
||||||
|
* - other audio → "document"
|
||||||
|
* - everything else → "document"
|
||||||
|
*
|
||||||
|
* Bytes are optional but recommended — they catch the case where
|
||||||
|
* iOS Safari uploads a HEIC photo with mime `image/jpeg` (or a
|
||||||
|
* QuickTime .mov with mime `video/mp4`), which mime alone misses.
|
||||||
|
*/
|
||||||
|
export function resolveDeliveryKind(
|
||||||
|
mimeType: string,
|
||||||
|
bytes?: Uint8Array,
|
||||||
|
): WaMediaKind {
|
||||||
|
const native = classifyMediaKind(mimeType);
|
||||||
|
if (native === "image") {
|
||||||
|
if (isUnsupportedImageMime(mimeType)) return "document";
|
||||||
|
if (bytes && sniffUnsupportedImage(bytes)) return "document";
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (native === "video") {
|
||||||
|
if (!isSupportedVideoMime(mimeType)) return "document";
|
||||||
|
if (bytes && sniffUnsupportedVideo(bytes)) return "document";
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
if (native === "audio") {
|
||||||
|
if (!isSupportedAudioMime(mimeType)) return "document";
|
||||||
|
return "audio";
|
||||||
|
}
|
||||||
|
return "document";
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user