diff --git a/backend/src/controllers/lessonController.js b/backend/src/controllers/lessonController.js index 468bc65..a3ead48 100644 --- a/backend/src/controllers/lessonController.js +++ b/backend/src/controllers/lessonController.js @@ -1,6 +1,9 @@ const db = require('../db/db'); const { onLessonComplete } = require('./gamificationController'); const { stripTags } = require('../utils/sanitize'); +// Shared whitelist SVG sanitizer (UMD module, node-safe) — single source of +// truth with the client. In node it uses the conservative regex path. +const { clean: cleanSvg } = require('../../../frontend/js/svg-sanitize.js'); /* ── helpers ──────────────────────────────────────────────────────────── */ function parseBlock(b) { @@ -151,7 +154,7 @@ function saveBlocks(req, res) { if (!Array.isArray(blocks)) return res.status(400).json({ error: 'blocks must be an array' }); - const VALID_TYPES = ['heading','text','formula','image','quiz','sim','table','code','divider','callout','video','flashcard','matching','fill-blank','ordering','accordion','timeline','diagram','geogebra','audio','columns','alert']; + const VALID_TYPES = ['heading','text','formula','image','svg-draw','quiz','sim','table','code','divider','callout','video','flashcard','matching','fill-blank','ordering','accordion','timeline','diagram','geogebra','audio','columns','alert']; db.transaction(() => { db.prepare('DELETE FROM lesson_blocks WHERE lesson_id = ?').run(lesson.id); @@ -160,7 +163,12 @@ function saveBlocks(req, res) { ); blocks.forEach((b, i) => { const type = VALID_TYPES.includes(b.type) ? b.type : 'text'; - ins.run(lesson.id, type, b.orderIndex ?? i, JSON.stringify(b.data || {})); + let data = b.data || {}; + // Sanitize inline SVG drawings server-side (defense-in-depth). + if (type === 'svg-draw' && data && typeof data.svg === 'string') { + data = Object.assign({}, data, { svg: cleanSvg(data.svg) }); + } + ins.run(lesson.id, type, b.orderIndex ?? i, JSON.stringify(data)); }); // recalculate read time — pass already-parsed data objects, no double stringify/parse diff --git a/frontend/js/svg-draw.js b/frontend/js/svg-draw.js new file mode 100644 index 0000000..d3fd6fb --- /dev/null +++ b/frontend/js/svg-draw.js @@ -0,0 +1,473 @@ +'use strict'; +/* svg-draw.js — lightweight vanilla SVG drawing widget. + * + * Outputs clean, re-editable markup (vector). Used by the lesson editor + * (block type "svg-draw") and reusable elsewhere (flashcards, exam figures). + * + * API: + * const ed = SvgDraw.mount(container, { + * svg: '' | null, // existing drawing to continue editing + * width: 800, height: 500, // viewBox size (logical units) + * onChange: (svgString) => {}, // called after every committed change + * }); + * ed.getSVG(); // current drawing as a standalone string + * ed.destroy(); + * + * Security: the widget only ever emits a fixed safe set of elements + * (path, line, rect, ellipse, circle, polyline, polygon, text, g) with + * geometric/style attributes — no script, foreignObject, href or event + * handlers. Always pass stored SVG through SvgSanitize.clean() before render. + */ +(function () { + const SVGNS = 'http://www.w3.org/2000/svg'; + const TOOLS = ['select', 'pen', 'line', 'rect', 'ellipse', 'arrow', 'text']; + const SWATCHES = ['#0f172a', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#ffffff']; + + function injectStyles() { + if (document.getElementById('svgd-style')) return; + const s = document.createElement('style'); + s.id = 'svgd-style'; + s.textContent = ` + .svgd { border:1px solid var(--border,#e2e8f0); border-radius:12px; overflow:hidden; background:#fff; font-family:inherit; } + .svgd-toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:6px; padding:8px 10px; background:var(--surface,#f8fafc); border-bottom:1px solid var(--border,#e2e8f0); } + .svgd-tool, .svgd-act { width:34px; height:34px; border:1px solid var(--border,#e2e8f0); background:#fff; border-radius:9px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; color:#475569; padding:0; transition:background .12s,border-color .12s,color .12s; } + .svgd-tool:hover, .svgd-act:hover { border-color:#8b5cf6; color:#8b5cf6; } + .svgd-tool.active { background:#ede9fe; border-color:#8b5cf6; color:#6d28d9; } + .svgd-tool svg, .svgd-act svg { width:17px; height:17px; pointer-events:none; } + .svgd-sep { width:1px; height:24px; background:var(--border,#e2e8f0); margin:0 3px; } + .svgd-sw { width:20px; height:20px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1px rgba(0,0,0,.15); cursor:pointer; padding:0; } + .svgd-sw.active { box-shadow:0 0 0 2px #8b5cf6; } + .svgd-color { width:28px; height:28px; border:1px solid var(--border,#e2e8f0); border-radius:7px; padding:0; cursor:pointer; background:none; } + .svgd-range { width:90px; } + .svgd-lbl { font-size:.72rem; color:#64748b; font-weight:600; } + .svgd-fillbtn { font-size:.72rem; font-weight:700; padding:0 9px; height:34px; border:1px solid var(--border,#e2e8f0); border-radius:9px; background:#fff; cursor:pointer; color:#475569; } + .svgd-fillbtn.on { background:#ede9fe; border-color:#8b5cf6; color:#6d28d9; } + .svgd-canvas-wrap { position:relative; background: + linear-gradient(45deg,#f8fafc 25%,transparent 25%),linear-gradient(-45deg,#f8fafc 25%,transparent 25%), + linear-gradient(45deg,transparent 75%,#f8fafc 75%),linear-gradient(-45deg,transparent 75%,#f8fafc 75%); + background-size:18px 18px; background-position:0 0,0 9px,9px -9px,-9px 0; } + .svgd-canvas { display:block; width:100%; height:auto; touch-action:none; cursor:crosshair; } + .svgd-canvas.tool-select { cursor:default; } + .svgd-sel { outline:none; } + .svgd-selbox { fill:none; stroke:#8b5cf6; stroke-width:1.5; stroke-dasharray:5 4; vector-effect:non-scaling-stroke; pointer-events:none; } + .svgd-textinput { position:absolute; border:1px solid #8b5cf6; border-radius:4px; padding:1px 4px; font-family:inherit; outline:none; background:#fff; z-index:5; } + `; + document.head.appendChild(s); + } + + const ICONS = { + select: '', + pen: '', + line: '', + rect: '', + ellipse: '', + arrow: '', + text: '', + undo: '', + redo: '', + trash: '', + }; + function icon(name) { + return '' + (ICONS[name] || '') + ''; + } + + function mount(container, opts) { + opts = opts || {}; + injectStyles(); + const W = opts.width || 800; + const H = opts.height || 500; + const onChange = typeof opts.onChange === 'function' ? opts.onChange : function () {}; + + const state = { + tool: 'pen', + color: '#0f172a', + width: 3, + fill: false, + sel: null, // selected element (select tool) + undo: [], + redo: [], + }; + + /* ── DOM scaffold ─────────────────────────────────────────────── */ + const root = document.createElement('div'); + root.className = 'svgd'; + + const toolbar = document.createElement('div'); + toolbar.className = 'svgd-toolbar'; + + const TOOL_TITLE = { select: 'Выбор / перемещение', pen: 'Карандаш', line: 'Линия', rect: 'Прямоугольник', ellipse: 'Эллипс', arrow: 'Стрелка', text: 'Текст' }; + const toolBtns = {}; + TOOLS.forEach(function (t) { + const b = document.createElement('button'); + b.type = 'button'; b.className = 'svgd-tool' + (t === state.tool ? ' active' : ''); + b.title = TOOL_TITLE[t]; b.innerHTML = icon(t); + b.addEventListener('click', function () { setTool(t); }); + toolBtns[t] = b; toolbar.appendChild(b); + }); + sep(toolbar); + + // swatches + SWATCHES.forEach(function (c) { + const sw = document.createElement('button'); + sw.type = 'button'; sw.className = 'svgd-sw' + (c === state.color ? ' active' : ''); + sw.style.background = c; sw.title = c; + sw.addEventListener('click', function () { setColor(c); }); + toolbar.appendChild(sw); + }); + const colorInp = document.createElement('input'); + colorInp.type = 'color'; colorInp.className = 'svgd-color'; colorInp.value = state.color; colorInp.title = 'Свой цвет'; + colorInp.addEventListener('input', function () { setColor(colorInp.value); }); + toolbar.appendChild(colorInp); + sep(toolbar); + + const wLbl = document.createElement('span'); wLbl.className = 'svgd-lbl'; wLbl.textContent = 'толщина'; + const wRange = document.createElement('input'); + wRange.type = 'range'; wRange.min = '1'; wRange.max = '20'; wRange.value = String(state.width); wRange.className = 'svgd-range'; + wRange.addEventListener('input', function () { state.width = Number(wRange.value); }); + toolbar.appendChild(wLbl); toolbar.appendChild(wRange); + + const fillBtn = document.createElement('button'); + fillBtn.type = 'button'; fillBtn.className = 'svgd-fillbtn'; fillBtn.textContent = 'Заливка'; + fillBtn.addEventListener('click', function () { state.fill = !state.fill; fillBtn.classList.toggle('on', state.fill); }); + toolbar.appendChild(fillBtn); + sep(toolbar); + + const undoBtn = actBtn('undo', 'Отменить', undo); + const redoBtn = actBtn('redo', 'Повторить', redo); + const clearBtn = actBtn('trash', 'Очистить всё', clearAll); + toolbar.appendChild(undoBtn); toolbar.appendChild(redoBtn); toolbar.appendChild(clearBtn); + + function actBtn(ic, title, fn) { + const b = document.createElement('button'); + b.type = 'button'; b.className = 'svgd-act'; b.title = title; b.innerHTML = icon(ic); + b.addEventListener('click', fn); return b; + } + function sep(bar) { const d = document.createElement('div'); d.className = 'svgd-sep'; bar.appendChild(d); } + + const wrap = document.createElement('div'); + wrap.className = 'svgd-canvas-wrap'; + const svg = document.createElementNS(SVGNS, 'svg'); + svg.setAttribute('class', 'svgd-canvas tool-' + state.tool); + svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); + svg.setAttribute('xmlns', SVGNS); + const content = document.createElementNS(SVGNS, 'g'); // user drawing lives here + content.setAttribute('class', 'svgd-content'); + svg.appendChild(content); + wrap.appendChild(svg); + + root.appendChild(toolbar); + root.appendChild(wrap); + container.appendChild(root); + + // Load existing drawing (sanitized) into content + if (opts.svg) loadSvg(opts.svg); + pushUndo(true); // baseline snapshot (no onChange) + + /* ── helpers ──────────────────────────────────────────────────── */ + function setTool(t) { + state.tool = t; + Object.keys(toolBtns).forEach(function (k) { toolBtns[k].classList.toggle('active', k === t); }); + svg.setAttribute('class', 'svgd-canvas tool-' + t); + if (t !== 'select') deselect(); + } + function setColor(c) { + state.color = c; colorInp.value = c; + toolbar.querySelectorAll('.svgd-sw').forEach(function (sw) { sw.classList.toggle('active', sw.style.background === c || rgbToHex(sw.style.background) === c); }); + if (state.sel && state.sel.tagName !== 'text') { + state.sel.setAttribute(state.sel.getAttribute('fill') !== 'none' && isFillShape(state.sel) && state.fill ? 'fill' : 'stroke', c); + } else if (state.sel && state.sel.tagName === 'text') { + state.sel.setAttribute('fill', c); + } + if (state.sel) commit(); + } + + function svgPoint(evt) { + const pt = svg.createSVGPoint(); + pt.x = evt.clientX; pt.y = evt.clientY; + const ctm = svg.getScreenCTM(); + const p = pt.matrixTransform(ctm.inverse()); + return { x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 }; + } + function isFillShape(el) { return ['rect', 'ellipse', 'circle', 'polygon', 'path'].indexOf(el.tagName) !== -1; } + function applyStyle(el, closed) { + el.setAttribute('stroke', state.color); + el.setAttribute('stroke-width', String(state.width)); + el.setAttribute('stroke-linecap', 'round'); + el.setAttribute('stroke-linejoin', 'round'); + el.setAttribute('fill', (state.fill && closed) ? state.color : 'none'); + } + + /* ── drawing interaction ──────────────────────────────────────── */ + let drawing = false, startP = null, curEl = null, penPts = null, dragMode = null, dragStart = null, selStartXY = null; + + svg.addEventListener('pointerdown', function (evt) { + if (evt.button !== 0) return; + const p = svgPoint(evt); + + if (state.tool === 'select') { + const target = (evt.target !== svg && evt.target.closest) ? evt.target.closest('.svgd-content > *') : null; + if (target && content.contains(target) && !target.classList.contains('svgd-selbox')) { + select(target); + dragMode = 'move'; dragStart = p; selStartXY = currentTranslate(target); + svg.setPointerCapture(evt.pointerId); + } else { deselect(); } + return; + } + + if (state.tool === 'text') { + spawnTextInput(evt, p); + return; + } + + drawing = true; startP = p; + svg.setPointerCapture(evt.pointerId); + + if (state.tool === 'pen') { + penPts = [p]; + curEl = document.createElementNS(SVGNS, 'path'); + applyStyle(curEl, false); + curEl.setAttribute('d', 'M ' + p.x + ' ' + p.y); + content.appendChild(curEl); + } else if (state.tool === 'line' || state.tool === 'arrow') { + curEl = document.createElementNS(SVGNS, state.tool === 'arrow' ? 'g' : 'line'); + if (state.tool === 'line') { + applyStyle(curEl, false); + setLine(curEl, p, p); + } else { + buildArrow(curEl, p, p); + } + content.appendChild(curEl); + } else if (state.tool === 'rect') { + curEl = document.createElementNS(SVGNS, 'rect'); + applyStyle(curEl, true); + setRect(curEl, p, p); + content.appendChild(curEl); + } else if (state.tool === 'ellipse') { + curEl = document.createElementNS(SVGNS, 'ellipse'); + applyStyle(curEl, true); + setEllipse(curEl, p, p); + content.appendChild(curEl); + } + }); + + svg.addEventListener('pointermove', function (evt) { + const p = svgPoint(evt); + if (dragMode === 'move' && state.sel) { + const dx = p.x - dragStart.x, dy = p.y - dragStart.y; + state.sel.setAttribute('transform', 'translate(' + (selStartXY.x + dx) + ' ' + (selStartXY.y + dy) + ')'); + positionSelBox(); + return; + } + if (!drawing || !curEl) return; + if (state.tool === 'pen') { + penPts.push(p); + curEl.setAttribute('d', penPath(penPts)); + } else if (state.tool === 'line') { + setLine(curEl, startP, p); + } else if (state.tool === 'arrow') { + buildArrow(curEl, startP, p); + } else if (state.tool === 'rect') { + setRect(curEl, startP, p); + } else if (state.tool === 'ellipse') { + setEllipse(curEl, startP, p); + } + }); + + function endDraw(evt) { + if (dragMode === 'move') { dragMode = null; commit(); return; } + if (!drawing) return; + drawing = false; + try { svg.releasePointerCapture(evt.pointerId); } catch (e) {} + if (curEl) { + // discard zero-size shapes / dot-only strokes + if (state.tool !== 'pen' && isTiny(curEl)) { curEl.remove(); curEl = null; return; } + if (state.tool === 'pen' && penPts.length < 2) { + // single tap → tiny dot circle + curEl.remove(); + const c = document.createElementNS(SVGNS, 'circle'); + c.setAttribute('cx', startP.x); c.setAttribute('cy', startP.y); c.setAttribute('r', String(Math.max(1, state.width / 2))); + c.setAttribute('fill', state.color); c.setAttribute('stroke', 'none'); + content.appendChild(c); + } + curEl = null; commit(); + } + } + svg.addEventListener('pointerup', endDraw); + svg.addEventListener('pointercancel', endDraw); + + function isTiny(el) { + const b = el.getBBox(); + return (b.width < 3 && b.height < 3); + } + + /* ── shape setters ────────────────────────────────────────────── */ + function setLine(el, a, b) { el.setAttribute('x1', a.x); el.setAttribute('y1', a.y); el.setAttribute('x2', b.x); el.setAttribute('y2', b.y); } + function setRect(el, a, b) { + el.setAttribute('x', Math.min(a.x, b.x)); el.setAttribute('y', Math.min(a.y, b.y)); + el.setAttribute('width', Math.abs(b.x - a.x)); el.setAttribute('height', Math.abs(b.y - a.y)); + } + function setEllipse(el, a, b) { + el.setAttribute('cx', (a.x + b.x) / 2); el.setAttribute('cy', (a.y + b.y) / 2); + el.setAttribute('rx', Math.abs(b.x - a.x) / 2); el.setAttribute('ry', Math.abs(b.y - a.y) / 2); + } + function buildArrow(g, a, b) { + while (g.firstChild) g.removeChild(g.firstChild); + const line = document.createElementNS(SVGNS, 'line'); + applyStyle(line, false); setLine(line, a, b); + g.appendChild(line); + const ang = Math.atan2(b.y - a.y, b.x - a.x); + const len = Math.max(8, state.width * 3.2); + const head = document.createElementNS(SVGNS, 'polyline'); + const p1 = { x: b.x - len * Math.cos(ang - Math.PI / 7), y: b.y - len * Math.sin(ang - Math.PI / 7) }; + const p2 = { x: b.x - len * Math.cos(ang + Math.PI / 7), y: b.y - len * Math.sin(ang + Math.PI / 7) }; + head.setAttribute('points', round1(p1.x) + ',' + round1(p1.y) + ' ' + round1(b.x) + ',' + round1(b.y) + ' ' + round1(p2.x) + ',' + round1(p2.y)); + head.setAttribute('fill', 'none'); head.setAttribute('stroke', state.color); + head.setAttribute('stroke-width', String(state.width)); head.setAttribute('stroke-linecap', 'round'); head.setAttribute('stroke-linejoin', 'round'); + g.appendChild(head); + } + function round1(n) { return Math.round(n * 10) / 10; } + + // Catmull-Rom → cubic bezier smoothing + function penPath(pts) { + if (pts.length < 2) return 'M ' + pts[0].x + ' ' + pts[0].y; + let d = 'M ' + pts[0].x + ' ' + pts[0].y; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2; + const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6; + const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6; + d += ' C ' + round1(c1x) + ' ' + round1(c1y) + ' ' + round1(c2x) + ' ' + round1(c2y) + ' ' + round1(p2.x) + ' ' + round1(p2.y); + } + return d; + } + + /* ── text tool ────────────────────────────────────────────────── */ + function spawnTextInput(evt, p) { + const rect = wrap.getBoundingClientRect(); + const inp = document.createElement('input'); + inp.type = 'text'; inp.className = 'svgd-textinput'; inp.placeholder = 'текст…'; + inp.style.left = (evt.clientX - rect.left) + 'px'; + inp.style.top = (evt.clientY - rect.top) + 'px'; + inp.style.color = state.color; + inp.style.fontSize = Math.max(12, state.width * 5) + 'px'; + wrap.appendChild(inp); inp.focus(); + function finish(keep) { + const val = inp.value.trim(); + inp.remove(); + if (keep && val) { + const t = document.createElementNS(SVGNS, 'text'); + t.setAttribute('x', p.x); t.setAttribute('y', p.y); + t.setAttribute('fill', state.color); + t.setAttribute('font-size', String(Math.max(12, state.width * 5))); + t.setAttribute('font-family', 'Inter, system-ui, sans-serif'); + t.textContent = val; + content.appendChild(t); commit(); + } + } + inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') finish(true); else if (e.key === 'Escape') finish(false); }); + inp.addEventListener('blur', function () { finish(true); }); + } + + /* ── selection ────────────────────────────────────────────────── */ + let selBox = null; + function select(el) { + deselect(); state.sel = el; + selBox = document.createElementNS(SVGNS, 'rect'); + selBox.setAttribute('class', 'svgd-selbox'); + svg.appendChild(selBox); positionSelBox(); + } + function positionSelBox() { + if (!selBox || !state.sel) return; + try { + const b = state.sel.getBBox(); const tr = currentTranslate(state.sel); + selBox.setAttribute('x', b.x + tr.x - 3); selBox.setAttribute('y', b.y + tr.y - 3); + selBox.setAttribute('width', b.width + 6); selBox.setAttribute('height', b.height + 6); + } catch (e) {} + } + function deselect() { if (selBox) { selBox.remove(); selBox = null; } state.sel = null; } + function currentTranslate(el) { + const tr = el.getAttribute('transform'); + const m = tr && tr.match(/translate\(\s*(-?[\d.]+)[ ,]+(-?[\d.]+)/); + return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : { x: 0, y: 0 }; + } + + document.addEventListener('keydown', onKey); + function onKey(e) { + if (!root.isConnected) return; + if ((e.key === 'Delete' || e.key === 'Backspace') && state.sel && document.activeElement && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') { + e.preventDefault(); state.sel.remove(); deselect(); commit(); + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && root.querySelector(':hover')) { e.preventDefault(); e.shiftKey ? redo() : undo(); } + } + + /* ── undo / redo / commit ─────────────────────────────────────── */ + function snapshot() { return content.innerHTML; } + function pushUndo(silent) { + state.undo.push(snapshot()); + if (state.undo.length > 60) state.undo.shift(); + state.redo.length = 0; + if (!silent) onChange(getSVG()); + } + function commit() { deselectKeepRef(); pushUndo(false); } + function deselectKeepRef() { /* selBox excluded from snapshot since it's on svg, not content */ } + function undo() { + if (state.undo.length <= 1) return; + deselect(); + state.redo.push(state.undo.pop()); + content.innerHTML = state.undo[state.undo.length - 1]; + onChange(getSVG()); + } + function redo() { + if (!state.redo.length) return; + deselect(); + const html = state.redo.pop(); state.undo.push(html); + content.innerHTML = html; onChange(getSVG()); + } + function clearAll() { + if (!content.firstChild) return; + if (!confirm('Очистить весь рисунок?')) return; + deselect(); content.innerHTML = ''; pushUndo(false); + } + + /* ── load / export ────────────────────────────────────────────── */ + function loadSvg(str) { + const clean = (window.SvgSanitize ? window.SvgSanitize.clean(str) : str) || ''; + const doc = new DOMParser().parseFromString(clean, 'image/svg+xml'); + const root2 = doc.querySelector('svg'); + if (!root2) return; + content.innerHTML = ''; + // inner content may be wrapped in a or be loose children + const inner = root2.querySelector('g.svgd-content') || root2; + Array.prototype.slice.call(inner.childNodes).forEach(function (n) { + if (n.nodeType === 1) content.appendChild(document.importNode(n, true)); + }); + } + + function getSVG() { + deselect(); + const out = document.createElementNS(SVGNS, 'svg'); + out.setAttribute('xmlns', SVGNS); + out.setAttribute('viewBox', '0 0 ' + W + ' ' + H); + out.setAttribute('width', String(W)); + out.setAttribute('height', String(H)); + const g = content.cloneNode(true); + out.appendChild(g); + const str = new XMLSerializer().serializeToString(out); + return window.SvgSanitize ? window.SvgSanitize.clean(str) : str; + } + + function rgbToHex(rgb) { + const m = (rgb || '').match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (!m) return rgb; + return '#' + [m[1], m[2], m[3]].map(function (x) { return ('0' + parseInt(x, 10).toString(16)).slice(-2); }).join(''); + } + + function destroy() { + document.removeEventListener('keydown', onKey); + if (root.parentNode) root.parentNode.removeChild(root); + } + + return { getSVG: getSVG, destroy: destroy, el: root }; + } + + window.SvgDraw = { mount: mount }; +})(); diff --git a/frontend/js/svg-sanitize.js b/frontend/js/svg-sanitize.js new file mode 100644 index 0000000..b6249c1 --- /dev/null +++ b/frontend/js/svg-sanitize.js @@ -0,0 +1,99 @@ +'use strict'; +/* svg-sanitize.js — whitelist sanitizer for inline drawings. + * + * Shared by the browser (DOM-based whitelist, robust) and Node (regex-based + * conservative strip, no DOM dependency). Defense-in-depth: the SvgDraw editor + * only emits a safe subset, but stored block data could be tampered with, so + * BOTH the server (on save) and the client (on render) clean it. + * + * Allowed: a fixed set of geometric/text elements + geometric & style attrs. + * Removed: