Files
Learn_System/backend/src/controllers/customSimController.js
T
Maxim Dolgolyov 69df2f8190 @
chore(quantik-game): полировка по финальному ревью + security-review

Финальное ревью: READY TO MERGE (0 блокеров). Security: SECURE (0 critical).
Применены дешёвые фиксы из ревью:
- validateSpec: блок game{} санитизируется ПОИМЁННО (chapter/subject →
  sanitizeText, order/par_ms/unlockStars → проверка типа, неизвестные ключи
  отбрасываются) — закрыт латентный хранимый XSS (раньше clean.game=spec.game).
- quantik.html: @media (prefers-reduced-motion) делает анимации мгновенными
  (не выключает — иначе forwards-появление узлов оставило бы их скрытыми).
- progress-logic.js: фикс комментария isUnlocked (сумма звёзд по ВСЕМ уровням
  с меньшим глобальным order, а не «той же главы»).
План: Ф6 (лидерборд/гонка) удалена (Amendment 1, решение пользователя);
финальные гейты отмечены; deferred-бэклог зафиксирован.
Затронутые тесты 45/45; lint:routes 0.

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

610 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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:<id>').
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:<id> (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:<id>),
// обычная симуляция — в лаборатории (/lab?sim=custom:<id>). Фаза 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:<id>'.
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,
};