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:
yiekheng 2026-05-10 12:59:13 +08:00
parent ce42a89af5
commit c7a6f5f1b0
5 changed files with 343 additions and 2 deletions

View File

@ -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 { validateForWhatsApp } from "@/lib/whatsapp-media"; import { sniffUnsupportedImage, validateForWhatsApp } from "@/lib/whatsapp-media";
async function rateLimit(key: string) { async function rateLimit(key: string) {
const h = await headers(); const h = await headers();
@ -39,6 +39,16 @@ export async function uploadMediaAction(
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
// 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 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);

View File

@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import {
buildRrule, buildRrule,
describeCronRule,
describeRecurrence, describeRecurrence,
isoWeekdayToCron, isoWeekdayToCron,
kindFromRrule, kindFromRrule,
@ -233,3 +234,92 @@ describe("describeRecurrence", () => {
).toBe("Every day, until 2026-06-01"); ).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");
});
});

View File

@ -120,7 +120,7 @@ const FREQ_UNIT: Record<string, string> = {
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string { export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
if (spec.kind === "none") return "One-off"; if (spec.kind === "none") return "One-off";
if (spec.kind === "cron") { 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); const interval = clampInterval(spec.interval);
@ -219,3 +219,140 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind
export function isoWeekdayToCron(iso: number): number { export function isoWeekdayToCron(iso: number): number {
return iso === 7 ? 0 : iso; 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(" · ");
}

View File

@ -3,6 +3,7 @@ import {
classifyMediaKind, classifyMediaKind,
formatBytes, formatBytes,
isUnsupportedImageMime, isUnsupportedImageMime,
sniffUnsupportedImage,
validateForWhatsApp, validateForWhatsApp,
WA_LIMITS, WA_LIMITS,
WA_MAX_BYTES, 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", () => { 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);

View File

@ -67,6 +67,50 @@ export function isUnsupportedImageMime(mimeType: string): boolean {
return UNSUPPORTED_IMAGE_MIMES.has(mimeType.toLowerCase()); 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 };