diff --git a/apps/web/src/actions/media.ts b/apps/web/src/actions/media.ts index 69db040..ce2d2d2 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 { validateForWhatsApp } from "@/lib/whatsapp-media"; +import { sniffUnsupportedImage, validateForWhatsApp } from "@/lib/whatsapp-media"; async function rateLimit(key: string) { const h = await headers(); @@ -39,6 +39,16 @@ export async function uploadMediaAction( 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", + }; + } 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/lib/recurrence.test.ts b/apps/web/src/lib/recurrence.test.ts index 7fc81f6..3c15152 100644 --- a/apps/web/src/lib/recurrence.test.ts +++ b/apps/web/src/lib/recurrence.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { DateTime } from "luxon"; import { buildRrule, + describeCronRule, describeRecurrence, isoWeekdayToCron, kindFromRrule, @@ -233,3 +234,92 @@ describe("describeRecurrence", () => { ).toBe("Every day, until 2026-06-01"); }); }); + +describe("describeCronRule — humanise stored cron strings", () => { + it("daily", () => { + expect(describeCronRule("CRON:32 11 * * *")).toBe("Every day at 11:32"); + // The CRON: prefix is optional in the helper. + expect(describeCronRule("0 9 * * *")).toBe("Every day at 09:00"); + }); + + it("weekly — single weekday and multi-day list, in canonical order", () => { + // Cron weekday: 1=Mon..6=Sat, 0=Sun. + expect(describeCronRule("CRON:0 9 * * 1")).toBe("Every week on Mon at 09:00"); + expect(describeCronRule("CRON:0 17 * * 5,1,3")).toBe( + "Every week on Mon, Wed, Fri at 17:00", + ); + }); + + it("weekly — Sunday encodes as cron 0 → ISO 7", () => { + expect(describeCronRule("CRON:30 8 * * 0")).toBe("Every week on Sun at 08:30"); + }); + + it("weekly — range expands (1-5 → Mon..Fri)", () => { + expect(describeCronRule("CRON:0 9 * * 1-5")).toBe( + "Every week on Mon, Tue, Wed, Thu, Fri at 09:00", + ); + }); + + it("monthly — single day uses singular 'day'", () => { + expect(describeCronRule("CRON:0 9 15 * *")).toBe("Every month on day 15 at 09:00"); + }); + + it("monthly — multiple days uses plural 'days'", () => { + expect(describeCronRule("CRON:0 9 1,15,28 * *")).toBe( + "Every month on days 1, 15, 28 at 09:00", + ); + }); + + it("monthly — long lists collapse with '+N more'", () => { + expect(describeCronRule("CRON:0 9 1,2,3,4,5,6,7,8 * *")).toBe( + "Every month on days 1, 2, 3, 4, 5, 6 +2 more at 09:00", + ); + }); + + it("yearly — single month + single day", () => { + expect(describeCronRule("CRON:0 9 13 5 *")).toBe( + "Every year in May on day 13 at 09:00", + ); + }); + + it("yearly — multi-month + multi-day Cartesian product", () => { + expect(describeCronRule("CRON:30 17 1,15 1,4,7,10 *")).toBe( + "Every year in Jan, Apr, Jul, Oct on days 1, 15 at 17:30", + ); + }); + + it("multi-line rules join with ' · '", () => { + expect(describeCronRule("CRON:0 9 * * 1\n0 17 * * 5")).toBe( + "Every week on Mon at 09:00 · Every week on Fri at 17:00", + ); + }); + + it("multi-line: blank lines are ignored", () => { + expect(describeCronRule("CRON:0 9 * * 1\n\n0 17 * * 5\n")).toBe( + "Every week on Mon at 09:00 · Every week on Fri at 17:00", + ); + }); + + it("empty / not-configured rule reads useful", () => { + expect(describeCronRule("CRON:")).toBe("Cron (not configured)"); + expect(describeCronRule("")).toBe("Cron (not configured)"); + }); + + it("unrecognised cron shape falls back to the raw expression (better than silent)", () => { + // Sub-hour fields like "*/5 * * * *" aren't shapes the picker + // produces, but if one ever sneaks in we surface it raw rather + // than dropping it on the floor. + expect(describeCronRule("CRON:*/5 * * * *")).toBe("*/5 * * * *"); + }); + + it("describeRecurrence delegates to describeCronRule for kind=cron", () => { + const spec: RecurrenceSpec = { + kind: "cron", + interval: 1, + weeklyDays: [], + cron: "0 9 * * *", + end: { kind: "never" }, + }; + expect(describeRecurrence(spec, FIRST)).toBe("Every day at 09:00"); + }); +}); diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts index 8d9933d..49b08f9 100644 --- a/apps/web/src/lib/recurrence.ts +++ b/apps/web/src/lib/recurrence.ts @@ -120,7 +120,7 @@ const FREQ_UNIT: Record = { export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string { if (spec.kind === "none") return "One-off"; if (spec.kind === "cron") { - return spec.cron ? `Cron: ${spec.cron}` : "Cron (not configured)"; + return spec.cron ? describeCronRule(spec.cron) : "Cron (not configured)"; } const interval = clampInterval(spec.interval); @@ -219,3 +219,140 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind export function isoWeekdayToCron(iso: number): number { return iso === 7 ? 0 : iso; } + +// --------------------------------------------------------------------------- +// describeCronRule +// +// Turns the cron expressions the recurrence picker produces back into the +// kind of human-readable sentence the rest of the app already uses for +// RRULE-based recurrences. Used by `describeRecurrence` for cron rules +// and by anywhere else the app surfaces a recurrence summary (reminder +// list rows, detail page, review step). +// +// Shapes the picker emits, all with `MM HH` at the front: +// +// "MM HH * * *" → daily, every day +// "MM HH * * D[,D...]" → weekly, on listed cron weekdays (0=Sun..6=Sat) +// "MM HH d[,d...] * *" → monthly, on listed days +// "MM HH d[,d...] m[,m...] *" → yearly, listed days × listed months +// +// Multi-line rules ("CRON:line1\nline2\n…") render as the joined +// per-line descriptions, separated by " · " for compactness in lists. +// --------------------------------------------------------------------------- + +const MONTH_NAMES_FULL = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +function pad2(n: number): string { + return n.toString().padStart(2, "0"); +} + +function formatDaysList(days: number[]): string { + if (days.length <= 6) return days.join(", "); + return `${days.slice(0, 6).join(", ")} +${days.length - 6} more`; +} + +function describeWeekdayList(cronDays: number[]): string { + // Cron weekday: 0=Sun..6=Sat. Sort + map to short labels. + const labels = cronDays + .slice() + .sort((a, b) => a - b) + .map((c) => { + const iso = c === 0 ? 7 : c; + return WEEKDAY_LABELS[iso - 1]?.short ?? ""; + }) + .filter(Boolean); + return labels.join(", "); +} + +/** Describe a single cron expression (no `CRON:` prefix). */ +function describeCronExpr(expr: string): string { + const head = expr.match(/^(\d+)\s+(\d+)\s+(.*)$/); + if (!head) return expr; // unrecognised — fall back to raw + const minute = Number(head[1]); + const hour = Number(head[2]); + if ( + !Number.isFinite(minute) || + !Number.isFinite(hour) || + minute < 0 || + minute > 59 || + hour < 0 || + hour > 23 + ) { + return expr; + } + const time = `${pad2(hour)}:${pad2(minute)}`; + const rest = head[3]!.trim(); + + // Daily: "* * *" + if (rest === "* * *") return `Every day at ${time}`; + + // Weekly: "* * " + let m = rest.match(/^\* \* ([0-9,\-]+)$/); + if (m) { + const days = m[1]! + .split(",") + .flatMap((p) => { + const r = p.match(/^(\d+)-(\d+)$/); + if (r) { + const out: number[] = []; + for (let i = Number(r[1]); i <= Number(r[2]); i++) out.push(i); + return out; + } + return [Number(p)]; + }) + .filter((n) => n >= 0 && n <= 6); + const labels = describeWeekdayList(days); + if (!labels) return expr; + return `Every week on ${labels} at ${time}`; + } + + // Monthly: " * *" + m = rest.match(/^([0-9,]+) \* \*$/); + if (m) { + const days = m[1]! + .split(",") + .map((s) => Number(s)) + .filter((n) => n >= 1 && n <= 31); + if (days.length === 0) return expr; + return `Every month on day${days.length > 1 ? "s" : ""} ${formatDaysList(days)} at ${time}`; + } + + // Yearly: " *" + m = rest.match(/^([0-9,]+) ([0-9,]+) \*$/); + if (m) { + const days = m[1]! + .split(",") + .map((s) => Number(s)) + .filter((n) => n >= 1 && n <= 31); + const months = m[2]! + .split(",") + .map((s) => Number(s)) + .filter((n) => n >= 1 && n <= 12); + if (days.length === 0 || months.length === 0) return expr; + const monthLabels = months.map((mo) => MONTH_NAMES_FULL[mo - 1]?.slice(0, 3)).join(", "); + return `Every year in ${monthLabels} on day${days.length > 1 ? "s" : ""} ${formatDaysList(days)} at ${time}`; + } + + // Unrecognised shape — show the raw expression so the user can still + // make sense of it; better than swallowing entirely. + return expr; +} + +/** + * Render a cron rule as a sentence. Handles: + * - the optional "CRON:" prefix our app stores + * - multi-line rules (joined with " · ") + */ +export function describeCronRule(rule: string): string { + const prefix = "CRON:"; + const stripped = rule.startsWith(prefix) ? rule.slice(prefix.length) : rule; + const lines = stripped + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + if (lines.length === 0) return "Cron (not configured)"; + return lines.map(describeCronExpr).join(" · "); +} diff --git a/apps/web/src/lib/whatsapp-media.test.ts b/apps/web/src/lib/whatsapp-media.test.ts index ade4616..8739ed2 100644 --- a/apps/web/src/lib/whatsapp-media.test.ts +++ b/apps/web/src/lib/whatsapp-media.test.ts @@ -3,6 +3,7 @@ import { classifyMediaKind, formatBytes, isUnsupportedImageMime, + sniffUnsupportedImage, validateForWhatsApp, WA_LIMITS, WA_MAX_BYTES, @@ -131,6 +132,65 @@ describe("isUnsupportedImageMime / HEIC/HEIF/AVIF guard", () => { }); }); +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; + } + + 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)", () => { + 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]); + 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. + expect(sniffUnsupportedImage(isobmffHeader("mp42"))).toBe(false); + expect(sniffUnsupportedImage(isobmffHeader("isom"))).toBe(false); + }); + + it("returns false for tiny / truncated buffers (< 12 bytes)", () => { + expect(sniffUnsupportedImage(new Uint8Array(0))).toBe(false); + expect(sniffUnsupportedImage(new Uint8Array([0x66, 0x74, 0x79, 0x70]))).toBe(false); + }); +}); + 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 c87f406..6d79405 100644 --- a/apps/web/src/lib/whatsapp-media.ts +++ b/apps/web/src/lib/whatsapp-media.ts @@ -67,6 +67,50 @@ 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); +} + export type WaSizeCheck = | { ok: true; kind: WaMediaKind; limitBytes: number } | { ok: false; kind: WaMediaKind; limitBytes: number; error: string };