Files
Maxim Dolgolyov ef59023546 feat(lessons): SVG-рисовалка как блок урока (svg-draw)
Лёгкий векторный редактор frontend/js/svg-draw.js (перо со сглаживанием, линия,
прямоугольник, эллипс, стрелка, текст, цвет/толщина/заливка, выбор/перемещение/удаление,
undo/redo, очистка) → выдаёт чистый <svg>. Хранится inline в данных блока, переоткрывается
для дорисовки.

- Новый тип блока svg-draw: палитра «Рисунок», редактор (монтирование виджета + подпись),
  превью и студенческий рендер (lesson.html) — санитизированный inline-SVG, адаптивный.
- Санитайзер frontend/js/svg-sanitize.js (UMD, общий клиент/сервер): whitelist тегов/атрибутов,
  вырезает script/foreignObject/style/image/a, on*=, href, javascript:. Без зависимостей.
- Сервер (lessonController): svg-draw в VALID_TYPES + очистка data.svg при сохранении.
- Переиспользуемо: тот же виджет пригоден для флешкарт и фигур генератора задач.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:11:04 +03:00

100 lines
4.4 KiB
JavaScript

'use strict';
/* svg-sanitize.js — whitelist sanitizer for inline <svg> 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: <script>, <foreignObject>, <style>, <image>, <a>, <use>, <iframe>,
* any on*= handler, href/xlink:href, and url()/javascript: in styles.
*/
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof window !== 'undefined') window.SvgSanitize = api;
})(this, function () {
const ALLOWED_TAGS = new Set([
'svg', 'g', 'path', 'line', 'rect', 'circle', 'ellipse',
'polyline', 'polygon', 'text', 'tspan', 'title', 'desc', 'defs',
'linearGradient', 'radialGradient', 'stop',
]);
const ALLOWED_ATTR = new Set([
'viewbox', 'width', 'height', 'xmlns', 'class', 'transform', 'fill',
'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity',
'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'opacity',
'd', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
'points', 'width', 'height', 'dx', 'dy', 'text-anchor', 'dominant-baseline',
'font-size', 'font-family', 'font-weight', 'font-style', 'letter-spacing',
'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform',
'id', 'vector-effect', 'preserveaspectratio',
]);
function badAttrValue(v) {
const s = String(v).replace(/\s+/g, '').toLowerCase();
return s.indexOf('javascript:') !== -1 || s.indexOf('expression(') !== -1 ||
s.indexOf('data:text/html') !== -1 || /url\(/.test(s) && s.indexOf('url(#') !== 0;
}
/* ── Browser path: parse → walk → whitelist → serialize ───────────── */
function cleanDom(str) {
const doc = new DOMParser().parseFromString(str, 'image/svg+xml');
const svg = doc.querySelector('svg');
if (!svg || doc.querySelector('parsererror')) return '';
(function walk(node) {
// iterate over a static copy — we mutate during traversal
Array.prototype.slice.call(node.childNodes).forEach(function (child) {
if (child.nodeType === 1) { // element
const tag = child.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) { child.remove(); return; }
// scrub attributes
Array.prototype.slice.call(child.attributes).forEach(function (a) {
const name = a.name.toLowerCase();
if (name.indexOf('on') === 0 || name === 'href' || name === 'xlink:href' || name === 'style') {
child.removeAttribute(a.name); return;
}
if (!ALLOWED_ATTR.has(name)) { child.removeAttribute(a.name); return; }
if (badAttrValue(a.value)) child.removeAttribute(a.name);
});
walk(child);
} else if (child.nodeType === 8) { // comment
child.remove();
}
});
})(svg);
return new XMLSerializer().serializeToString(svg);
}
/* ── Node path: conservative regex strip (no DOM) ─────────────────── */
function cleanRegex(str) {
let s = String(str || '');
// drop dangerous element blocks entirely
s = s.replace(/<\s*(script|foreignObject|style|iframe|image|use|a)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '');
// drop self-closing / unclosed dangerous tags
s = s.replace(/<\s*(script|foreignObject|style|iframe|image|use|a)\b[^>]*\/?\s*>/gi, '');
// strip on*= handlers (single/double/unquoted)
s = s.replace(/\son[a-z-]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
// strip href / xlink:href
s = s.replace(/\s(?:xlink:)?href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
// neutralise javascript:/expression in any remaining attribute
s = s.replace(/javascript:/gi, '').replace(/expression\(/gi, '');
if (!/<svg[\s>]/i.test(s)) return '';
return s.trim();
}
function clean(str) {
if (!str) return '';
try {
if (typeof DOMParser !== 'undefined' && typeof XMLSerializer !== 'undefined') return cleanDom(str);
} catch (e) { /* fall through */ }
return cleanRegex(str);
}
return { clean: clean, ALLOWED_TAGS: ALLOWED_TAGS, ALLOWED_ATTR: ALLOWED_ATTR };
});