diff --git a/assets/deck-themes.json b/assets/deck-themes.json new file mode 100644 index 00000000..bbd1b38d --- /dev/null +++ b/assets/deck-themes.json @@ -0,0 +1,40 @@ +{ + "_comment": "A theme is a palette and a typeface, nothing more. Every slide type already draws from these five names, so adding one here restyles the whole deck with no change to any slide builder. Colours are hex without a leading #, the form python-pptx wants.", + "themes": [ + { + "id": "clinical-blue", + "name": "Clinical Blue", + "description": "The default. Calm, high-contrast, reads well on a projector.", + "accent": "2563EB", "ink": "1F2937", "muted": "4B5563", + "rule": "E5E7EB", "paper": "FFFFFF", "font": "Calibri" + }, + { + "id": "teaching-amber", + "name": "Teaching Amber", + "description": "Warmer and less clinical. Good for sessions with parents or students.", + "accent": "D97706", "ink": "1C1917", "muted": "57534E", + "rule": "EDE9E4", "paper": "FFFFFF", "font": "Calibri" + }, + { + "id": "ward-teal", + "name": "Ward Teal", + "description": "Quieter than blue, still clearly clinical.", + "accent": "0F766E", "ink": "134E4A", "muted": "4B5563", + "rule": "E3EDEB", "paper": "FFFFFF", "font": "Calibri" + }, + { + "id": "slate", + "name": "Slate", + "description": "Almost monochrome. Lets figures and tables carry the colour.", + "accent": "475569", "ink": "0F172A", "muted": "64748B", + "rule": "E2E8F0", "paper": "FFFFFF", "font": "Calibri" + }, + { + "id": "high-contrast", + "name": "High Contrast", + "description": "For a bright room or a poor projector. Heavier ink, stronger rules.", + "accent": "B91C1C", "ink": "000000", "muted": "27272A", + "rule": "A1A1AA", "paper": "FFFFFF", "font": "Calibri" + } + ] +} diff --git a/public/components/my-resources.html b/public/components/my-resources.html index a163086a..92c498ed 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -42,6 +42,16 @@ + + +
Draw on
diff --git a/public/js/myResources.js b/public/js/myResources.js index ad725d16..0d071b36 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -144,6 +144,30 @@ } if (modelRow) modelRow.hidden = models.length < 2; + // The theme catalogue. Structural slides carry no colour of their own, + // so this is the whole of a deck's styling — one choice rather than a + // colour field the model would have to guess a value for. + var themeSelect = document.getElementById('mr-theme'); + var themeRow = document.getElementById('mr-theme-row'); + var themes = Array.isArray(data.themes) ? data.themes : []; + if (themeSelect && themes.length) { + var chosenTheme = themeSelect.value; + themeSelect.textContent = ''; + themes.forEach(function (t) { + var option = document.createElement('option'); + option.value = t.id; + option.textContent = t.name; + option.title = t.description || ''; + themeSelect.appendChild(option); + }); + if (chosenTheme && themes.some(function (t) { return t.id === chosenTheme; })) { + themeSelect.value = chosenTheme; + } + describeTheme(themes); + themeSelect.onchange = function () { describeTheme(themes); }; + } + if (themeRow) themeRow.hidden = themes.length < 2; + // Hidden entirely unless an administrator enabled it, so an option // never appears that someone could tick and then be refused. Both // groups are driven from the same answer: Generate and Modify offer @@ -184,6 +208,15 @@ // when the tab was reopened, looking like output for a topic nobody had // typed. It cleared on a full page refresh and only then, which is why it // read as a leak. + // Says what the chosen theme is for, which is the part a name cannot carry. + function describeTheme(themes) { + var select = document.getElementById('mr-theme'); + var hint = document.getElementById('mr-theme-hint'); + if (!select || !hint) return; + var chosen = themes.filter(function (t) { return t.id === select.value; })[0]; + hint.textContent = chosen ? (chosen.description || '') : ''; + } + function clearResults() { ['mr-images', 'mr-searches', 'mr-image-failures'].forEach(function (id) { var el = document.getElementById(id); @@ -213,6 +246,7 @@ refinement: (document.getElementById('mr-refinement') || {}).value || '', useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true', model: (document.getElementById('mr-model') || {}).value || '', + theme: (document.getElementById('mr-theme') || {}).value || '', withImages: (document.getElementById('mr-with-images') || {}).checked ? 'true' : 'false', withWebSearch: (document.getElementById('mr-web-search') || {}).checked ? 'true' : 'false', withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false' diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py index 499fadff..ee7f79a8 100644 --- a/scripts/render_pptx.py +++ b/scripts/render_pptx.py @@ -51,6 +51,13 @@ BODY_TOP = HEADING_TOP + HEADING_H + Emu(228600) # clears the accent rule BODY_H = SLIDE_H - BODY_TOP - MARGIN BODY_W = SLIDE_W - MARGIN * 2 +# The palette. Every slide builder reads these five names and the font, which is +# what makes theming a rebinding rather than a rewrite: apply_theme() below +# reassigns them once, and a comparison slide, a table header and a callout card +# all follow without a line changing in any builder. +# +# These values are the clinical-blue theme, kept as the literal default so the +# renderer still works standalone with no theme and no catalogue on disk. INK = RGBColor(0x1F, 0x29, 0x37) MUTED = RGBColor(0x4B, 0x55, 0x63) ACCENT = RGBColor(0x25, 0x63, 0xEB) @@ -59,6 +66,49 @@ PAPER = RGBColor(0xFF, 0xFF, 0xFF) FONT = "Calibri" +THEMES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "assets", "deck-themes.json") + + +def _rgb(value): + """'2563EB' -> RGBColor. Raises on anything that is not six hex digits.""" + text = str(value).lstrip("#") + if len(text) != 6: + raise ValueError("colour must be six hex digits: %r" % (value,)) + return RGBColor(int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)) + + +def load_themes(): + """The catalogue, or an empty list if it cannot be read.""" + try: + with open(THEMES_PATH, "r", encoding="utf-8") as handle: + return json.load(handle).get("themes") or [] + except Exception: + # A deck must still render with no catalogue: the defaults above stand. + return [] + + +def apply_theme(theme_id): + """Rebind the palette. An unknown or missing id leaves the default in place, + because a deck rendering in the wrong colours beats a deck not rendering.""" + if not theme_id: + return None + for theme in load_themes(): + if theme.get("id") != theme_id: + continue + global INK, MUTED, ACCENT, RULE, PAPER, FONT + try: + INK = _rgb(theme["ink"]) + MUTED = _rgb(theme["muted"]) + ACCENT = _rgb(theme["accent"]) + RULE = _rgb(theme["rule"]) + PAPER = _rgb(theme["paper"]) + except Exception: + return None + FONT = theme.get("font") or FONT + return theme + return None + # Text is sized to fit rather than left to the renderer's autofit, which only # some viewers honour and which LibreOffice ignores entirely when converting to # PDF — the reason slides were being cut off mid-sentence. @@ -655,6 +705,7 @@ def main(): print("usage: render_pptx.py (spec on stdin)", file=sys.stderr) return 2 spec = json.load(sys.stdin) + apply_theme(spec.get("theme")) prs = Presentation() prs.slide_width = SLIDE_W diff --git a/src/routes/myResources.js b/src/routes/myResources.js index bc11c560..8337a573 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -213,6 +213,9 @@ router.get('/my-resources/options', async function (req, res) { models: models.allowed, defaultModel: models.configured, imagesAvailable: Boolean(await db.getSetting('clinical_assistant.image_model', '')), + themes: deckSchema.themes().map(function (t) { + return { id: t.id, name: t.name, description: t.description || '' }; + }), webSearchAvailable: await webSearch.isAvailable(), pubmedAvailable: await pubmedSearch.isAvailable() }); @@ -353,6 +356,9 @@ router.post('/my-resources/generate', async function (req, res) { // guessed at: the shape vocabulary should grow from real demand. var vocabularyGaps = []; var deck = deckMode ? deckBuild.parse(ai && ai.content, vocabularyGaps) : null; + // Chosen by the author, not the model: a theme is a look, and the person + // making the deck is the one who knows the room it will be shown in. + if (deck) deck.theme = deckSchema.themeId(req.body.theme); reportVocabularyGaps(vocabularyGaps, topic); // Why a deck became plain slides, when it did. Reported to the caller as // well as logged: the fallback produces a usable but plainer deck, and @@ -532,6 +538,36 @@ router.put('/my-resources/:id', async function (req, res) { } }); +// Re-skin, not re-generate. Export renders from the stored deck every time, so +// changing the theme is a column write and the next download looks different. +// No model call, nothing regenerated, nothing that can reword a slide. +router.put('/my-resources/:id/theme', async function (req, res) { + try { + var theme = deckSchema.themeId(req.body.theme); + if (!theme && String(req.body.theme || '').trim()) { + return res.status(400).json({ error: 'That is not one of the available themes.' }); + } + var row = await db.get( + 'SELECT id, deck FROM user_resources WHERE id = ? AND user_id = ?', + [parseInt(req.params.id, 10), req.user.id] + ); + if (!row) return res.status(404).json({ error: 'Not found' }); + if (!row.deck) { + return res.status(400).json({ error: 'This resource has no slide layout, so it has no theme to change.' }); + } + var deck = typeof row.deck === 'string' ? JSON.parse(row.deck) : row.deck; + deck.theme = theme; + await db.run( + 'UPDATE user_resources SET deck = ?::jsonb, updated_at = NOW() WHERE id = ? AND user_id = ?', + [JSON.stringify(deck), row.id, req.user.id] + ); + res.json({ success: true, theme: theme }); + } catch (err) { + logger.warn('[my-resources] theme', { error: err.message }); + res.status(500).json({ error: 'Could not change the theme' }); + } +}); + router.post('/my-resources/:id/refine', async function (req, res) { try { var instructions = String(req.body.instructions || '').trim(); diff --git a/src/utils/clinicalConversation.js b/src/utils/clinicalConversation.js index 987cb907..4d1abfbc 100644 --- a/src/utils/clinicalConversation.js +++ b/src/utils/clinicalConversation.js @@ -109,6 +109,7 @@ const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; const MAX_ATTACHMENT_TOTAL_BYTES = 10 * 1024 * 1024; const MAX_ATTACHMENT_NAME = 255; const CANONICAL_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; +const fileType = require('./fileType'); function validateAttachments(images) { if (images === undefined || images === null) return []; @@ -132,6 +133,15 @@ function validateAttachments(images) { } if (decoded.length === 0) throw failure('Image attachments must not be empty.', 400, 'INVALID_ATTACHMENTS'); if (decoded.length > MAX_ATTACHMENT_BYTES) throw failure('Each image attachment is limited to 5 MiB.', 400, 'INVALID_ATTACHMENTS'); + // The bytes have to be the thing the caller says they are. Until now the + // MIME type was taken on trust, so anything at all could be posted as + // image/png: it would be stored in the saved chat, and handed to a provider + // as a data URI. Documents and S3 uploads have always been sniffed; this + // path was the one that was not. Same helper, so there is one idea of what + // a PNG looks like. + if (!fileType.matches(image.mimeType, decoded)) { + throw failure('That file is not the image type it claims to be.', 400, 'INVALID_ATTACHMENTS'); + } total += decoded.length; if (total > MAX_ATTACHMENT_TOTAL_BYTES) throw failure('Image attachments are limited to 10 MiB in total.', 400, 'INVALID_ATTACHMENTS'); return { dataBase64: image.dataBase64, mimeType: image.mimeType }; diff --git a/src/utils/deckSchema.js b/src/utils/deckSchema.js index a48653c2..52664d69 100644 --- a/src/utils/deckSchema.js +++ b/src/utils/deckSchema.js @@ -93,6 +93,24 @@ function bullets(list) { * Accept only what the renderer can draw, and never throw. A model that returns * one malformed slide should cost that slide, not the deck. */ +// The themes the renderer knows, read from the same catalogue it reads, so the +// two cannot disagree about what exists. +var themeCache = null; +function themes() { + if (themeCache) return themeCache; + try { + var file = require('path').join(__dirname, '..', '..', 'assets', 'deck-themes.json'); + themeCache = JSON.parse(require('fs').readFileSync(file, 'utf8')).themes || []; + } catch (e) { themeCache = []; } + return themeCache; +} + +function themeId(value) { + var id = String(value || '').trim(); + if (!id) return ''; + return themes().some(function (t) { return t.id === id; }) ? id : ''; +} + function normalise(raw, gaps) { var deck = raw && typeof raw === 'object' ? raw : {}; var slides = []; @@ -164,6 +182,12 @@ function normalise(raw, gaps) { title: text(deck.title, 200), subtitle: text(deck.subtitle, 200), date: text(deck.date, 60), + // The deck object is the renderer's spec, so a theme carried here reaches + // render_pptx.py with nothing in between to thread it through. Validated + // against the catalogue rather than passed on trust: an id the renderer + // does not know would silently fall back, and a stored deck would then + // claim a theme it never had. + theme: themeId(deck.theme), slides: slides }; } @@ -243,4 +267,6 @@ function figureRequests(deck) { return wanted; } -module.exports = { instructions, normalise, toMarkdown, figureRequests, VALID }; +module.exports = { + themes: themes, + themeId: themeId, instructions, normalise, toMarkdown, figureRequests, VALID }; diff --git a/test/assistant-attachment-roundtrip.test.js b/test/assistant-attachment-roundtrip.test.js index 52453a80..d24896fc 100644 --- a/test/assistant-attachment-roundtrip.test.js +++ b/test/assistant-attachment-roundtrip.test.js @@ -11,9 +11,22 @@ const answer = require('../src/utils/clinicalAnswer'); const root = path.join(__dirname, '..'); const read = file => fs.readFileSync(path.join(root, file), 'utf8'); const quiet = { log() {}, warn() {}, error() {}, info() {} }; -const canonical = (length, fill = 7) => Buffer.alloc(length, fill).toString('base64'); +// Real file headers, padded. Attachments are sniffed now, so a buffer of 0x07 +// is not an image and is correctly refused — a fixture has to be the thing it +// claims to be, exactly like a real upload. +const HEADERS = { + 'image/png': Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'), + 'image/jpeg': Buffer.from('ffd8ffe000104a4649460001', 'hex'), + 'image/webp': Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP')]) +}; +function image(mimeType, length) { + const head = HEADERS[mimeType]; + const total = Math.max(length, head.length); + return Buffer.concat([head, Buffer.alloc(total - head.length, 7)]).toString('base64'); +} +const canonical = (length, fill = 7) => image('image/png', length); const png = { dataBase64: canonical(16), mimeType: 'image/png', name: 'chest.png' }; -const jpeg = { dataBase64: canonical(32), mimeType: 'image/jpeg', name: 'knee.jpeg' }; +const jpeg = { dataBase64: image('image/jpeg', 32), mimeType: 'image/jpeg', name: 'knee.jpeg' }; const asset = '/api/generated-images/12345678-1234-1234-1234-123456789abc'; function server(options = {}) { diff --git a/test/assistant-image-attachments.test.js b/test/assistant-image-attachments.test.js index c279ca53..b9c1f78b 100644 --- a/test/assistant-image-attachments.test.js +++ b/test/assistant-image-attachments.test.js @@ -12,10 +12,23 @@ const root = path.join(__dirname, '..'); const read = file => fs.readFileSync(path.join(root, file), 'utf8'); const quiet = { log() {}, warn() {}, error() {}, info() {} }; const b64 = bytes => Buffer.from(bytes).toString('base64'); -const canonical = (length, fill = 7) => Buffer.alloc(length, fill).toString('base64'); -const png = { dataBase64: canonical(16), mimeType: 'image/png' }; -const jpeg = { dataBase64: canonical(32), mimeType: 'image/jpeg' }; -const webp = { dataBase64: canonical(48), mimeType: 'image/webp' }; +// Real file headers, padded to length. Attachments are sniffed now, so a buffer +// of 0x07 is not an image and is correctly refused — these fixtures have to be +// the thing they claim to be, exactly like a real upload. +const HEADERS = { + 'image/png': Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'), + 'image/jpeg': Buffer.from('ffd8ffe000104a4649460001', 'hex'), + 'image/webp': Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP')]) +}; +function image(mimeType, length) { + const head = HEADERS[mimeType]; + const total = Math.max(length, head.length); + return Buffer.concat([head, Buffer.alloc(total - head.length, 7)]).toString('base64'); +} +const canonical = (length, fill = 7) => image('image/png', length); +const png = { dataBase64: image('image/png', 16), mimeType: 'image/png' }; +const jpeg = { dataBase64: image('image/jpeg', 32), mimeType: 'image/jpeg' }; +const webp = { dataBase64: image('image/webp', 48), mimeType: 'image/webp' }; function server(options = {}) { const calls = { ai: [], search: [], writes: [], health: [] }; @@ -84,7 +97,7 @@ function server(options = {}) { test('attachment policy validates MIME, canonical base64, per-image/count/total limits without provider contact', async () => { const oversize = { dataBase64: canonical(5 * 1024 * 1024 + 1), mimeType: 'image/png' }; - const big = { dataBase64: canonical(4 * 1024 * 1024), mimeType: 'image/jpeg' }; + const big = { dataBase64: image('image/jpeg', 4 * 1024 * 1024), mimeType: 'image/jpeg' }; const invalid = [ { images: [{ ...png, mimeType: 'image/svg+xml' }] }, { images: [{ ...png, mimeType: 'image/gif' }] }, @@ -115,14 +128,14 @@ test('attachment policy validates MIME, canonical base64, per-image/count/total test('valid images are normalized, ride the outgoing question only, and are excluded from the UTF-16 text budget', async () => { const app = server({ limit: '1000' }); - const images = [{ ...png, extra: 'ignored' }, jpeg, webp, { dataBase64: canonical(64), mimeType: 'image/webp' }]; + const images = [{ ...png, extra: 'ignored' }, jpeg, webp, { dataBase64: image('image/webp', 64), mimeType: 'image/webp' }]; const question = 'What about monitoring?'; const result = await app.request('post', '/clinical-assistant/chat', { message: question, history: [{ role: 'user', content: 'x'.repeat(500) }], images }); assert.equal(result.statusCode, 200); assert.equal(app.calls.search.length, 1); assert.equal(app.calls.ai.length, 2, 'rewrite plus generation'); const generation = app.calls.ai.at(-1); - assert.deepEqual(generation.settings.images, [png, jpeg, webp, { dataBase64: canonical(64), mimeType: 'image/webp' }], 'normalized, extra fields dropped'); + assert.deepEqual(generation.settings.images, [png, jpeg, webp, { dataBase64: image('image/webp', 64), mimeType: 'image/webp' }], 'normalized, extra fields dropped'); assert.equal(typeof generation.messages[1].content, 'string', 'the route keeps prompt text; ai.js builds multimodal parts'); assert.ok(generation.messages[1].content.includes(question)); assert.equal(app.calls.ai[0].settings.images, undefined, 'the retrieval rewrite is text-only'); diff --git a/test/attachment-magic-bytes.test.js b/test/attachment-magic-bytes.test.js new file mode 100644 index 00000000..798f8180 --- /dev/null +++ b/test/attachment-magic-bytes.test.js @@ -0,0 +1,60 @@ +// An attachment has to be the thing it says it is. The MIME type used to be +// taken on trust here, so anything could be posted as image/png: stored in the +// saved chat, and handed to a provider as a data URI. Documents and S3 uploads +// were always sniffed; this was the path that was not. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { validateAttachments } = require('../src/utils/clinicalConversation'); + +const b64 = buf => Buffer.from(buf).toString('base64'); +const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'); +const JPEG = Buffer.from('ffd8ffe000104a464946', 'hex'); +const WEBP = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP'), Buffer.alloc(4)]); + +const attach = (mimeType, buf) => validateAttachments([{ mimeType, dataBase64: b64(buf) }]); +const refuses = (mimeType, buf, why) => assert.throws( + () => attach(mimeType, buf), + err => { assert.match(err.message, /not the image type it claims to be/); return true; }, why); + +test('a genuine image of each accepted type passes', () => { + assert.equal(attach('image/png', PNG).length, 1); + assert.equal(attach('image/jpeg', JPEG).length, 1); + assert.equal(attach('image/webp', WEBP).length, 1); +}); + +test('executable and script payloads labelled as images are refused', () => { + refuses('image/png', '', 'php'); + refuses('image/png', '#!/bin/sh\nrm -rf /', 'shell script'); + refuses('image/jpeg', '', 'html/js'); + refuses('image/png', Buffer.from('4d5a90000300', 'hex'), 'a Windows executable'); + refuses('image/png', Buffer.from('7f454c46', 'hex'), 'an ELF binary'); +}); + +test('a real file of the wrong type is refused, not just junk', () => { + refuses('image/png', '%PDF-1.4 trailing', 'a PDF called a PNG'); + refuses('image/png', Buffer.from('504b0304', 'hex'), 'a zip/docx called a PNG'); + refuses('image/webp', PNG, 'a PNG called a WebP'); + refuses('image/jpeg', PNG, 'a PNG called a JPEG'); +}); + +test('a polyglot that merely starts with image bytes is still only that image', () => { + // A PNG header followed by script text sniffs as PNG and is accepted — which + // is correct: it IS a PNG. The protection is that it is never executed and + // never served as anything but an image, not that payloads cannot be hidden + // inside valid image bytes, which no sniffer can promise. + assert.equal(attach('image/png', Buffer.concat([PNG, Buffer.from('')])).length, 1); +}); + +test('the size and count limits still hold, and are checked before the bytes are read', () => { + assert.throws(() => validateAttachments(new Array(5).fill({ mimeType: 'image/png', dataBase64: b64(PNG) })), + /maximum of 4 images/); + const big = Buffer.concat([PNG, Buffer.alloc(5 * 1024 * 1024)]); + assert.throws(() => attach('image/png', big), /limited to 5 MiB/); +}); + +test('a type outside the allowlist never reaches the sniffer', () => { + assert.throws(() => attach('application/pdf', Buffer.from('%PDF-1.4')), + /Only PNG, JPEG and WebP/); + assert.throws(() => attach('image/svg+xml', Buffer.from('')), + /Only PNG, JPEG and WebP/); +});