diff --git a/apps/bot/src/scheduler/fire-reminder.ts b/apps/bot/src/scheduler/fire-reminder.ts index 664170b..ba57cfa 100644 --- a/apps/bot/src/scheduler/fire-reminder.ts +++ b/apps/bot/src/scheduler/fire-reminder.ts @@ -4,7 +4,8 @@ import { db } from "../db.js"; import { logger } from "../logger.js"; import { sessionManager } from "../whatsapp/session-manager.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 { writeAuditLog } from "../audit.js"; import { getReminderWithDetails } from "../reminders/crud.js"; @@ -13,6 +14,23 @@ import { scheduleReminderFire } from "./reminder-jobs.js"; 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 { + 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 { const reminder = await getReminderWithDetails(payload.reminderId); if (!reminder) { @@ -89,14 +107,18 @@ export async function fireReminder(payload: FireReminderPayload): Promise }); if (!media) throw new Error(`media row missing: ${part.mediaId}`); const filePath = absoluteMediaPath(media.storagePath, env.MEDIA_DIR); - // Map our DB kind ('media' or 'image'/'video'/'document') to sender kind. - // For now we infer from mime type since createReminder stores 'media'. + // Resolve the actual delivery kind from mime + magic bytes. + // 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" = - media.mimeType.startsWith("image/") - ? "image" - : media.mimeType.startsWith("video/") - ? "video" - : "document"; + resolved === "image" || resolved === "video" ? resolved : "document"; const r = await sendMediaToGroup(session.socket, group.waGroupJid, senderKind, filePath, { caption: part.textContent ?? undefined, mimeType: media.mimeType, diff --git a/apps/web/src/actions/media.ts b/apps/web/src/actions/media.ts index ce2d2d2..ddf3789 100644 --- a/apps/web/src/actions/media.ts +++ b/apps/web/src/actions/media.ts @@ -10,7 +10,7 @@ import { db } from "@/lib/db"; import { env } from "@/env"; import { getSeededOperator } from "@/lib/operator"; import { checkRateLimit } from "@/lib/rate-limit"; -import { sniffUnsupportedImage, validateForWhatsApp } from "@/lib/whatsapp-media"; +import { validateForWhatsApp } from "@/lib/whatsapp-media"; async function rateLimit(key: string) { const h = await headers(); @@ -30,25 +30,20 @@ export async function uploadMediaAction( await rateLimit("media-upload"); const file = formData.get("file"); 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 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 - // missed those — the bot then failed to extract a thumbnail and - // the message went out broken on the receiving end. - if (sniffUnsupportedImage(buffer)) { - return { - ok: false, - error: "Images are not supported, please re-upload images", - }; - } + + // Validate against the resolved delivery kind. The validator sniffs + // the magic bytes too, so an iOS HEIC labelled image/jpeg gets + // routed to the document path (100 MB cap) instead of the image + // path (5 MB cap) — the upload still succeeds but the bot delivers + // it as a downloadable file rather than an inline image. Only + // size-related rejections happen here. + 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 storagePath = newMediaPath(file.name); const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR); diff --git a/apps/web/src/components/message-stack.tsx b/apps/web/src/components/message-stack.tsx index 649dcc1..527184c 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

- 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

diff --git a/apps/web/src/lib/whatsapp-media.test.ts b/apps/web/src/lib/whatsapp-media.test.ts index 8739ed2..0c463a6 100644 --- a/apps/web/src/lib/whatsapp-media.test.ts +++ b/apps/web/src/lib/whatsapp-media.test.ts @@ -2,8 +2,12 @@ import { describe, it, expect } from "vitest"; import { classifyMediaKind, formatBytes, + isSupportedAudioMime, + isSupportedVideoMime, isUnsupportedImageMime, + resolveDeliveryKind, sniffUnsupportedImage, + sniffUnsupportedVideo, validateForWhatsApp, WA_LIMITS, WA_MAX_BYTES, @@ -11,7 +15,24 @@ import { 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", () => { expect(classifyMediaKind("image/jpeg")).toBe("image"); expect(classifyMediaKind("image/png")).toBe("image"); @@ -29,79 +50,15 @@ describe("classifyMediaKind", () => { 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/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("isUnsupportedImageMime / HEIC/HEIF/AVIF guard", () => { +describe("isUnsupportedImageMime", () => { it("recognises HEIC, HEIF, AVIF (case-insensitive)", () => { expect(isUnsupportedImageMime("image/heic")).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/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"); - } +describe("isSupportedVideoMime / allow-list", () => { + it("accepts MP4 and 3GPP only", () => { + 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("validateForWhatsApp rejects HEIC even when below the 5 MB image cap", () => { - const r = validateForWhatsApp("image/heif", 100 * 1024); // 100 KB - expect(r.ok).toBe(false); + 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", () => { - // Helper: build a 12-byte ISOBMFF header with the chosen brand. - // Bytes 0..3 are the box size (any 32-bit value), 4..7 are "ftyp", - // 8..11 are the 4-char major brand we want the sniffer to spot. - function isobmffHeader(brand: string): Uint8Array { - const buf = new Uint8Array(12); - // Size — pick any 4 bytes; the sniffer ignores them. - buf[0] = 0x00; - 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; - } +describe("isSupportedAudioMime / allow-list", () => { + it("accepts the common formats", () => { + expect(isSupportedAudioMime("audio/mpeg")).toBe(true); + expect(isSupportedAudioMime("audio/mp4")).toBe(true); + expect(isSupportedAudioMime("audio/aac")).toBe(true); + expect(isSupportedAudioMime("audio/ogg")).toBe(true); + expect(isSupportedAudioMime("audio/amr")).toBe(true); + expect(isSupportedAudioMime("audio/wav")).toBe(true); + }); + 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", () => { for (const brand of ["heic", "heix", "hevc", "heim", "heis", "mif1", "msf1", "avif", "avis"]) { 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("Avif"))).toBe(true); }); - it("does NOT flag a JPEG (no 'ftyp' marker at offset 4)", () => { - // JPEG starts with FF D8 FF E0 ... no 'ftyp' anywhere near byte 4. - const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]); + it("does NOT flag JPEG/PNG headers (no 'ftyp' marker)", () => { + const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1]); + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d]); 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); }); - it("does NOT flag an unrelated ftyp brand (mp4)", () => { - // 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. + it("does NOT flag unrelated ftyp brands (mp42, isom)", () => { expect(sniffUnsupportedImage(isobmffHeader("mp42"))).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([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", () => { it("equals the document cap (100 MB)", () => { expect(WA_MAX_BYTES).toBe(WA_LIMITS.document); diff --git a/apps/web/src/lib/whatsapp-media.ts b/apps/web/src/lib/whatsapp-media.ts index 6d79405..95b7f79 100644 --- a/apps/web/src/lib/whatsapp-media.ts +++ b/apps/web/src/lib/whatsapp-media.ts @@ -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 - * — 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) + * Policy: never reject on format alone. If a JPEG-tagged HEIC photo + * gets uploaded, route it through the document path so the recipient + * still gets the file (just without an inline preview). Only reject + * on size — image/video/audio inline limits are tighter than the + * 100 MB document cap, so the validator picks the cap based on the + * RESOLVED delivery kind, not the original mime category. */ -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; - -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 = 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 = 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); -} +const MB = 1024 * 1024; export type WaSizeCheck = - | { ok: true; kind: WaMediaKind; limitBytes: number } - | { ok: false; kind: WaMediaKind; limitBytes: number; error: string }; + | { 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. + * Validate an uploaded file. Returns the resolved delivery kind and + * the cap regardless of pass/fail. The optional `bytes` arg lets the + * caller pass the buffered payload so iOS-Safari-style mime spoofs + * (HEIC labelled image/jpeg, .mov labelled video/mp4) get caught and + * routed to the document path. */ export function validateForWhatsApp( mimeType: string, sizeBytes: number, + bytes?: Uint8Array, ): WaSizeCheck { - const kind = classifyMediaKind(mimeType); - const limitBytes = WA_LIMITS[kind]; + const kind = _resolve(mimeType, bytes); + const limitBytes = _WA_LIMITS[kind]; if (sizeBytes <= 0) { 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) { return { ok: false, @@ -153,7 +64,7 @@ export function validateForWhatsApp( return { ok: true, kind, limitBytes }; } -function labelFor(kind: WaMediaKind): string { +function labelFor(kind: _WaMediaKind): string { switch (kind) { case "image": return "Image"; @@ -168,11 +79,7 @@ function labelFor(kind: WaMediaKind): string { } } -/** - * 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. - */ +/** Render a byte count with a sensible WhatsApp-style unit. */ export function formatBytes(n: number): string { if (n < KB) return `${n} B`; if (n < MB) return `${(n / KB).toFixed(0)} KB`; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e38c014..4200c15 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from "./rrule.js"; export * from "./media-paths.js"; export * from "./timezones.js"; +export * from "./whatsapp-media.js"; diff --git a/packages/shared/src/whatsapp-media.test.ts b/packages/shared/src/whatsapp-media.test.ts new file mode 100644 index 0000000..1628d8e --- /dev/null +++ b/packages/shared/src/whatsapp-media.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/whatsapp-media.ts b/packages/shared/src/whatsapp-media.ts new file mode 100644 index 0000000..4d2e75a --- /dev/null +++ b/packages/shared/src/whatsapp-media.ts @@ -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 = 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 = 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 = 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 = new Set([ + "heic", "heix", "hevc", "heim", "heis", "mif1", "msf1", "avif", "avis", +]); + +const MP4_COMPATIBLE_BRANDS: ReadonlySet = 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"; +}