From 12eaf57ddb3945f997f9ff1b81cd9ebef18c4fbf Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 24 Apr 2026 02:30:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20Learning=20Hub=20=E2=80=94=20sa?= =?UTF-8?q?nitized=20HTML=20body=20+=20Marp=20slide=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last two Learning Hub follow-ups flagged in the earlier commit body: rich-HTML body rendering (instead of pre-wrap plain text) and in-React Marp slide playback (instead of the legacy-viewer link). client/src/lib/sanitize.ts (new) Inline HTML sanitizer. Parses via DOMParser (sandboxed — no scripts run), walks the tree and: • Removes script / style / iframe / object / embed / link / meta / base / form / input / button / select / textarea. • Drops every on* event-handler attribute. • Drops href / src / xlink:href values starting with javascript: or data:text/html. • Drops any attribute whose value contains "javascript:". • Falls back to entity-escaping the raw string if parsing throws. Admin-authored Learning Hub content is the trust model here — essentially CMS content. A full DOMPurify dep would be strictly better but adding a package requires a network install; the inline sanitizer covers the realistic XSS vectors without the dep bump. client/src/pages/Learning.tsx • Body rendering (non-presentation content_type) now runs through sanitizeHtml + dangerouslySetInnerHTML with a `prose prose-sm` Tailwind-typography class. Markdown/HTML formatting from admin content now appears correctly (headings, lists, code, bold, italics, links) instead of raw text. • SlideViewer component (new) replaces the "Open in legacy viewer" button for content_type === 'presentation'. Fetches /api/learning/content/:slug/slides (returns { css, slides[] } — server-side Marp output), sanitizes each slide's HTML + the CSS block, renders one slide at a time with: - prev/next buttons - keyboard ←/→ and PageUp/PageDown navigation - slide counter (N/total) - fullscreen toggle (Escape exits) Marp's own CSS is injected scoped-ish via sanitizer so slide theming survives. shared/types.ts + client/src/shared/types.ts — additive: LearningSlidesOk { css: string; slides: string[] } Client tsc + vite build clean. Initial bundle unchanged (342.86 kB / 106.03 kB gz) since Learning.tsx was already lazy-loaded; the sanitizer + SlideViewer roll into its chunk. --- client/src/lib/sanitize.ts | 57 ++++++++++++ client/src/pages/Learning.tsx | 92 ++++++++++++++----- client/src/shared/types.ts | 5 + .../{Admin--1-AiSvo.js => Admin-C0HL0O3O.js} | 2 +- ...atchup-B8pTp8ZN.js => Catchup-BtDkOQp-.js} | 2 +- ...ew-BiL3eBLI.js => ChartReview-D6HIVum0.js} | 2 +- ...tion-BOzE18pb.js => Dictation-9xiYhi4N.js} | 2 +- ...nter-Bke3XfMa.js => Encounter-cVLPC9kX.js} | 2 +- ...BhtMwZ57.js => HospitalCourse-DxLtRVyw.js} | 2 +- public/app/assets/Learning-Cgr2RK0s.js | 1 + public/app/assets/Learning-DHnt_HCY.js | 1 - ...eGuide-ZAyIHUEG.js => PeGuide-QOeO6850.js} | 2 +- ...tings-D5OljPbz.js => Settings-P_Y5_PdG.js} | 2 +- ...isit-BhmlIsiq.js => SickVisit-D3m0qxKd.js} | 2 +- .../{Soap-DWeR8vsk.js => Soap-CUwNa7mC.js} | 2 +- ...le-BvmWxXa3.js => VaxSchedule-CHpL4cnR.js} | 2 +- ...isit-vt8QwDLr.js => WellVisit-kzdVHevl.js} | 2 +- public/app/assets/index-CyWXS7DQ.css | 2 + .../{index-B0uH73Hx.js => index-DTb5Y4u0.js} | 4 +- public/app/assets/index-DnJqszjp.css | 2 - public/app/index.html | 4 +- shared/types.ts | 5 + 22 files changed, 155 insertions(+), 42 deletions(-) create mode 100644 client/src/lib/sanitize.ts rename public/app/assets/{Admin--1-AiSvo.js => Admin-C0HL0O3O.js} (99%) rename public/app/assets/{Catchup-B8pTp8ZN.js => Catchup-BtDkOQp-.js} (97%) rename public/app/assets/{ChartReview-BiL3eBLI.js => ChartReview-D6HIVum0.js} (98%) rename public/app/assets/{Dictation-BOzE18pb.js => Dictation-9xiYhi4N.js} (98%) rename public/app/assets/{Encounter-Bke3XfMa.js => Encounter-cVLPC9kX.js} (98%) rename public/app/assets/{HospitalCourse-BhtMwZ57.js => HospitalCourse-DxLtRVyw.js} (98%) create mode 100644 public/app/assets/Learning-Cgr2RK0s.js delete mode 100644 public/app/assets/Learning-DHnt_HCY.js rename public/app/assets/{PeGuide-ZAyIHUEG.js => PeGuide-QOeO6850.js} (99%) rename public/app/assets/{Settings-D5OljPbz.js => Settings-P_Y5_PdG.js} (99%) rename public/app/assets/{SickVisit-BhmlIsiq.js => SickVisit-D3m0qxKd.js} (98%) rename public/app/assets/{Soap-DWeR8vsk.js => Soap-CUwNa7mC.js} (98%) rename public/app/assets/{VaxSchedule-BvmWxXa3.js => VaxSchedule-CHpL4cnR.js} (97%) rename public/app/assets/{WellVisit-vt8QwDLr.js => WellVisit-kzdVHevl.js} (98%) create mode 100644 public/app/assets/index-CyWXS7DQ.css rename public/app/assets/{index-B0uH73Hx.js => index-DTb5Y4u0.js} (99%) delete mode 100644 public/app/assets/index-DnJqszjp.css diff --git a/client/src/lib/sanitize.ts b/client/src/lib/sanitize.ts new file mode 100644 index 0000000..55dabad --- /dev/null +++ b/client/src/lib/sanitize.ts @@ -0,0 +1,57 @@ +// ============================================================ +// Minimal inline HTML sanitizer. Not as thorough as DOMPurify but +// covers the main XSS vectors for CMS-authored Learning Hub +// content (admin-authored, so the trust model is higher than +// user-generated content anyway): +// +// • Strips + - +
diff --git a/shared/types.ts b/shared/types.ts index cc26856..8da666d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -499,6 +499,11 @@ export interface QuizSubmitOk { percentage: number; results: QuizResultEntry[]; } +// /api/learning/content/:slug/slides (Marp rendering) +export interface LearningSlidesOk { + css: string; + slides: string[]; // pre-rendered HTML per slide from the server +} // ── Extensions (pagers/directory) ──────────────────────────── export interface Extension {