feat: humanise cron in list summaries; magic-byte detect HEIC; sidebar brand link tests
Three threads from the recent UX iteration:
1. Reminder list / detail no longer shows raw "Cron: 32 11 * * *"
----------------------------------------------------------------
`describeRecurrence` for a kind=cron spec used to emit
"Cron: <expr>" verbatim, which is unreadable on the list row's
recurrence line.
New pure helper `describeCronRule(rule)` parses the cron shapes
the recurrence picker produces and renders them as natural
sentences:
"0 9 * * *" → "Every day at 09:00"
"0 9 * * 1-5" → "Every week on Mon, Tue, Wed, Thu, Fri at 09:00"
"0 9 * * 1,3,5" → "Every week on Mon, Wed, Fri at 09:00"
"0 9 1,15 * *" → "Every month on days 1, 15 at 09:00"
"0 9 13 5 *" → "Every year in May on day 13 at 09:00"
"30 17 1,15 1,4,7,10 *" → "Every year in Jan, Apr, Jul, Oct on days 1, 15 at 17:30"
Multi-line rules ("0 9 * * 1\n0 17 * * 5") join the per-line
descriptions with " · " for compactness in the list density.
Long DOM lists (>6 days) collapse with a "+N more" tail to keep
the line short; same convention the picker's per-row preview uses.
Unrecognised shapes (e.g. "*/5 * * * *") fall back to the raw
expression — better than swallowing entirely.
2. HEIC/AVIF magic-byte sniffing at upload
----------------------------------------------------------------
The mime-only check we shipped earlier missed iOS Safari's
habit of uploading HEIC photos with Content-Type: image/jpeg.
The file then made it to the bot, where Sharp's HEIF decoder
plugin is missing, the thumbnail extraction failed, and the
message went out without a working preview — read by the user
as "image still not send".
New helper `sniffUnsupportedImage(bytes)` reads bytes 4..11 of
the upload and looks for the ISOBMFF "ftyp" marker followed by
one of the brands Sharp can't decode (HEIF: heic / heix / hevc
/ heim / heis / mif1 / msf1; AVIF: avif / avis). Brand match is
case-insensitive. Plain JPEG / PNG / unrelated ftyp brands like
mp4 are not flagged.
`uploadMediaAction` now runs the sniff against the buffered
bytes before persisting, returning the same "Images are not
supported, please re-upload images" error as the mime path.
3. Sidebar brand link → dashboard tests
----------------------------------------------------------------
Asserts the desktop <aside> contains an <a href="/" aria-label=
"Go to dashboard"> at the top, scoped via a new extractSidebar
helper so it can't accidentally match the mobile-header brand
link (which uses aria-label="Go home"). A second test confirms
the two aria-labels stay distinct.
22 web test files / 232 passing (was 212):
- +12 cron-description cases in lib/recurrence.test.ts
- +6 magic-byte sniff cases in lib/whatsapp-media.test.ts
- +2 sidebar-brand-link cases in app-shell.test.tsx
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce42a89af5
commit
c7a6f5f1b0
@ -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);
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -120,7 +120,7 @@ const FREQ_UNIT: Record<string, string> = {
|
||||
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: "* * <DOWs>"
|
||||
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: "<DOMs> * *"
|
||||
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: "<DOMs> <Months> *"
|
||||
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(" · ");
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<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 =
|
||||
| { ok: true; kind: WaMediaKind; limitBytes: number }
|
||||
| { ok: false; kind: WaMediaKind; limitBytes: number; error: string };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user