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>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
'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 };
|
||||
});
|
||||
Reference in New Issue
Block a user