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>
@
610 lines
30 KiB
JavaScript
610 lines
30 KiB
JavaScript
'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, '<').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:<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,
|
||
};
|