From 15a8b399ba230aa34fe6f78f0caf0d0f47b3b4c1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 13:25:24 +0200 Subject: [PATCH] feat: slides are built by pandoc from markdown, with a reference template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pptxgenjs is gone, and with it 269 lines of hand-rolled markdown parsing. It stretched every image. Reading the slide XML it emitted shows why: it writes the target box verbatim with and a no-op srcRect, so a 200x800 image handed an 11.8x3.9 box came out 1:4 squashed to 3:1. It could not do better — it never measures an image, and its own getSizeFromImage is commented out and marked "currently unused", reaching for a package called sizeof that does not exist. pandoc measures them: a 300x175 source renders at aspect 1.714 and a 160x360 at 0.445, verified by rendering the deck to PDF and looking at it. Tables, ordered and unordered lists, bold, italic and subscripts all come out natively, and the fonts, palette and slide layouts come from assets/learning/slides-reference.pptx. Design now lives in that file: restyling the decks means editing it in PowerPoint, not editing this route. Only images the requester owns can reach a deck. pandoc resolves an image link against the filesystem, so a markdown link naming any local path would read that file into the presentation. Images are fetched by id through the ownership check, written into a per-request temporary directory under names we choose, and every image link that did not resolve is removed rather than passed through. The directory is removed in a finally block, and the conversion has a 60s timeout so it cannot hang a request. pandoc is in the image rather than a sidecar, because an export must not fail for reasons outside this container. It costs 197MB (307 -> 504). Removing pptxgenjs also removed image-size, and with it both high-severity advisories — GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq, ICNS/JXL/HEIF parser denial of service, ranged <=2.0.2 with no fixed release to upgrade to. npm audit goes from 2 high and 2 moderate to 2 moderate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- Dockerfile | 8 +- assets/learning/slides-reference.pptx | Bin 0 -> 35339 bytes package-lock.json | 47 ---- package.json | 5 +- src/routes/learningAI.js | 343 ++++---------------------- test/backend-hardening.test.js | 77 +++--- test/generated-images.integration.js | 2 +- 7 files changed, 93 insertions(+), 389 deletions(-) create mode 100644 assets/learning/slides-reference.pptx 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 0000000000000000000000000000000000000000..4665c8c125a2e0e988ab7854d0a302eafabf60f4 GIT binary patch literal 35339 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 Jjz9kQ{{YWgCkg-n literal 0 HcmV?d00001 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', {