diff --git a/artifacts/api-server/src/lib/sanitize.ts b/artifacts/api-server/src/lib/sanitize.ts index e1bc40d0..0329cd2d 100644 --- a/artifacts/api-server/src/lib/sanitize.ts +++ b/artifacts/api-server/src/lib/sanitize.ts @@ -1,16 +1,17 @@ import sanitizeHtml from "sanitize-html"; -const ALLOWED_FONT_FAMILIES = new Set([ - "Cairo", - "Tajawal", - "Amiri", - "Noto Naskh Arabic", - "IBM Plex Sans Arabic", - "system-ui", - "sans-serif", - "serif", - "monospace", -]); +// font-family is validated by a strict regex (no custom functions, no +// type-bypass casts) so sanitize-html can run it through its native +// `regex.test(value)` style validator. +// +// Allowed families: Cairo, Tajawal, Amiri, Noto Naskh Arabic, +// IBM Plex Sans Arabic, system-ui, sans-serif, serif, monospace. +// A value may be a single name (optionally quoted) or a comma-separated +// stack of allowed names. +const FONT_NAME_PART = String.raw`(?:["']?(?:Cairo|Tajawal|Amiri|Noto Naskh Arabic|IBM Plex Sans Arabic|system-ui|sans-serif|serif|monospace)["']?)`; +const FONT_FAMILY_RE = new RegExp( + `^${FONT_NAME_PART}(?:\\s*,\\s*${FONT_NAME_PART})*$`, +); const FONT_SIZE_RE = /^(?:[8-9]|[1-9]\d)px$/; const COLOR_RE = /^(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i; @@ -34,11 +35,7 @@ export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = { "text-decoration": [/^(?:none|underline)$/i], "text-align": [/^(?:left|right|center|start|end|justify)$/i], "font-size": [FONT_SIZE_RE], - "font-family": [ - // Allow either an exact known family or a comma-separated list of - // quoted/unquoted families that all match our allowlist. - (value: string) => isAllowedFontFamilyValue(value), - ] as unknown as RegExp[], + "font-family": [FONT_FAMILY_RE], }, }, allowedSchemes: [], @@ -49,14 +46,6 @@ export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = { exclusiveFilter: () => false, }; -function isAllowedFontFamilyValue(raw: string): boolean { - // Split on commas, strip whitespace + surrounding quotes, then check each - // candidate name. If any unknown name appears we reject the whole value. - const parts = raw.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")); - if (parts.length === 0) return false; - return parts.every((p) => ALLOWED_FONT_FAMILIES.has(p)); -} - /** * Sanitize a Tiptap-style rich-text HTML string from a client. * diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index d833e6b1..d22ec4d7 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -951,12 +951,24 @@ router.post( }); } } - // Snapshot the day's existing slots (start_time, end_time) sorted by - // current daily_number. We redistribute them in the new order so the - // first row of the new order takes the earliest existing slot, etc. + // Snapshot the day's existing slots (start_time, end_time) sorted + // by `start_time NULLS LAST, daily_number` — this is the canonical + // schedule order. The first row of the new order takes the earliest + // existing slot, the second takes the next, and so on. const slots = allRows .slice() - .sort((a, b) => a.dailyNumber - b.dailyNumber) + .sort((a, b) => { + const aHasTime = a.startTime != null; + const bHasTime = b.startTime != null; + if (aHasTime && bHasTime) { + if (a.startTime! < b.startTime!) return -1; + if (a.startTime! > b.startTime!) return 1; + return a.dailyNumber - b.dailyNumber; + } + if (aHasTime) return -1; // NULLS LAST + if (bHasTime) return 1; + return a.dailyNumber - b.dailyNumber; + }) .map((r) => ({ startTime: r.startTime, endTime: r.endTime })); // Two-phase update to avoid (date, daily_number) unique conflicts // *during* the renumber. @@ -984,7 +996,9 @@ router.post( .where(eq(executiveMeetingsTable.id, id)); } await logAudit(tx, { - action: "reorder", + // Specific reorder action namespace so audit consumers can filter + // schedule reorders separately from per-row meeting writes. + action: "executive_meeting.reorder", entityType: "meeting", entityId: data.orderedIds[0]!, oldValue: {