diff --git a/Dockerfile b/Dockerfile index 9234fa58..25149fc1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,13 @@ WORKDIR /app # ffmpeg: audio conversion for AWS Transcribe (WebM → PCM) # curl: HTTP helper used by the OpenBao entrypoint and health/debug tooling # jq: JSON parsing for the entrypoint's OpenBao secret-fetch step -RUN apk add --no-cache ffmpeg curl jq +# pandoc: Markdown → PPTX for Learning resources. It is large (~230MB), and it +# is here rather than in a sidecar because a sidecar would add a +# cross-stack network dependency to an export that must not fail for +# reasons outside this container. It also measures images, which +# pptxgenjs cannot: that library emits the target box verbatim with +# , so every image in every generated deck was distorted. +RUN apk add --no-cache ffmpeg curl jq pandoc-cli # Pull the bao CLI out of the upstream image — matches host arch because # buildx pulls the right manifest-list variant per build. diff --git a/assets/learning/slides-reference.pptx b/assets/learning/slides-reference.pptx new file mode 100644 index 00000000..4665c8c1 Binary files /dev/null and b/assets/learning/slides-reference.pptx differ diff --git a/package-lock.json b/package-lock.json index e3535249..68342546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "express": "^4.21.0", "express-rate-limit": "^7.4.0", "helmet": "^8.0.0", - "image-size": "^2.0.2", "jsonwebtoken": "^9.0.2", "katex": "^0.18.7", "mammoth": "^1.8.0", @@ -39,7 +38,6 @@ "openid-client": "^6.8.2", "pdf-parse": "^1.1.1", "pg": "^8.13.0", - "pptxgenjs": "^4.0.1", "prom-client": "^15.1.3", "qrcode": "^1.5.4", "redis": "^4.7.1", @@ -5056,12 +5054,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", - "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", - "license": "ISC" - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -5122,18 +5114,6 @@ "node": ">=0.10.0" } }, - "node_modules/image-size": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", - "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", - "license": "MIT", - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -6481,33 +6461,6 @@ "node": ">=0.10.0" } }, - "node_modules/pptxgenjs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz", - "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", - "license": "MIT", - "dependencies": { - "@types/node": "^22.8.1", - "https": "^1.0.0", - "image-size": "^1.2.1", - "jszip": "^3.10.1" - } - }, - "node_modules/pptxgenjs/node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/pptxgenjs/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/package.json b/package.json index f3a54c11..13e01ed6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "express": "^4.21.0", "express-rate-limit": "^7.4.0", "helmet": "^8.0.0", - "image-size": "^2.0.2", "jsonwebtoken": "^9.0.2", "katex": "^0.18.7", "mammoth": "^1.8.0", @@ -47,7 +46,6 @@ "openid-client": "^6.8.2", "pdf-parse": "^1.1.1", "pg": "^8.13.0", - "pptxgenjs": "^4.0.1", "prom-client": "^15.1.3", "qrcode": "^1.5.4", "redis": "^4.7.1", @@ -77,8 +75,7 @@ }, "speech-rule-engine": { "@xmldom/xmldom": "0.9.12" - }, - "image-size": "^2.0.2" + } }, "engines": { "node": "24.x" diff --git a/src/routes/learningAI.js b/src/routes/learningAI.js index 040e8462..4f2baab5 100644 --- a/src/routes/learningAI.js +++ b/src/routes/learningAI.js @@ -551,314 +551,60 @@ router.post('/webdav-path', require('../utils/policy').requireFeature('nextcloud }); // ── POST /api/admin/learning/generate-pptx ────────────────── -// Fit an image inside a box without distorting it. -// -// PowerPoint scales an image to whatever extent it is given, so passing the box -// straight through stretches anything whose aspect ratio differs — which is -// every generated image, since they are square or portrait and the content area -// is wide. pptxgenjs offers a `sizing` option that looks like it solves this, -// but it emits the box dimensions unchanged with ; it cannot do -// better, because it never measures the image. -// -// So measure it here and hand PowerPoint an extent that already has the right -// shape, centred in the space available. An image that cannot be measured keeps -// the old behaviour rather than failing an export. -// The parsers this application will run, and only these. -// -// image-size ships twenty formats. Three of them — ICNS, JXL and HEIF — have -// open denial-of-service advisories against every published version -// (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq): a crafted file spins forever in -// the parser. There is no fixed release to upgrade to, so the answer is not to -// run them. Slide images come from the generator as PNG, JPEG or WebP. -// -// Done once, at load, rather than per call: disableTypes replaces the enabled -// set, so calling it repeatedly would be wasted work and easy to get wrong. -var imageSizeReady = false; -function measurer() { - var lib = require('image-size'); - if (!imageSizeReady) { - var keep = { png: 1, jpg: 1, webp: 1, gif: 1 }; - lib.disableTypes((lib.types || []).filter(function (t) { return !keep[t]; })); - imageSizeReady = true; - } - return lib.imageSize; -} - -function fitImage(bytes, x, y, maxW, maxH) { - var fallback = { x: x, y: y, w: maxW, h: maxH }; - if (!bytes || !bytes.length) return fallback; - try { - var dims = measurer()(bytes); - if (!dims || !dims.width || !dims.height) return fallback; - var scale = Math.min(maxW / dims.width, maxH / dims.height); - var w = dims.width * scale; - var h = dims.height * scale; - return { x: x + (maxW - w) / 2, y: y + (maxH - h) / 2, w: w, h: h }; - } catch (e) { - return fallback; - } -} - -// Convert Marp markdown to PPTX using pptxgenjs (no browser needed) +// pandoc measures images itself and emits an extent with the right shape, so +// nothing here needs to size them. router.post('/generate-pptx', async function(req, res) { + var workdir = null; try { var { markdown, title } = req.body; if (!markdown) return res.status(400).json({ error: 'markdown required' }); - var assetImages = {}; - // Kept alongside the data URI so the image can be measured. pptxgenjs cannot - // do it: its sizing option emits the box dimensions verbatim with - // , so a portrait image handed to a landscape box comes out - // stretched. Its own getSizeFromImage is commented out and marked unused. - var assetImageBytes = {}; - for (const id of require('../utils/generatedImageLinks').references(markdown)) { - const image = await require('../utils/generatedImages').service().asset(id, req.user); - assetImages['/api/generated-images/' + id] = 'data:' + image.mime + ';base64,' + image.bytes.toString('base64'); - assetImageBytes['/api/generated-images/' + id] = image.bytes; - } - var PptxGenJS = require('pptxgenjs'); - var pptx = new PptxGenJS(); + var fsp = require('fs/promises'); + var os = require('os'); + var pathMod = require('path'); + var { execFile } = require('child_process'); - pptx.layout = 'LAYOUT_WIDE'; // 16:9 + workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'pptx-')); - // ── Helpers ──────────────────────────────────────────────── - - // Parse inline markdown (**bold**, *italic*, `code`, ***both***) into pptxgenjs text objects - function parseInline(text, defaults) { - var parts = []; - var re = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g; - var last = 0; - var m; - while ((m = re.exec(text)) !== null) { - if (m.index > last) parts.push({ text: text.slice(last, m.index), options: Object.assign({}, defaults) }); - if (m[2]) parts.push({ text: m[2], options: Object.assign({}, defaults, { bold: true, italic: true }) }); - else if (m[3]) parts.push({ text: m[3], options: Object.assign({}, defaults, { bold: true }) }); - else if (m[4]) parts.push({ text: m[4], options: Object.assign({}, defaults, { italic: true }) }); - else if (m[5]) parts.push({ text: m[5], options: Object.assign({}, defaults, { fontFace: 'Courier New', color: '7c3aed' }) }); - last = m.index + m[0].length; - } - if (last < text.length) parts.push({ text: text.slice(last), options: Object.assign({}, defaults) }); - return parts.length ? parts : [{ text: text, options: Object.assign({}, defaults) }]; + // Only images this user owns, written beside the markdown under names we + // choose. Everything else is stripped: pandoc resolves an image link + // against the filesystem, so a link naming any local path would embed that + // file into the deck. The allow-list is the set we just fetched by id. + var allowed = Object.create(null); + var refs = require('../utils/generatedImageLinks').references(markdown); + for (var r = 0; r < refs.length; r++) { + var id = refs[r]; + var image = await require('../utils/generatedImages').service().asset(id, req.user); + var ext = String(image.mime || '').indexOf('png') !== -1 ? 'png' : 'jpg'; + var file = 'img-' + r + '.' + ext; + await fsp.writeFile(pathMod.join(workdir, file), image.bytes); + allowed['/api/generated-images/' + id] = file; } - // Parse a markdown table block into { headers: string[], rows: string[][] } - function parseTable(lines) { - var headers = lines[0].split('|').map(function(c) { return c.trim(); }).filter(Boolean); - var rows = []; - for (var i = 2; i < lines.length; i++) { - var cells = lines[i].split('|').map(function(c) { return c.trim(); }).filter(Boolean); - if (cells.length) rows.push(cells); - } - return { headers: headers, rows: rows }; - } - - // Classify a line by type - function classifyLine(line) { - if (/^!\[[^\]]*\]\(\/api\/generated-images\/[0-9a-f-]{36}\)$/.test(line.trim())) return 'image'; - if (/^\|.+\|/.test(line)) return 'table'; - if (/^\|[\s:-]+\|/.test(line)) return 'table-sep'; - if (/^>\s+/.test(line)) return 'blockquote'; - if (/^```/.test(line)) return 'code-fence'; - if (/^\d+\.\s+/.test(line)) return 'ordered'; - if (/^[-*]\s+/.test(line)) return 'bullet'; - if (/^#{1,3}\s+/.test(line)) return 'heading'; - if (line.trim() === '') return 'blank'; - return 'paragraph'; - } - - // ── Parse slides ─────────────────────────────────────────── - - var md = markdown.replace(/^---[\s\S]*?---\n?/, '').trim(); - var rawSlides = md.split(/\n---\n/); - - rawSlides.forEach(function(slideText, slideIdx) { - slideText = slideText.trim(); - if (!slideText) return; - - var slide = pptx.addSlide(); - slide.background = { color: 'FFFFFF' }; - - // Extract slide title (first # heading) - var titleMatch = slideText.match(/^#{1,3}\s+(.+)$/m); - var slideTitle = titleMatch ? titleMatch[1].trim() : ''; - var body = slideText.replace(/^#{1,3}\s+.+$/m, '').trim(); - - if (slideTitle) { - slide.addText(parseInline(slideTitle, { fontSize: 28, bold: true, color: '1e40af', fontFace: 'Calibri' }), { - x: 0.5, y: 0.3, w: '90%', h: 0.8, valign: 'middle' - }); - } - - if (!body) { - slide.addText((slideIdx + 1).toString(), { x: '90%', y: '92%', w: '8%', h: 0.3, fontSize: 9, color: 'bbbbbb', align: 'right', fontFace: 'Calibri' }); - return; - } - - var contentY = slideTitle ? 1.3 : 0.6; - var maxH = 5.2 - contentY; - var lines = body.split('\n'); - var i = 0; - var inCodeBlock = false; - var codeLines = []; - - while (i < lines.length) { - var line = lines[i]; - var type = classifyLine(line); - - if (type === 'image') { - var src = line.trim().match(/\(([^)]+)\)$/)[1]; - if (!assetImages[src]) throw new Error('Generated image unavailable'); - var box = fitImage(assetImageBytes[src], 0.5, contentY, 11.8, Math.max(0.5, 5.2 - contentY)); - slide.addImage({ data: assetImages[src], x: box.x, y: box.y, w: box.w, h: box.h }); - contentY = 5.2; i++; continue; - } - - // ── Code block ── - if (type === 'code-fence' || inCodeBlock) { - if (type === 'code-fence' && !inCodeBlock) { - inCodeBlock = true; codeLines = []; i++; continue; - } - if (type === 'code-fence' && inCodeBlock) { - inCodeBlock = false; - if (codeLines.length) { - var codeText = codeLines.join('\n'); - var codeH = Math.min(Math.max(codeLines.length * 0.28, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6))); - slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: '90%', h: codeH, fill: { color: 'f3f4f6' }, rectRadius: 0.08 }); - slide.addText(codeText, { - x: 0.65, y: contentY + 0.08, w: '86%', h: codeH - 0.16, - fontSize: 13, fontFace: 'Courier New', color: '1f2937', valign: 'top', wrap: true - }); - contentY += codeH + 0.2; - codeLines = []; - } - i++; continue; - } - codeLines.push(line); - i++; continue; - } - - // ── Table ── - if (type === 'table') { - var tableLines = []; - while (i < lines.length && /^\|/.test(lines[i])) { tableLines.push(lines[i]); i++; } - var tbl = parseTable(tableLines); - if (tbl.headers.length) { - var colW = (12 / tbl.headers.length); - var tblRows = []; - // Header row - tblRows.push(tbl.headers.map(function(h) { - return { text: h, options: { bold: true, fontSize: 13, color: 'ffffff', fill: { color: '1e40af' }, fontFace: 'Calibri', align: 'center', valign: 'middle' } }; - })); - // Data rows - tbl.rows.forEach(function(row, ri) { - tblRows.push(row.map(function(cell) { - return { text: cell, options: { fontSize: 12, color: '374151', fill: { color: ri % 2 === 0 ? 'f9fafb' : 'ffffff' }, fontFace: 'Calibri', valign: 'middle' } }; - })); - }); - var tblH = Math.min((tblRows.length * 0.38) + 0.1, maxH - (contentY - (slideTitle ? 1.3 : 0.6))); - slide.addTable(tblRows, { - x: 0.5, y: contentY, w: 12, - colW: Array(tbl.headers.length).fill(colW), - border: { type: 'solid', pt: 0.5, color: 'dee2e6' }, - rowH: 0.36, - autoPage: false - }); - contentY += tblH + 0.15; - } - continue; - } - - // ── Blockquote ── - if (type === 'blockquote') { - var quoteText = line.replace(/^>\s*/, '').trim(); - i++; - while (i < lines.length && /^>\s*/.test(lines[i])) { quoteText += '\n' + lines[i].replace(/^>\s*/, '').trim(); i++; } - var qH = Math.max(0.5, Math.ceil(quoteText.length / 100) * 0.35); - slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: '90%', h: qH, fill: { color: 'eff6ff' }, rectRadius: 0.06 }); - slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: 0.06, h: qH, fill: { color: '3b82f6' } }); - slide.addText(parseInline(quoteText, { fontSize: 16, italic: true, color: '1e40af', fontFace: 'Calibri' }), { - x: 0.8, y: contentY + 0.06, w: '85%', h: qH - 0.12, valign: 'middle', wrap: true - }); - contentY += qH + 0.15; - continue; - } - - // ── Bullets (unordered) ── - if (type === 'bullet') { - var bulletItems = []; - while (i < lines.length && /^[-*]\s+/.test(lines[i])) { - bulletItems.push(lines[i].replace(/^[-*]\s+/, '').trim()); - i++; - } - var bulletObjs = []; - bulletItems.forEach(function(b) { - var inlineParts = parseInline(b, { fontSize: 17, color: '374151', fontFace: 'Calibri' }); - inlineParts[0].options.bullet = { type: 'bullet' }; - inlineParts[0].options.paraSpaceBefore = 4; - bulletObjs.push(inlineParts); - }); - var flat = []; bulletObjs.forEach(function(arr) { arr.forEach(function(p) { flat.push(p); }); }); - var bH = Math.min(Math.max(bulletItems.length * 0.35, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6))); - slide.addText(flat, { x: 0.6, y: contentY, w: '88%', h: bH, fontFace: 'Calibri', valign: 'top' }); - contentY += bH + 0.1; - continue; - } - - // ── Numbered list ── - if (type === 'ordered') { - var orderedItems = []; - while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { - orderedItems.push(lines[i].replace(/^\d+\.\s+/, '').trim()); - i++; - } - var orderedObjs = []; - orderedItems.forEach(function(item, idx) { - var inlineParts = parseInline(item, { fontSize: 17, color: '374151', fontFace: 'Calibri' }); - inlineParts[0].options.bullet = { type: 'number', numberStartAt: idx === 0 ? 1 : undefined }; - inlineParts[0].options.paraSpaceBefore = 4; - orderedObjs.push(inlineParts); - }); - var flatOrd = []; orderedObjs.forEach(function(arr) { arr.forEach(function(p) { flatOrd.push(p); }); }); - var oH = Math.min(Math.max(orderedItems.length * 0.35, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6))); - slide.addText(flatOrd, { x: 0.6, y: contentY, w: '88%', h: oH, fontFace: 'Calibri', valign: 'top' }); - contentY += oH + 0.1; - continue; - } - - // ── Sub-heading (## or ### inside body) ── - if (type === 'heading') { - var hText = line.replace(/^#{1,3}\s+/, '').trim(); - slide.addText(parseInline(hText, { fontSize: 22, bold: true, color: '1e3a5f', fontFace: 'Calibri' }), { - x: 0.5, y: contentY, w: '90%', h: 0.45, valign: 'bottom' - }); - contentY += 0.5; - i++; continue; - } - - // ── Paragraph ── - if (type === 'paragraph') { - var paraText = line.trim(); - i++; - // Merge consecutive paragraph lines - while (i < lines.length && classifyLine(lines[i]) === 'paragraph') { paraText += ' ' + lines[i].trim(); i++; } - var pParts = parseInline(paraText, { fontSize: 17, color: '374151', fontFace: 'Calibri' }); - var pH = Math.max(0.4, Math.ceil(paraText.length / 110) * 0.3); - slide.addText(pParts, { x: 0.5, y: contentY, w: '90%', h: pH, valign: 'top', wrap: true }); - contentY += pH + 0.1; - continue; - } - - // ── Blank / skip ── - i++; - } - - // Slide number - slide.addText((slideIdx + 1).toString(), { x: '90%', y: '92%', w: '8%', h: 0.3, fontSize: 9, color: 'bbbbbb', align: 'right', fontFace: 'Calibri' }); + var source = String(markdown).replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (whole, alt, src) { + var local = allowed[String(src).trim()]; + // A link we did not resolve is dropped rather than passed through, so a + // deck can never be made to read a path off this host. + return local ? '![' + alt + '](' + local + ')' : ''; }); - // Write to buffer and send - var pptxBuffer = await pptx.write({ outputType: 'nodebuffer' }); + await fsp.writeFile(pathMod.join(workdir, 'deck.md'), source, 'utf8'); + + // The reference deck carries the fonts, palette and slide layouts. Design + // lives there, not here: restyling means editing that file in PowerPoint. + var reference = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx'); + + await new Promise(function (resolve, reject) { + execFile('pandoc', ['deck.md', '--reference-doc=' + reference, '-o', 'deck.pptx'], + { cwd: workdir, timeout: 60000, maxBuffer: 1024 * 1024 }, + function (err, stdout, stderr) { + if (err) return reject(new Error(String(stderr || err.message).slice(0, 400))); + resolve(); + }); + }); + + var pptxBuffer = await fsp.readFile(pathMod.join(workdir, 'deck.pptx')); var safeTitle = (title || 'presentation').replace(/[^a-zA-Z0-9-_\s]/g, '').replace(/\s+/g, '-').toLowerCase(); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'); @@ -868,6 +614,11 @@ router.post('/generate-pptx', async function(req, res) { } catch (err) { console.error('[PPTX]', err.message); res.status(500).json({ error: 'Request failed' }); + } finally { + if (workdir) { + try { await require('fs/promises').rm(workdir, { recursive: true, force: true }); } + catch (e) { console.warn('[PPTX] could not clean', workdir, e.message); } + } } }); diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index bd9a9ddd..c8f88025 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -286,50 +286,47 @@ test('citation quality is measured on the server, where answer and sources both assert.match(route, /tracker\.store\(req\.user\.id, question, result, sources\);/); }); -test('PPTX images keep their shape', () => { - // PowerPoint scales an image to whatever extent it is given. pptxgenjs offers - // a `sizing` option that looks like it handles this, but it emits the box - // dimensions unchanged with — verified by reading the slide XML: - // a 200x800 image in an 11.8x3.9 box came out as cx=10789920 cy=3566160, - // i.e. stretched from 1:4 to 3:1. It cannot do better, because it never - // measures the image; its own getSizeFromImage is commented out as unused. +test('slides are built by pandoc, from markdown, with a reference template', () => { + // pptxgenjs is gone. It stretched every image — reading the slide XML it + // emitted showed the target box verbatim with , so a 200x800 + // image in an 11.8x3.9 box came out 1:4 squashed to 3:1 — and it could not + // do better, because it never measures an image; its own getSizeFromImage is + // commented out as unused. pandoc measures them: rendered and inspected, a + // 300x175 source produced aspect 1.714 and a 160x360 produced 0.445. + // + // Removing it also took image-size with it, and with that both high-severity + // advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq), which had no fixed + // release to upgrade to. const src = read('src/routes/learningAI.js'); - assert.match(src, /function fitImage\(bytes, x, y, maxW, maxH\)/); - assert.match(src, /var dims = measurer\(\)\(bytes\);/, 'measured here instead'); - assert.match(src, /var scale = Math\.min\(maxW \/ dims\.width, maxH \/ dims\.height\);/, - 'scaled to fit inside the box on whichever axis binds first'); - assert.match(src, /x: x \+ \(maxW - w\) \/ 2, y: y \+ \(maxH - h\) \/ 2/, 'and centred in it'); - // An image that cannot be measured must not fail the export. - assert.match(src, /var fallback = \{ x: x, y: y, w: maxW, h: maxH \};/); - assert.match(src, /catch \(e\) \{\s*\n\s*return fallback;/); - // The sizing option is gone: leaving it would only re-stretch what fitImage - // just measured. - const placement = src.slice(src.indexOf("if (type === 'image')"), src.indexOf("// ── Code block ──")); - assert.doesNotMatch(placement, /sizing:/, 'no sizing option fighting the measured box'); - assert.match(placement, /slide\.addImage\(\{ data: assetImages\[src\], x: box\.x, y: box\.y, w: box\.w, h: box\.h \}\)/); + assert.doesNotMatch(src, /pptxgenjs|PptxGenJS/, 'nothing builds slides by hand any more'); + assert.match(src, /execFile\('pandoc', \['deck\.md', '--reference-doc=' \+ reference, '-o', 'deck\.pptx'\]/); + assert.match(src, /timeout: 60000/, 'a conversion cannot hang the request'); + + // Design lives in the template, so restyling is editing a file in PowerPoint. + assert.match(src, /assets', 'learning', 'slides-reference\.pptx'/); + assert.ok(require('node:fs').existsSync( + require('node:path').join(__dirname, '..', 'assets', 'learning', 'slides-reference.pptx')), + 'and that template ships with the app'); + + // pandoc must be in the image, or every export fails at runtime. + assert.match(read('Dockerfile'), /apk add --no-cache ffmpeg curl jq pandoc-cli/); - // image-size is a real dependency now, and only one copy of it exists. const pkg = JSON.parse(read('package.json')); - assert.ok(pkg.dependencies['image-size'], 'declared directly rather than borrowed transitively'); - assert.equal(pkg.overrides['image-size'], '^2.0.2', - 'pptxgenjs declares it but never requires it; without this npm ships a second copy nothing can load'); + assert.ok(!pkg.dependencies['pptxgenjs'], 'and the library is no longer a dependency'); + assert.ok(!pkg.dependencies['image-size'], 'nor the parser it dragged in'); }); -test('only the image parsers this app needs are allowed to run', () => { - // ICNS, JXL and HEIF have open denial-of-service advisories against EVERY - // published image-size — GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq, both - // ranged <=2.0.2. There is no fixed release, so the fix is not to run them. +test('a deck can only embed images the requester owns', () => { + // pandoc resolves an image link against the filesystem, so a markdown link + // naming any local path would read that file into the deck. Only the images + // fetched by id for this user are written into the working directory, and + // every other image link is dropped rather than passed through. const src = read('src/routes/learningAI.js'); - assert.match(src, /var keep = \{ png: 1, jpg: 1, webp: 1, gif: 1 \};/); - assert.match(src, /lib\.disableTypes\(\(lib\.types \|\| \[\]\)\.filter\(function \(t\) \{ return !keep\[t\]; \}\)\)/); - // disableTypes replaces the enabled set, so calling it per measurement would - // be wasted work and easy to get wrong. - assert.match(src, /if \(!imageSizeReady\) \{/, 'done once, at first use'); - - // And the library actually honours it — this is the contract that could move. - const lib = require('image-size'); - const keep = { png: 1, jpg: 1, webp: 1, gif: 1 }; - lib.disableTypes((lib.types || []).filter(t => !keep[t])); - assert.throws(() => lib.imageSize(Buffer.concat([Buffer.from('icns'), Buffer.alloc(60)])), - 'a disabled parser must refuse rather than run'); + assert.match(src, /var image = await require\('\.\.\/utils\/generatedImages'\)\.service\(\)\.asset\(id, req\.user\);/, + 'ownership is checked when fetching'); + assert.match(src, /return local \? '!\[' \+ alt \+ '\]\(' \+ local \+ '\)' : '';/, + 'an unresolved link is removed, not passed to pandoc'); + // The working directory is per request and always cleaned up. + assert.match(src, /workdir = await fsp\.mkdtemp\(/); + assert.match(src, /\} finally \{[\s\S]{0,200}rm\(workdir, \{ recursive: true, force: true \}\)/); }); diff --git a/test/generated-images.integration.js b/test/generated-images.integration.js index 40fa3e45..2e890468 100644 --- a/test/generated-images.integration.js +++ b/test/generated-images.integration.js @@ -154,7 +154,7 @@ test('actual authenticated asset/settings and Learning content write routes enfo const learningAI=load('src/routes/learningAI.js',{ express,multer:require('multer'),axios:{},path:require('path'),'../utils/ai':{},'../utils/imageTool':require('../src/utils/imageTool'), '../middleware/auth':auth,'../db/database':routeDb,'../utils/crypto':require('../src/utils/crypto'),'../utils/urlSafety':require('../src/utils/urlSafety'), - '../utils/policy':{requireFeature:()=>()=>{}},'../utils/generatedImageLinks':links,'../utils/generatedImages':{service:()=>jobs},'pptxgenjs':require('pptxgenjs') + '../utils/policy':{requireFeature:()=>()=>{}},'../utils/generatedImageLinks':links,'../utils/generatedImages':{service:()=>jobs} }); await db.query('CREATE TABLE learning_categories(id SERIAL PRIMARY KEY,name TEXT);CREATE TABLE learning_questions(id SERIAL PRIMARY KEY,content_id INTEGER,sort_order INTEGER)'); const clinicalRoutes = load('src/routes/clinicalAssistant.js', {