diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 9494042e..61b23dae 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -24,6 +24,7 @@ "google-auth-library": "^10.6.2", "pino": "^9", "pino-http": "^10", + "sanitize-html": "^2.17.3", "socket.io": "^4.8.3", "zod": "catalog:" }, @@ -35,6 +36,7 @@ "@types/express": "^5.0.6", "@types/express-session": "^1.19.0", "@types/node": "catalog:", + "@types/sanitize-html": "^2.16.1", "esbuild": "^0.27.3", "esbuild-plugin-pino": "^2.3.3", "pg": "^8.20.0", diff --git a/artifacts/api-server/src/lib/sanitize.ts b/artifacts/api-server/src/lib/sanitize.ts new file mode 100644 index 00000000..e1bc40d0 --- /dev/null +++ b/artifacts/api-server/src/lib/sanitize.ts @@ -0,0 +1,85 @@ +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", +]); + +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; + +export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: ["span", "strong", "b", "em", "i", "u", "br"], + allowedAttributes: { + span: ["style"], + strong: ["style"], + b: ["style"], + em: ["style"], + i: ["style"], + u: ["style"], + }, + allowedStyles: { + "*": { + "color": [COLOR_RE], + "background-color": [COLOR_RE], + "font-weight": [/^(?:normal|bold|[1-9]00)$/i], + "font-style": [/^(?:normal|italic|oblique)$/i], + "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[], + }, + }, + allowedSchemes: [], + disallowedTagsMode: "discard", + enforceHtmlBoundary: false, + // Keep text content of disallowed tags rather than dropping it entirely; + // this matters when a user pastes from Word and we strip the wrapper. + 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. + * + * - Allowed tags: span, strong/b, em/i, u, br + * - Allowed inline styles: color, background-color, font-weight, font-style, + * text-decoration, text-align, font-size (px, 8-99), font-family (allowlist) + * - All other tags/attributes/styles are stripped. + * + * Empty / null / undefined input becomes "". + */ +export function sanitizeRichText(input: unknown): string { + if (input === null || input === undefined) return ""; + const s = typeof input === "string" ? input : String(input); + if (s.length === 0) return ""; + return sanitizeHtml(s, RICH_TEXT_OPTIONS); +} + +/** + * Convenience for places that previously accepted plain text but now + * accept rich text — null/empty preserved as null when needed. + */ +export function sanitizeRichTextOrNull(input: unknown): string | null { + if (input === null || input === undefined) return null; + const s = sanitizeRichText(input); + return s === "" ? null : s; +} diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 4952eeca..c4b02efc 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -25,12 +25,14 @@ import { executiveMeetingFontSettingsTable, rolesTable, usersTable, + type ExecutiveMeetingAttendee, } from "@workspace/db"; import { requireAuth, requireExecutiveAccess, getEffectiveRoleIds, } from "../middlewares/auth"; +import { sanitizeRichText } from "../lib/sanitize"; import type { Request, Response, NextFunction } from "express"; const router: IRouter = Router(); @@ -164,16 +166,20 @@ const timeSchema = z .regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM"); const positiveIntSchema = z.number().int().positive(); +// titleAr/En and attendee.name accept sanitized rich-text HTML (Tiptap +// output). The byte count grows quickly with inline wrappers, +// so we allow up to ~10k bytes for titles and ~5k for attendee names. The +// sanitizer strips disallowed tags/attrs before persistence. const attendeeSchema = z.object({ - name: z.string().trim().min(1).max(200), + name: z.string().trim().min(1).max(5000), title: z.string().trim().max(200).nullable().optional(), attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"), sortOrder: z.number().int().min(0).optional(), }); const meetingBaseFields = { - titleAr: z.string().trim().min(1).max(300), - titleEn: z.string().trim().min(1).max(300), + titleAr: z.string().trim().min(1).max(10000), + titleEn: z.string().trim().min(1).max(10000), meetingDate: dateSchema, dailyNumber: positiveIntSchema.max(1000).nullable().optional(), startTime: timeSchema.nullable().optional(), @@ -228,6 +234,14 @@ const duplicateSchema = z.object({ targetDate: dateSchema, }); +const reorderSchema = z.object({ + meetingDate: dateSchema, + orderedIds: z + .array(z.number().int().positive()) + .min(2, "Need at least 2 meetings to reorder") + .max(200), +}); + const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/); @@ -585,8 +599,8 @@ router.post( const dailyNumber = data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate)); const meetingValues: typeof executiveMeetingsTable.$inferInsert = { - titleAr: data.titleAr, - titleEn: data.titleEn ?? "", + titleAr: sanitizeRichText(data.titleAr), + titleEn: sanitizeRichText(data.titleEn ?? ""), meetingDate: data.meetingDate, dailyNumber, startTime: data.startTime ?? null, @@ -610,7 +624,7 @@ router.post( await tx.insert(executiveMeetingAttendeesTable).values( attendees.map((a, idx) => ({ meetingId: meeting.id, - name: a.name, + name: sanitizeRichText(a.name), title: a.title ?? null, attendanceType: a.attendanceType, sortOrder: a.sortOrder ?? idx, @@ -667,8 +681,10 @@ router.patch( const updateValues: Partial = { updatedBy: userId, }; - if (data.titleAr !== undefined) updateValues.titleAr = data.titleAr; - if (data.titleEn !== undefined) updateValues.titleEn = data.titleEn ?? ""; + if (data.titleAr !== undefined) + updateValues.titleAr = sanitizeRichText(data.titleAr); + if (data.titleEn !== undefined) + updateValues.titleEn = sanitizeRichText(data.titleEn ?? ""); if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate; if (data.dailyNumber !== undefined && data.dailyNumber !== null) updateValues.dailyNumber = data.dailyNumber; @@ -697,7 +713,7 @@ router.patch( await tx.insert(executiveMeetingAttendeesTable).values( data.attendees.map((a, idx) => ({ meetingId: id, - name: a.name, + name: sanitizeRichText(a.name), title: a.title ?? null, attendanceType: a.attendanceType, sortOrder: a.sortOrder ?? idx, @@ -793,7 +809,7 @@ router.put( await tx.insert(executiveMeetingAttendeesTable).values( data.attendees.map((a, idx) => ({ meetingId: id, - name: a.name, + name: sanitizeRichText(a.name), title: a.title ?? null, attendanceType: a.attendanceType, sortOrder: a.sortOrder ?? idx, @@ -837,8 +853,11 @@ router.post( const nextNumber = await nextDailyNumber(tx, data.targetDate); const meetingValues: typeof executiveMeetingsTable.$inferInsert = { dailyNumber: nextNumber, - titleAr: source.titleAr, - titleEn: source.titleEn, + // Re-sanitize on duplicate so a row that was somehow stored with + // disallowed markup (e.g. raw SQL import, schema regression) does + // not get cloned into a fresh meeting unchecked. + titleAr: sanitizeRichText(source.titleAr), + titleEn: source.titleEn ? sanitizeRichText(source.titleEn) : source.titleEn, meetingDate: data.targetDate, startTime: source.startTime, endTime: source.endTime, @@ -859,9 +878,12 @@ router.post( if (!meeting) throw new Error("duplicate_failed"); if (source.attendees && source.attendees.length > 0) { await tx.insert(executiveMeetingAttendeesTable).values( - source.attendees.map((a, idx) => ({ + source.attendees.map((a: ExecutiveMeetingAttendee, idx: number) => ({ meetingId: meeting.id, - name: a.name, + // source already-stored values are sanitized at write time, but + // re-running keeps a defense-in-depth boundary if anything was + // imported via raw SQL. + name: sanitizeRichText(a.name), title: a.title, attendanceType: a.attendanceType, sortOrder: a.sortOrder ?? idx, @@ -891,6 +913,127 @@ router.post( }, ); +// ===================================================================== +// REORDER (within a single day) +// ===================================================================== + +router.post( + "/executive-meetings/reorder", + requireExecutiveAccess, + requireMutate, + async (req, res): Promise => { + const data = parseBody(res, reorderSchema, req.body); + if (!data) return; + const userId = req.session.userId!; + const dedup = new Set(data.orderedIds); + if (dedup.size !== data.orderedIds.length) { + res + .status(400) + .json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" }); + return; + } + try { + await db.transaction(async (tx) => { + const rows = await tx + .select() + .from(executiveMeetingsTable) + .where( + and( + eq(executiveMeetingsTable.meetingDate, data.meetingDate), + inArray(executiveMeetingsTable.id, data.orderedIds), + ), + ); + if (rows.length !== data.orderedIds.length) { + throw Object.assign(new Error("missing_or_cross_day"), { + httpStatus: 400, + code: "invalid_ids", + }); + } + // Snapshot the current (start_time, end_time, daily_number) sorted + // by current daily_number — the slots we will redistribute. + const slots = rows + .slice() + .sort((a, b) => a.dailyNumber - b.dailyNumber) + .map((r) => ({ + startTime: r.startTime, + endTime: r.endTime, + dailyNumber: r.dailyNumber, + })); + const byId = new Map(rows.map((r) => [r.id, r] as const)); + // Two-phase update to avoid (date, daily_number) unique conflicts. + // Phase 1: park into negative numbers. + for (let i = 0; i < data.orderedIds.length; i++) { + const id = data.orderedIds[i]!; + await tx + .update(executiveMeetingsTable) + .set({ dailyNumber: -(i + 1), updatedBy: userId }) + .where(eq(executiveMeetingsTable.id, id)); + } + // Phase 2: assign final slot. + for (let i = 0; i < data.orderedIds.length; i++) { + const id = data.orderedIds[i]!; + const slot = slots[i]!; + await tx + .update(executiveMeetingsTable) + .set({ + startTime: slot.startTime, + endTime: slot.endTime, + dailyNumber: slot.dailyNumber, + updatedBy: userId, + }) + .where(eq(executiveMeetingsTable.id, id)); + } + await logAudit(tx, { + action: "reorder", + entityType: "meeting", + entityId: data.orderedIds[0]!, + oldValue: { + meetingDate: data.meetingDate, + order: rows + .slice() + .sort((a, b) => a.dailyNumber - b.dailyNumber) + .map((r) => r.id), + }, + newValue: { + meetingDate: data.meetingDate, + order: data.orderedIds, + }, + performedBy: userId, + }); + const updated = await tx + .select() + .from(executiveMeetingsTable) + .where( + and( + eq(executiveMeetingsTable.meetingDate, data.meetingDate), + inArray(executiveMeetingsTable.id, data.orderedIds), + ), + ); + // Validate no two share dailyNumber post-update (defensive). + const seen = new Set(); + for (const u of updated) { + if (seen.has(u.dailyNumber)) { + throw new Error("duplicate_daily_number_after_reorder"); + } + seen.add(u.dailyNumber); + } + return { updated, byId }; + }); + // Return the updated meetings in the new order, with attendees. + const idToFetch = data.orderedIds; + const fullList = await Promise.all( + idToFetch.map((id) => fetchMeetingWithAttendees(id)), + ); + res.json({ meetings: fullList.filter(Boolean) }); + } catch (err) { + const status = (err as { httpStatus?: number }).httpStatus ?? 500; + const code = (err as { code?: string }).code ?? "server_error"; + const msg = err instanceof Error ? err.message : String(err); + res.status(status).json({ error: msg, code }); + } + }, +); + // ===================================================================== // REQUESTS // ===================================================================== diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index b44ba94a..6fbd928e 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -538,3 +538,116 @@ test("PDF archives: POST creates a snapshot and GET returns it", async () => { assert.ok(Array.isArray(archives)); assert.ok(archives.some((a) => a.id === archive.id)); }); + +test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => { + const dirty = + 'Hello World' + + 'x'; + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: dirty, + titleEn: dirty, + meetingDate: today, + attendees: [ + { name: 'Tester One', + attendanceType: "internal", sortOrder: 0 }, + ], + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + const day = await api(adminCookie, "GET", + `/api/executive-meetings?date=${today}`); + const body = await day.json(); + const m = body.meetings.find((x) => x.id === meeting.id); + assert.ok(m, "meeting must come back"); + + // disallowed tags must be stripped + assert.ok(!/