'use strict'; /* Custom simulations ("Конструктор симуляций" / SimForge), Фаза 3. * * Учитель/админ сохраняет интерактивную 2D-симуляцию как ДАННЫЕ (JSON-спека). * CRUD под авторизацией с проверкой владения; спека валидируется на входе * через validateSpec — БЕЗ исполнения (спека шарится между людьми, server * не запускает движок выражений). draft видит только владелец; published — * публичная (каталог /lab, Фаза 5). * * Стиль следует studentMaterialsController: node:sqlite db.prepare, * per-row ownership на каждой мутации, статусы 400/403/404. */ const db = require('../db/db'); const { pushNotif } = require('../utils/notifications'); /* ── Лимиты валидации спеки ──────────────────────────────────────────── */ const MAX_SPEC_BYTES = 200 * 1024; // 200 KB сериализованного JSON const MAX_PARAMS = 50; const MAX_OBJECTS = 200; const MAX_WALLS = 20; const MAX_SPRINGS = 50; const MAX_EXPR_LEN = 500; // длина строки-выражения (x/y/expr/…) const MAX_DEPTH = 8; // глубина вложенности JSON (анти-bomb) const MAX_TEXT_LEN = 300; // подписи/заголовки/единицы const MAX_POINTS = 1000; // точек в polyline/path/points // Типы объектов из whitelist (см. формат спеки v1 в _sim_engine.js). const OBJECT_TYPES = new Set([ 'point', 'segment', 'vector', 'circle', 'rect', 'polyline', 'path', 'label', 'plot', 'readout', 'zone', // Квантик Ф3: зона-препятствие/цель/сбор (граф-уровни) ]); const STATUSES = new Set(['draft', 'published']); const CATS = new Set(['math', 'phys', 'chem', 'bio', 'game']); /* Экранирование подписей как ТЕКСТА (не HTML): спека рендерится в KaTeX/canvas, но мы режем угловые скобки/амперсанд, чтобы исключить инъекцию при возможном попадании строки в HTML-контекст. Также обрезаем по длине. */ function sanitizeText(v, max = MAX_TEXT_LEN) { if (v === null || v === undefined) return v; let s = String(v).slice(0, max); s = s.replace(/&/g, '&').replace(//g, '>'); return s; } /* Цвет: пропускаем ТОЛЬКО безопасные формы (#hex, rgb()/rgba()/hsl()/hsla(), имя-слово). Иначе возвращаем undefined — поле выкидывается, движок берёт дефолт. Цель: строка вида "#fff;background:url(https://evil)" не должна утечь в style.cssText при рендере шаренной/опубликованной спеки (CSS-инъекция → исходящий GET). */ function sanitizeColor(v) { if (v === null || v === undefined) return undefined; const s = String(v).trim().slice(0, 40); if (/^#[0-9a-fA-F]{3,8}$/.test(s)) return s; if (/^(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%\s/]+\)$/.test(s)) return s; if (/^[a-zA-Z]{1,30}$/.test(s)) return s; // named color (red, transparent, ...) return undefined; // небезопасно — выкинуть } function applyColorFields(out, src, keys) { for (const k of keys) { if (src[k] === undefined) continue; const c = sanitizeColor(src[k]); if (c === undefined) delete out[k]; else out[k] = c; } } /* Строка-выражение: число оставляем числом; строку обрезаем по длине, но НЕ парсим/исполняем (это делает безопасный SimExpr на клиенте). Отклоняем только превышение длины. */ function checkExpr(v, label, errs) { if (typeof v === 'string' && v.length > MAX_EXPR_LEN) { errs.push(`${label}: выражение длиннее ${MAX_EXPR_LEN} символов`); return false; } return true; } /* Глубина вложенности — простая защита от «бомбы» из вложенных структур. */ function depthOK(node, depth) { if (depth > MAX_DEPTH) return false; if (Array.isArray(node)) { for (const x of node) if (!depthOK(x, depth + 1)) return false; } else if (node && typeof node === 'object') { for (const k of Object.keys(node)) if (!depthOK(node[k], depth + 1)) return false; } return true; } /** * validateSpec(spec) — серверная валидация спеки БЕЗ исполнения. * Возвращает { ok:true, clean } с очищенной (санитизированной) спекой, * либо { ok:false, error } для ответа 400. */ function validateSpec(spec) { if (!spec || typeof spec !== 'object' || Array.isArray(spec)) { return { ok: false, error: 'spec должна быть объектом' }; } // Размер сериализованного JSON. let json; try { json = JSON.stringify(spec); } catch { return { ok: false, error: 'spec не сериализуется в JSON' }; } if (Buffer.byteLength(json, 'utf8') > MAX_SPEC_BYTES) { return { ok: false, error: `spec превышает ${Math.round(MAX_SPEC_BYTES / 1024)} KB` }; } // Глубина вложенности. if (!depthOK(spec, 0)) return { ok: false, error: 'слишком глубокая вложенность spec' }; // specVersion. if (spec.specVersion !== undefined && spec.specVersion !== 1) { return { ok: false, error: 'неподдерживаемая specVersion (ожидается 1)' }; } const errs = []; const clean = {}; clean.specVersion = 1; // meta: title/desc — текст. if (spec.meta && typeof spec.meta === 'object') { clean.meta = {}; if (spec.meta.title !== undefined) clean.meta.title = sanitizeText(spec.meta.title); if (spec.meta.desc !== undefined) clean.meta.desc = sanitizeText(spec.meta.desc, 1000); } // viewport — числовые границы пропускаем как есть; bg санитизируем (CSS-инъекция). if (spec.viewport && typeof spec.viewport === 'object' && !Array.isArray(spec.viewport)) { clean.viewport = { ...spec.viewport }; if (clean.viewport.bg !== undefined) { const c = sanitizeColor(clean.viewport.bg); if (c === undefined) delete clean.viewport.bg; else clean.viewport.bg = c; } } // time — конфиг t-цикла (autoplay/loop/duration/speed). if (spec.time && typeof spec.time === 'object' && !Array.isArray(spec.time)) { clean.time = spec.time; } // params[] — слайдеры. const params = Array.isArray(spec.params) ? spec.params : []; if (params.length > MAX_PARAMS) return { ok: false, error: `params > ${MAX_PARAMS}` }; clean.params = params.map((p, i) => { if (!p || typeof p !== 'object') { errs.push(`params[${i}]: не объект`); return {}; } const out = { ...p }; if (p.label !== undefined) out.label = sanitizeText(p.label, 120); if (p.unit !== undefined) out.unit = sanitizeText(p.unit, 40); if (p.name !== undefined) out.name = sanitizeText(p.name, 60); return out; }); // objects[] — фигуры/подписи/графики/телá. const objects = Array.isArray(spec.objects) ? spec.objects : []; if (objects.length > MAX_OBJECTS) return { ok: false, error: `objects > ${MAX_OBJECTS}` }; clean.objects = objects.map((o, i) => { if (!o || typeof o !== 'object') { errs.push(`objects[${i}]: не объект`); return {}; } const type = String(o.type || ''); if (!OBJECT_TYPES.has(type)) errs.push(`objects[${i}]: недопустимый type "${type}"`); const out = { ...o }; // Текстовые поля. if (o.text !== undefined) out.text = sanitizeText(o.text, 1000); if (o.label !== undefined) out.label = sanitizeText(o.label, 120); if (o.unit !== undefined) out.unit = sanitizeText(o.unit, 40); if (o.id !== undefined) out.id = sanitizeText(o.id, 60); // Цвета — вайтлист (иначе CSS-инъекция через style.cssText при рендере). applyColorFields(out, o, ['color', 'fill', 'fillColor', 'trailColor', 'bg']); // Строки-выражения: координаты/радиусы/выражения/диапазоны. for (const k of ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'r', 'w', 'h', 'dx', 'dy', 'expr', 'size', 'width', 'precision', 'samples']) { if (o[k] !== undefined) checkExpr(o[k], `objects[${i}].${k}`, errs); } // points[] (polyline/path) — ограничиваем число точек. if (Array.isArray(o.points) && o.points.length > MAX_POINTS) { errs.push(`objects[${i}].points > ${MAX_POINTS}`); } // body{} — физическое тело (mass/vx/vy/fixed). mass>0. if (o.body && typeof o.body === 'object' && !Array.isArray(o.body)) { const b = o.body; for (const k of ['mass', 'vx', 'vy']) if (b[k] !== undefined) checkExpr(b[k], `objects[${i}].body.${k}`, errs); if (typeof b.mass === 'number' && !(b.mass > 0)) errs.push(`objects[${i}].body.mass должна быть > 0`); } // drag{} — параметр-привязка. if (o.drag && typeof o.drag === 'object' && o.drag.param !== undefined) { out.drag = { ...o.drag }; out.drag.param = sanitizeText(o.drag.param, 60); if (o.drag.paramY !== undefined) out.drag.paramY = sanitizeText(o.drag.paramY, 60); } // zone{} — track = id отслеживаемой точки (Квантик Ф3): санитизируем как id. if (type === 'zone' && o.track !== undefined) out.track = sanitizeText(o.track, 60); // runner{} на plot (Квантик Ф3): duration — число/выражение (длина). if (o.runner && typeof o.runner === 'object' && !Array.isArray(o.runner)) { if (o.runner.duration !== undefined) checkExpr(o.runner.duration, `objects[${i}].runner.duration`, errs); } return out; }); // physics{} — глобальный блок сил/мира. if (spec.physics && typeof spec.physics === 'object' && !Array.isArray(spec.physics)) { const ph = spec.physics; const cph = { ...ph }; // gravity: {x,y} — числа/выражения. if (ph.gravity && typeof ph.gravity === 'object') { checkExpr(ph.gravity.x, 'physics.gravity.x', errs); checkExpr(ph.gravity.y, 'physics.gravity.y', errs); } // friction/restitution/dt — числа/выражения + границы для числовых. for (const k of ['friction', 'restitution', 'dt']) if (ph[k] !== undefined) checkExpr(ph[k], `physics.${k}`, errs); if (typeof ph.restitution === 'number' && (ph.restitution < 0 || ph.restitution > 1)) { errs.push('physics.restitution вне диапазона 0..1'); } if (typeof ph.dt === 'number' && (ph.dt < 1 / 2000 || ph.dt > 1 / 30)) { errs.push('physics.dt вне диапазона 1/2000..1/30'); } // walls[] — лимит. if (Array.isArray(ph.walls)) { if (ph.walls.length > MAX_WALLS) return { ok: false, error: `physics.walls > ${MAX_WALLS}` }; for (let i = 0; i < ph.walls.length; i++) { const wl = ph.walls[i]; if (wl && typeof wl === 'object') { for (const k of ['x1', 'y1', 'x2', 'y2']) if (wl[k] !== undefined) checkExpr(wl[k], `physics.walls[${i}].${k}`, errs); } } } // springs[] — лимит + поля. if (Array.isArray(ph.springs)) { if (ph.springs.length > MAX_SPRINGS) return { ok: false, error: `physics.springs > ${MAX_SPRINGS}` }; for (let i = 0; i < ph.springs.length; i++) { const sp = ph.springs[i]; if (sp && typeof sp === 'object') { for (const k of ['k', 'length', 'damping']) if (sp[k] !== undefined) checkExpr(sp[k], `physics.springs[${i}].${k}`, errs); } } } clean.physics = cph; } // goal{} — слой цели/победы (Квантик, Фаза 0). Выражения НЕ исполняем (длина), // текст — sanitizeText (escape + обрезка), не более 3 звёзд, hold — число. if (spec.goal && typeof spec.goal === 'object' && !Array.isArray(spec.goal)) { const g = spec.goal; const cg = {}; if (g.when !== undefined) { checkExpr(g.when, 'goal.when', errs); cg.when = g.when; } if (g.fail !== undefined) { checkExpr(g.fail, 'goal.fail', errs); cg.fail = g.fail; } if (g.title !== undefined) cg.title = sanitizeText(g.title, 120); if (g.hint !== undefined) cg.hint = sanitizeText(g.hint, 300); if (g.hold !== undefined) { if (typeof g.hold !== 'number') errs.push('goal.hold должно быть числом'); else cg.hold = g.hold; } if (g.stars !== undefined) { if (!Array.isArray(g.stars)) { errs.push('goal.stars должно быть массивом'); } else if (g.stars.length > 3) { return { ok: false, error: 'goal.stars > 3' }; } else { cg.stars = g.stars.map((s, i) => { if (!s || typeof s !== 'object') { errs.push(`goal.stars[${i}]: не объект`); return {}; } const os = {}; if (s.when !== undefined) { checkExpr(s.when, `goal.stars[${i}].when`, errs); os.when = s.when; } if (s.label !== undefined) os.label = sanitizeText(s.label, 120); return os; }); } } clean.goal = cg; } // game{} — мета-слой игрового уровня (Фаза 1/5). Санитизируем ПОИМЁННО (как goal): // строки → sanitizeText (escape), числа → проверка типа, неизвестные ключи отбрасываем. // Иначе произвольная строка в game.* стала бы хранимым XSS у любого, кому раздали уровень. if (spec.game && typeof spec.game === 'object' && !Array.isArray(spec.game)) { const gm = spec.game; const cgm = {}; if (gm.chapter !== undefined) cgm.chapter = sanitizeText(gm.chapter, 60); if (gm.subject !== undefined) cgm.subject = sanitizeText(gm.subject, 60); if (typeof gm.order === 'number') cgm.order = gm.order; if (typeof gm.par_ms === 'number') cgm.par_ms = gm.par_ms; if (typeof gm.unlockStars === 'number') cgm.unlockStars = gm.unlockStars; clean.game = cgm; } if (errs.length) return { ok: false, error: errs.slice(0, 8).join('; ') }; return { ok: true, clean }; } /* ── Сериализация строки БД → ответ API ──────────────────────────────── */ function rowToSim(row) { if (!row) return null; let spec = null; try { spec = JSON.parse(row.spec_json); } catch { spec = null; } return { id: row.id, owner_id: row.owner_id, title: row.title, description: row.description, subject: row.subject, grade: row.grade, cat: row.cat, status: row.status, version: row.version, spec, created_at: row.created_at, updated_at: row.updated_at, }; } /* Метаданные из body — общая нормализация для create/update. */ function readMeta(b) { return { title: b.title !== undefined ? sanitizeText(b.title) : undefined, description: b.description !== undefined ? sanitizeText(b.description, 2000) : undefined, subject: b.subject !== undefined ? (b.subject != null ? String(b.subject).slice(0, 60) : null) : undefined, grade: b.grade !== undefined ? (Number.isFinite(Number(b.grade)) ? Number(b.grade) : null) : undefined, cat: b.cat !== undefined ? (CATS.has(String(b.cat)) ? String(b.cat) : null) : undefined, }; } /* GET /api/custom-sims — свои (любой статус) + чужие published. Без выдачи spec_json в списке (тяжело); spec приходит в GET /:id. */ function list(req, res) { const uid = req.user.id; const rows = db.prepare(` SELECT id, owner_id, title, description, subject, grade, cat, status, version, created_at, updated_at FROM custom_sims WHERE owner_id = ? OR status = 'published' ORDER BY updated_at DESC, created_at DESC, id DESC `).all(uid); res.json({ sims: rows }); } /* GET /api/custom-sims/:id — свой (любой статус) ИЛИ чужой published. */ function get(req, res) { const row = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.owner_id !== req.user.id && row.status !== 'published') { return res.status(403).json({ error: 'forbidden' }); } res.json({ sim: rowToSim(row) }); } /* POST /api/custom-sims — создать (teacher/admin). Body: { title?, description?, subject?, grade?, cat?, status?, spec }. */ function create(req, res) { const b = req.body || {}; const v = validateSpec(b.spec); if (!v.ok) return res.status(400).json({ error: v.error }); const m = readMeta(b); const status = STATUSES.has(String(b.status)) ? String(b.status) : 'draft'; const r = db.prepare(` INSERT INTO custom_sims (owner_id, title, description, subject, grade, cat, spec_json, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1) `).run( req.user.id, m.title ?? null, m.description ?? null, m.subject ?? null, m.grade ?? null, m.cat ?? null, JSON.stringify(v.clean), status, ); res.status(201).json({ id: Number(r.lastInsertRowid) }); } /* PUT /api/custom-sims/:id — обновить (владелец/admin). Любое поле опционально; spec, если передан, валидируется заново и поднимает version. */ function update(req, res) { const row = db.prepare('SELECT owner_id, version FROM custom_sims WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.owner_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'forbidden' }); } const b = req.body || {}; const fields = [], args = []; if (b.spec !== undefined) { const v = validateSpec(b.spec); if (!v.ok) return res.status(400).json({ error: v.error }); fields.push('spec_json = ?'); args.push(JSON.stringify(v.clean)); fields.push('version = ?'); args.push(row.version + 1); } const m = readMeta(b); if (m.title !== undefined) { fields.push('title = ?'); args.push(m.title); } if (m.description !== undefined) { fields.push('description = ?'); args.push(m.description); } if (m.subject !== undefined) { fields.push('subject = ?'); args.push(m.subject); } if (m.grade !== undefined) { fields.push('grade = ?'); args.push(m.grade); } if (m.cat !== undefined) { fields.push('cat = ?'); args.push(m.cat); } if (b.status !== undefined && STATUSES.has(String(b.status))) { fields.push('status = ?'); args.push(String(b.status)); } if (!fields.length) return res.json({ ok: true }); fields.push("updated_at = datetime('now')"); args.push(req.params.id); db.prepare(`UPDATE custom_sims SET ${fields.join(', ')} WHERE id = ?`).run(...args); res.json({ ok: true }); } /* DELETE /api/custom-sims/:id — удалить (владелец/admin). */ function remove(req, res) { const row = db.prepare('SELECT owner_id FROM custom_sims WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.owner_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'forbidden' }); } db.prepare('DELETE FROM custom_sims WHERE id = ?').run(req.params.id); // Заодно чистим осиротевшие курикулумные связи (sim_id = 'custom:'). try { db.prepare("DELETE FROM lab_sim_links WHERE sim_id = ?").run('custom:' + req.params.id); } catch (_e) { /* таблица может отсутствовать */ } res.json({ ok: true }); } /* ════════════════════════════════════════════════════════════════════════ Фаза 6 — раздача классу / клонирование / курикулумная привязка. ════════════════════════════════════════════════════════════════════════ */ /* Проверка владения симуляцией (владелец ИЛИ admin). Возвращает row или null. */ function ownedSim(req) { const row = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id); if (!row) return { row: null, code: 404 }; if (row.owner_id !== req.user.id && req.user.role !== 'admin') return { row: null, code: 403 }; return { row, code: 200 }; } /* POST /api/custom-sims/:id/share body: { classId } * * РЕШЕНИЕ (копия vs доступ): published custom-sim И ТАК видна всем в каталоге * /lab (list/get отдают published любому). Поэтому раздача классу — это НЕ копия * (как у «Моих материалов», где копия нужна т.к. оригинал приватный), а: * 1) авто-публикация (status -> published), чтобы ученики могли открыть; * 2) адресное ДОЛГОВЕЧНОЕ уведомление ученикам класса со ссылкой * /lab?sim=custom: (notifications-таблица + SSE через pushNotif). * Отдельная запись content_access не нужна: custom-sim не гейтится allowlist'ом * 'sim' (тот гейтит только legacy lab_sims); published виден всем. */ function share(req, res) { const { row, code } = ownedSim(req); if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' }); const b = req.body || {}; const classId = Number(b.classId); if (!Number.isFinite(classId)) return res.status(400).json({ error: 'classId required' }); const cls = db.prepare('SELECT id, teacher_id, name FROM classes WHERE id = ?').get(classId); if (!cls) return res.status(404).json({ error: 'class not found' }); if (cls.teacher_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'not your class' }); } // Авто-публикация, чтобы ученики могли открыть симуляцию по ссылке. if (row.status !== 'published') { db.prepare("UPDATE custom_sims SET status = 'published', updated_at = datetime('now') WHERE id = ?").run(row.id); } const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель'; const isGame = row.cat === 'game'; const simTitle = row.title || (isGame ? 'игровой уровень' : 'симуляция'); // Игровой уровень открывается в «Квантике» (/quantik?level=custom:), // обычная симуляция — в лаборатории (/lab?sim=custom:). Фаза 5/6. const link = (isGame ? '/quantik?level=custom:' : '/lab?sim=custom:') + row.id; const notifType = isGame ? 'game_level_shared' : 'sim_shared'; const notifMsg = isGame ? `Новый игровой уровень от ${teacherName}: «${simTitle}»` : `Новая симуляция от ${teacherName}: «${simTitle}»`; const recipients = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId).map(r => r.user_id); let sent = 0; for (const uid of recipients) { if (!uid || uid === req.user.id) continue; pushNotif(uid, notifType, notifMsg, link); sent++; } res.json({ ok: true, sent, status: 'published', link }); } /* POST /api/custom-sims/:id/clone — копия спеки текущему пользователю как draft. * Источник: своя (любой статус) ИЛИ чужая published. Заголовок += « (копия)». * Метаданные (subject/grade/cat) копируются; status всегда draft; version=1. */ function clone(req, res) { const src = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id); if (!src) return res.status(404).json({ error: 'not found' }); // Клонировать можно свою (любую) или чужую только published. if (src.owner_id !== req.user.id && src.status !== 'published' && req.user.role !== 'admin') { return res.status(403).json({ error: 'forbidden' }); } const baseTitle = src.title || 'Симуляция'; const title = sanitizeText(baseTitle + ' (копия)'); const r = db.prepare(` INSERT INTO custom_sims (owner_id, title, description, subject, grade, cat, spec_json, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', 1) `).run( req.user.id, title, src.description, src.subject, src.grade, src.cat, src.spec_json, ); res.status(201).json({ id: Number(r.lastInsertRowid) }); } /* ── Курикулумная привязка: переиспользуем lab_sim_links с sim_id='custom:'. sim_id в таблице — TEXT, поэтому отдельная таблица не нужна. Управляет связями ВЛАДЕЛЕЦ симуляции (или admin), а не только admin как у lab_sims: custom-sim принадлежит учителю. ───────────────────────────────────────── */ const LINK_KINDS = new Set(['textbook', 'topic', 'kmap', 'question']); function decorateLink(l) { const out = { id: l.id, kind: l.kind, ref_id: l.ref_id, label: l.label || null }; if (l.kind === 'textbook') { const tb = db.prepare('SELECT title, subject, grade FROM textbooks WHERE slug = ?').get(l.ref_id); if (tb) { out.label = out.label || tb.title; out.subject = tb.subject; out.grade = tb.grade; } out.href = '/textbooks?book=' + encodeURIComponent(l.ref_id); } else if (l.kind === 'topic') { const tp = db.prepare('SELECT name FROM topics WHERE id = ?').get(Number(l.ref_id)); if (tp) out.label = out.label || tp.name; } if (!out.label) out.label = l.kind + ':' + l.ref_id; return out; } /* GET /api/custom-sims/:id/related → { links:{ textbook:[], topic:[], kmap:[], question:[] } } Доступно любому, кто может видеть симуляцию (own ИЛИ published). */ function related(req, res) { const sim = db.prepare('SELECT id, owner_id, status FROM custom_sims WHERE id = ?').get(req.params.id); if (!sim) return res.status(404).json({ error: 'not found' }); if (sim.owner_id !== req.user.id && sim.status !== 'published' && req.user.role !== 'admin') { return res.status(403).json({ error: 'forbidden' }); } const simId = 'custom:' + sim.id; let rows; try { rows = db.prepare( 'SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE sim_id = ? ORDER BY kind, id' ).all(simId); } catch (_e) { return res.json({ links: {}, needs_migration: true }); } const links = { textbook: [], topic: [], kmap: [], question: [] }; for (const l of rows) (links[l.kind] || (links[l.kind] = [])).push(decorateLink(l)); res.json({ links }); } /* POST /api/custom-sims/:id/links body: { kind, ref_id, label? } — добавить связь. Владелец/admin. */ function addLink(req, res) { const { row, code } = ownedSim(req); if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' }); const b = req.body || {}; const kind = String(b.kind || ''); const refId = String(b.ref_id || '').trim(); if (!LINK_KINDS.has(kind)) return res.status(400).json({ error: 'неверный kind' }); if (!refId) return res.status(400).json({ error: 'ref_id обязателен' }); // Мягкая валидация существования цели (как в lab.js). if (kind === 'textbook' && !db.prepare('SELECT 1 FROM textbooks WHERE slug = ?').get(refId)) { return res.status(404).json({ error: 'учебник не найден: ' + refId }); } if (kind === 'topic') { const tid = Number(refId); if (!Number.isInteger(tid) || !db.prepare('SELECT 1 FROM topics WHERE id = ?').get(tid)) { return res.status(404).json({ error: 'тема не найдена: ' + refId }); } } const label = b.label != null ? (String(b.label).trim().slice(0, 200) || null) : null; const simId = 'custom:' + row.id; try { const info = db.prepare( 'INSERT INTO lab_sim_links (sim_id, kind, ref_id, label, created_by) VALUES (?, ?, ?, ?, ?)' ).run(simId, kind, refId, label, req.user.id); const created = db.prepare('SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE id = ?') .get(info.lastInsertRowid); res.json({ ok: true, link: decorateLink(created) }); } catch (e) { if (/UNIQUE/i.test(e.message)) return res.status(409).json({ error: 'такая связь уже есть' }); throw e; } } /* DELETE /api/custom-sims/:id/links/:linkId — удалить связь. Владелец/admin. */ function removeLink(req, res) { const { row, code } = ownedSim(req); if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' }); const linkId = Number(req.params.linkId); if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' }); const simId = 'custom:' + row.id; const info = db.prepare('DELETE FROM lab_sim_links WHERE id = ? AND sim_id = ?').run(linkId, simId); if (!info.changes) return res.status(404).json({ error: 'связь не найдена' }); res.json({ ok: true }); } module.exports = { list, get, create, update, remove, validateSpec, share, clone, related, addLink, removeLink, };