From abe84b9f909c433b60b7d6ee34096ca3249c0ea1 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 13 Jun 2026 14:21:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(materials):=20=C2=AB=D0=9C=D0=BE=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D1=8B=C2=BB=20?= =?UTF-8?q?v2=20=E2=80=94=20=D1=85=D0=B0=D1=80=D0=B4=D0=BD=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=B8=20=D0=B4=D0=BE=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Безопасность/целостность: allowlist схемы URL (safeUrl) против stored-XSS через javascript:-ссылку; ссылочно-подсчётная чистка файлов при delete/смене url (releaseFileForUrl, учёт share-алиасов); квота на пользователя — число материалов + байты (колонка bytes, миграция 073). Производительность: список отдаёт превью body (1000 симв.) + body_trunc; полный текст — ленивый GET /api/materials/:id (getOne, owner-only). Фичи/UX (my-materials.html): теги-UI (ввод + чипы-фильтр + пилюля), ссылка на исходный урок, сортировка, множественный выбор + массовые действия, цвет/порядок папок, live-KaTeX в редакторе заметки. Тесты: backend/tests/materials.test.js (16 тестов) — ранее их не было. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/fileController.js | 9 + .../controllers/studentMaterialsController.js | 104 +++++++- .../src/db/migrations/073_material_bytes.sql | 12 + backend/src/routes/materials.js | 2 + backend/tests/materials.test.js | 197 +++++++++++++++ frontend/my-materials.html | 228 ++++++++++++++++-- js/api.js | 3 +- plans/my-materials/PLAN_V2.md | 57 +++++ 8 files changed, 578 insertions(+), 34 deletions(-) create mode 100644 backend/src/db/migrations/073_material_bytes.sql create mode 100644 backend/tests/materials.test.js create mode 100644 plans/my-materials/PLAN_V2.md diff --git a/backend/src/controllers/fileController.js b/backend/src/controllers/fileController.js index cb644f0..74b35da 100644 --- a/backend/src/controllers/fileController.js +++ b/backend/src/controllers/fileController.js @@ -182,6 +182,15 @@ function uploadPersonalFile(req, res) { return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' }); } + // Per-user storage quota: reject before the file becomes usable. Accounting + // is by student_materials.bytes (the uploaded file is not a material yet). + const used = db.prepare('SELECT COALESCE(SUM(bytes),0) AS b FROM student_materials WHERE user_id = ?').get(req.user.id); + const maxBytes = Number(process.env.MATERIALS_MAX_BYTES) || 300 * 1024 * 1024; // 300 MB + if (used.b + (req.file.size || 0) > maxBytes) { + try { fs.unlinkSync(filePath); } catch {} + return res.status(413).json({ error: 'Превышен лимит хранилища материалов' }); + } + res.status(201).json({ url: '/uploads/materials/' + req.file.filename }); } diff --git a/backend/src/controllers/studentMaterialsController.js b/backend/src/controllers/studentMaterialsController.js index 0f7f6ca..60446c2 100644 --- a/backend/src/controllers/studentMaterialsController.js +++ b/backend/src/controllers/studentMaterialsController.js @@ -2,16 +2,64 @@ /* Student-owned personal materials ("Мои материалы"). * A user keeps copies of items saved from live lessons; the copies are * independent of the session lifecycle. */ +const fs = require('fs'); +const path = require('path'); const db = require('../db/db'); const { emit } = require('../sse'); const KINDS = ['board', 'note', 'link', 'image']; +// Personal uploads live here (mirrors fileController MATERIALS_DIR). Used for +// reference-counted file cleanup when the material(s) pointing at a file go away. +const MATERIALS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'materials'); + +// Soft per-user cap on the number of materials. Read at call time so tests can +// lower it via env. Byte quota is enforced separately at the upload endpoint. +function maxItems() { return Number(process.env.MATERIALS_MAX_ITEMS) || 2000; } + +// Storable URLs are app-relative ("/…", not protocol-relative "//host") or +// http(s). Everything else (javascript:, data:, mailto:, …) is rejected: a saved +// link is rendered as on the owner's page AND can be handed out to a +// whole class via /share, so a bad scheme would be stored XSS. +// Returns the (length-capped) url, '' for empty, or undefined when invalid. +function safeUrl(raw) { + const u = String(raw == null ? '' : raw).trim(); + if (!u) return ''; + if (/^https?:\/\//i.test(u)) return u.slice(0, 2000); + if (u[0] === '/' && u[1] !== '/') return u.slice(0, 2000); + return undefined; +} + +// Bytes attributed to a material for quota accounting (server-measured). +function measureBytes(kind, url, body) { + if (kind === 'note') return Buffer.byteLength(String(body || ''), 'utf8'); + if ((kind === 'image' || kind === 'board') && typeof url === 'string' && url.startsWith('/uploads/materials/')) { + try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(url))).size; } catch (e) { return 0; } + } + return 0; +} + +// Reference-counted cleanup: unlink the file backing `url` only when NO material +// row references it any more (share/annotate can alias one physical file across +// rows). Call AFTER the delete/url-update so the freed row no longer counts. +// No-op for non-local urls. Exported for tests. +function releaseFileForUrl(url) { + if (typeof url !== 'string' || !url.startsWith('/uploads/materials/')) return; + if (db.prepare('SELECT 1 FROM student_materials WHERE url = ? LIMIT 1').get(url)) return; + const fp = path.join(MATERIALS_DIR, path.basename(url)); + if (fp.startsWith(MATERIALS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch (e) { /* already gone */ } } +} + /* GET /api/materials — list the current user's saved materials + their collections */ function list(req, res) { const uid = req.user.id; + // Return only a body PREVIEW (first 1000 chars) to keep the payload small for + // note-heavy users; the full text is fetched on demand via GET /:id. body_trunc + // tells the client a lazy fetch is needed before viewing/editing the note. const materials = db.prepare(` - SELECT id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at + SELECT id, kind, title, substr(body, 1, 1000) AS body, + (CASE WHEN body IS NOT NULL AND length(body) > 1000 THEN 1 ELSE 0 END) AS body_trunc, + url, source_session_id, source_title, collection_id, tags, created_at FROM student_materials WHERE user_id = ? ORDER BY created_at DESC, id DESC @@ -26,6 +74,19 @@ function list(req, res) { res.json({ materials, collections }); } +/* GET /api/materials/:id — one material with its FULL body (lazy-loaded by the + client when a note's preview was truncated). Owner-only. */ +function getOne(req, res) { + const row = db.prepare(` + SELECT id, user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at + FROM student_materials WHERE id = ? + `).get(req.params.id); + if (!row) return res.status(404).json({ error: 'not found' }); + if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); + delete row.user_id; + res.json(row); +} + /* Validate that a collection id belongs to the user; returns null if unset/invalid. */ function ownCollectionId(raw, uid) { if (raw === null || raw === '' || raw === undefined) return null; @@ -42,9 +103,16 @@ function create(req, res) { const kind = String(b.kind || ''); if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' }); + if (db.prepare('SELECT COUNT(*) AS n FROM student_materials WHERE user_id = ?').get(req.user.id).n >= maxItems()) + return res.status(413).json({ error: 'Достигнут лимит числа материалов' }); + const title = String(b.title || '').slice(0, 300); const body = b.body != null ? String(b.body).slice(0, 60000) : null; - const url = b.url != null ? String(b.url).slice(0, 2000) : null; + let url = null; + if (b.url != null && b.url !== '') { + url = safeUrl(b.url); + if (url === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' }); + } if ((kind === 'note') && !body) return res.status(400).json({ error: 'body required for note' }); if ((kind === 'board' || kind === 'image' || kind === 'link') && !url) return res.status(400).json({ error: 'url required' }); @@ -58,38 +126,49 @@ function create(req, res) { const collectionId = ownCollectionId(b.collection_id, req.user.id); const tags = b.tags != null ? String(b.tags).slice(0, 500) : null; + const bytes = measureBytes(kind, url, body); const r = db.prepare(` - INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags); + INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags, bytes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags, bytes); res.status(201).json({ id: Number(r.lastInsertRowid) }); } /* PATCH /api/materials/:id — rename / edit one of the current user's items. Editable: title, body, url (e.g. re-saving an annotated image), collection_id, tags. */ function update(req, res) { - const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id); + const row = db.prepare('SELECT user_id, url FROM student_materials WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); const b = req.body || {}; const fields = [], args = []; if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); } if (b.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); } - if (b.url !== undefined) { fields.push('url = ?'); args.push(b.url != null ? String(b.url).slice(0, 2000) : null); } + if (b.url !== undefined) { + let nu = null; + if (b.url != null && b.url !== '') { nu = safeUrl(b.url); if (nu === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' }); } + fields.push('url = ?'); args.push(nu); + } if (b.collection_id !== undefined) { fields.push('collection_id = ?'); args.push(ownCollectionId(b.collection_id, req.user.id)); } if (b.tags !== undefined) { fields.push('tags = ?'); args.push(b.tags != null ? String(b.tags).slice(0, 500) : null); } if (!fields.length) return res.json({ ok: true }); args.push(req.params.id); db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args); + // Recompute quota bytes from the persisted row; free the old file if the url + // changed (annotate overwrites url) and nothing else references it. + const cur = db.prepare('SELECT kind, url, body FROM student_materials WHERE id = ?').get(req.params.id); + db.prepare('UPDATE student_materials SET bytes = ? WHERE id = ?').run(measureBytes(cur.kind, cur.url, cur.body), req.params.id); + if (b.url !== undefined && row.url && row.url !== cur.url) releaseFileForUrl(row.url); res.json({ ok: true }); } /* DELETE /api/materials/:id — remove one of the current user's items */ function remove(req, res) { - const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id); + const row = db.prepare('SELECT user_id, url FROM student_materials WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); db.prepare('DELETE FROM student_materials WHERE id = ?').run(req.params.id); + releaseFileForUrl(row.url); // unlink the backing file if no other material aliases it res.json({ ok: true }); } @@ -164,12 +243,13 @@ function share(req, res) { const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель'; const srcTitle = 'Раздатка: ' + teacherName; - const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title) VALUES (?,?,?,?,?,NULL,?)`); + const bytes = measureBytes(mat.kind, mat.url, mat.body); // each copy counts toward the recipient's quota + const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, bytes) VALUES (?,?,?,?,?,NULL,?,?)`); let sent = 0; db.transaction(() => { for (const uid of recipients) { if (!uid || uid === req.user.id) continue; - ins.run(uid, mat.kind, mat.title, mat.body, mat.url, srcTitle); + ins.run(uid, mat.kind, mat.title, mat.body, mat.url, srcTitle, bytes); try { emit(uid, { type: 'notification', notif_type: 'material_shared', message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' }); @@ -180,4 +260,6 @@ function share(req, res) { res.json({ ok: true, sent }); } -module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection, share }; +module.exports = { list, getOne, create, update, remove, createCollection, updateCollection, deleteCollection, share, + // exported for tests / reuse + safeUrl, measureBytes, releaseFileForUrl }; diff --git a/backend/src/db/migrations/073_material_bytes.sql b/backend/src/db/migrations/073_material_bytes.sql new file mode 100644 index 0000000..a18a2fd --- /dev/null +++ b/backend/src/db/migrations/073_material_bytes.sql @@ -0,0 +1,12 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 073: Storage accounting for «Мои материалы» +-- +-- Per-material byte size, used to enforce a per-user storage quota and to free +-- orphaned files. Populated on create/update: +-- image|board → size of the file on disk (server-measured) +-- note → text length +-- link → 0 +-- Existing rows default to 0 (the next edit recomputes them; quota is a soft cap). +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE student_materials ADD COLUMN bytes INTEGER NOT NULL DEFAULT 0; diff --git a/backend/src/routes/materials.js b/backend/src/routes/materials.js index 2090da4..d02d353 100644 --- a/backend/src/routes/materials.js +++ b/backend/src/routes/materials.js @@ -19,6 +19,8 @@ router.patch('/collections/:id', c.updateCollection); // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler router.delete('/collections/:id', c.deleteCollection); +// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler +router.get('/:id', c.getOne); // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler router.patch('/:id', c.update); // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler diff --git a/backend/tests/materials.test.js b/backend/tests/materials.test.js new file mode 100644 index 0000000..8db988d --- /dev/null +++ b/backend/tests/materials.test.js @@ -0,0 +1,197 @@ +'use strict'; +/** + * Integration tests: /api/materials — «Мои материалы» (v2 hardening). + * Covers: auth, CRUD happy-path, ownership (чужой PATCH/DELETE → 403, 404), + * collections (create / move / delete keeps material), share-копия (роль + owner + * + привязка ученика), URL-allowlist (javascript: → 400), лимит числа материалов, + * и ссылочно-подсчётную чистку файла (releaseFileForUrl на временном файле). + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { app, db, inject, getToken, cleanup } = require('./setup'); + +// setup.js не монтирует /api/materials — монтируем на общий тест-app. +app.use('/api/materials', require('../src/routes/materials')); +const ctrl = require('../src/controllers/studentMaterialsController'); + +after(() => cleanup()); + +describe('/api/materials', () => { + let studentToken, studentId, otherToken, teacherToken, teacherId; + + before(async () => { + const s = await getToken('student'); studentToken = s.token; studentId = s.userId; + otherToken = (await getToken('student')).token; + const t = await getToken('teacher'); teacherToken = t.token; teacherId = t.userId; + }); + + it('GET requires auth (401 without token)', async () => { + const res = await inject('GET', '/api/materials', null, null); + assert.equal(res.status, 401, `got ${res.status}`); + }); + + let noteId; + it('student can create a note (201) and list it back', async () => { + const c = await inject('POST', '/api/materials', { kind: 'note', title: 'Закон Ома', body: 'U=IR' }, studentToken); + assert.equal(c.status, 201, JSON.stringify(c.body)); + noteId = c.body.id; + const l = await inject('GET', '/api/materials', null, studentToken); + assert.equal(l.status, 200); + assert.ok(l.body.materials.some(m => m.id === noteId && m.title === 'Закон Ома')); + }); + + it('accepts http(s) and app-relative link urls', async () => { + const a = await inject('POST', '/api/materials', { kind: 'link', url: 'https://example.com/x' }, studentToken); + assert.equal(a.status, 201, JSON.stringify(a.body)); + const b = await inject('POST', '/api/materials', { kind: 'link', url: '/textbook/phys7#sec-1' }, studentToken); + assert.equal(b.status, 201, JSON.stringify(b.body)); + }); + + it('rejects a link with javascript: scheme (400) — stored-XSS guard', async () => { + const res = await inject('POST', '/api/materials', { kind: 'link', url: 'javascript:alert(1)' }, studentToken); + assert.equal(res.status, 400, JSON.stringify(res.body)); + }); + + it('rejects a protocol-relative url (400)', async () => { + const res = await inject('POST', '/api/materials', { kind: 'link', url: '//evil.example.com' }, studentToken); + assert.equal(res.status, 400, JSON.stringify(res.body)); + }); + + it('PATCH cannot smuggle a javascript: url (400)', async () => { + const res = await inject('PATCH', `/api/materials/${noteId}`, { url: 'javascript:alert(1)' }, studentToken); + assert.equal(res.status, 400, JSON.stringify(res.body)); + }); + + it('owner can rename; others get 403; missing → 404', async () => { + const ok = await inject('PATCH', `/api/materials/${noteId}`, { title: 'Ом' }, studentToken); + assert.equal(ok.status, 200); + const forbidden = await inject('PATCH', `/api/materials/${noteId}`, { title: 'hack' }, otherToken); + assert.equal(forbidden.status, 403); + const missing = await inject('PATCH', '/api/materials/999999', { title: 'x' }, studentToken); + assert.equal(missing.status, 404); + }); + + it('list returns a 1000-char body preview; GET /:id returns the full body (owner only)', async () => { + const big = 'x'.repeat(1500); + const c = await inject('POST', '/api/materials', { kind: 'note', body: big }, studentToken); + const id = c.body.id; + const l = await inject('GET', '/api/materials', null, studentToken); + const row = l.body.materials.find(m => m.id === id); + assert.equal(row.body.length, 1000, 'preview trimmed'); + assert.equal(row.body_trunc, 1, 'truncation flagged'); + const one = await inject('GET', `/api/materials/${id}`, null, studentToken); + assert.equal(one.status, 200); + assert.equal(one.body.body.length, 1500, 'full body returned'); + assert.equal(one.body.user_id, undefined, 'user_id not leaked'); + const forbidden = await inject('GET', `/api/materials/${id}`, null, otherToken); + assert.equal(forbidden.status, 403); + const missing = await inject('GET', '/api/materials/999999', null, studentToken); + assert.equal(missing.status, 404); + }); + + it('collections: create, move material in, delete keeps material (uncategorised)', async () => { + const col = await inject('POST', '/api/materials/collections', { name: 'Физика' }, studentToken); + assert.equal(col.status, 201, JSON.stringify(col.body)); + const cid = col.body.id; + const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: cid }, studentToken); + assert.equal(mv.status, 200); + let l = await inject('GET', '/api/materials', null, studentToken); + assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, cid); + assert.equal(l.body.collections.find(c => c.id === cid).count, 1); + const del = await inject('DELETE', `/api/materials/collections/${cid}`, null, studentToken); + assert.equal(del.status, 200); + l = await inject('GET', '/api/materials', null, studentToken); + assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null, 'material survives folder delete'); + }); + + it('moving into another user\'s collection is ignored (collection_id stays null)', async () => { + const col = await inject('POST', '/api/materials/collections', { name: 'Чужая' }, otherToken); + const foreignCid = col.body.id; + const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: foreignCid }, studentToken); + assert.equal(mv.status, 200); + const l = await inject('GET', '/api/materials', null, studentToken); + assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null); + }); + + it('share: student is role-gated (403)', async () => { + const res = await inject('POST', `/api/materials/${noteId}/share`, { userId: studentId }, studentToken); + assert.equal(res.status, 403, JSON.stringify(res.body)); + }); + + it('share: teacher → linked student copies the material; unlinked → 403', async () => { + const tNote = await inject('POST', '/api/materials', { kind: 'note', title: 'Раздатка', body: 'привет' }, teacherToken); + const tId = tNote.body.id; + // not linked yet + const denied = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken); + assert.equal(denied.status, 403, JSON.stringify(denied.body)); + // link teacher → student, then share + db.prepare('INSERT INTO teacher_students (teacher_id, student_id) VALUES (?, ?)').run(teacherId, studentId); + const ok = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken); + assert.equal(ok.status, 200, JSON.stringify(ok.body)); + assert.equal(ok.body.sent, 1); + const l = await inject('GET', '/api/materials', null, studentToken); + assert.ok(l.body.materials.some(m => m.title === 'Раздатка' && /Раздатка:/.test(m.source_title || '')), 'student received a copy'); + }); + + it('enforces the per-user item cap (413)', async () => { + const q = await getToken('student'); + const prev = process.env.MATERIALS_MAX_ITEMS; + process.env.MATERIALS_MAX_ITEMS = '3'; + try { + for (let i = 0; i < 3; i++) { + const r = await inject('POST', '/api/materials', { kind: 'note', body: 'n' + i }, q.token); + assert.equal(r.status, 201, `create #${i}: ${JSON.stringify(r.body)}`); + } + const over = await inject('POST', '/api/materials', { kind: 'note', body: 'overflow' }, q.token); + assert.equal(over.status, 413, JSON.stringify(over.body)); + } finally { + if (prev === undefined) delete process.env.MATERIALS_MAX_ITEMS; else process.env.MATERIALS_MAX_ITEMS = prev; + } + }); + + it('delete removes the row; owner only', async () => { + const m = await inject('POST', '/api/materials', { kind: 'note', body: 'temp' }, studentToken); + const id = m.body.id; + const forbidden = await inject('DELETE', `/api/materials/${id}`, null, otherToken); + assert.equal(forbidden.status, 403); + const ok = await inject('DELETE', `/api/materials/${id}`, null, studentToken); + assert.equal(ok.status, 200); + const l = await inject('GET', '/api/materials', null, studentToken); + assert.ok(!l.body.materials.some(x => x.id === id)); + }); + + it('releaseFileForUrl: unlinks the file only when no material references it', () => { + const dir = path.join(__dirname, '..', 'uploads', 'materials'); + fs.mkdirSync(dir, { recursive: true }); + const fname = 'test_' + Date.now() + '.png'; + const fpath = path.join(dir, fname); + const url = '/uploads/materials/' + fname; + fs.writeFileSync(fpath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const ins = db.prepare('INSERT INTO student_materials (user_id, kind, url) VALUES (?, ?, ?)'); + const r1 = ins.run(studentId, 'image', url).lastInsertRowid; + const r2 = ins.run(studentId, 'image', url).lastInsertRowid; // aliasing copy (как при share) + + ctrl.releaseFileForUrl(url); + assert.ok(fs.existsSync(fpath), 'file kept while two rows reference it'); + + db.prepare('DELETE FROM student_materials WHERE id = ?').run(r1); + ctrl.releaseFileForUrl(url); + assert.ok(fs.existsSync(fpath), 'file kept while one row still references it'); + + db.prepare('DELETE FROM student_materials WHERE id = ?').run(r2); + ctrl.releaseFileForUrl(url); + assert.ok(!fs.existsSync(fpath), 'file unlinked once orphaned'); + }); + + it('safeUrl / measureBytes behave as documented', () => { + assert.equal(ctrl.safeUrl('https://a.b/c'), 'https://a.b/c'); + assert.equal(ctrl.safeUrl('/textbook/x'), '/textbook/x'); + assert.equal(ctrl.safeUrl(''), ''); + assert.equal(ctrl.safeUrl('javascript:x'), undefined); + assert.equal(ctrl.safeUrl('//host'), undefined); + assert.equal(ctrl.measureBytes('note', null, 'abc'), 3); + assert.equal(ctrl.measureBytes('link', 'https://x', null), 0); + }); +}); diff --git a/frontend/my-materials.html b/frontend/my-materials.html index ceb3c4c..a0f04d1 100644 --- a/frontend/my-materials.html +++ b/frontend/my-materials.html @@ -70,6 +70,26 @@ .mm-viewer-note { white-space: pre-wrap; word-break: break-word; line-height: 1.6; font-size: 0.9rem; color: var(--text-2); } .mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); } .mm-empty svg { width: 38px; height: 38px; opacity: 0.4; margin-bottom: 12px; } + .mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } + .mm-tag { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 99px; background: rgba(6,182,212,0.12); color: #0891b2; cursor: pointer; transition: background .12s; } + .mm-tag:hover { background: rgba(6,182,212,0.24); } + .mm-src { color: var(--text-3); text-decoration: none; border-bottom: 1px dotted var(--text-3); } + .mm-src:hover { color: var(--violet); border-bottom-color: var(--violet); } + .mm-tagpill { display: inline-flex; align-items: center; gap: 4px; font-size: .76rem; font-weight: 600; padding: 6px 10px; border-radius: 9px; background: rgba(155,93,229,0.12); color: var(--violet); } + .mm-tagpill-x { display: inline-flex; cursor: pointer; } + .mm-tagpill-x svg { width: 13px; height: 13px; } + .mm-check { position: absolute; top: 10px; left: 10px; z-index: 3; width: 18px; height: 18px; cursor: pointer; accent-color: var(--violet); opacity: 0; transition: opacity .12s; } + .mm-card:hover .mm-check, .mm-check:checked { opacity: 1; } + .mm-card.mm-selected { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.35); } + .mm-bulk { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; padding: 10px 12px; border: 1px solid var(--violet); border-radius: 10px; background: rgba(155,93,229,0.06); } + .mm-bulk-count { font-weight: 700; font-size: .84rem; color: var(--violet); margin-right: auto; } + .mm-swatches { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; } + .mm-swatch { width: 26px; height: 26px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); } + .mm-swatch.on { border-color: var(--text); } + .mm-swatch-none { background: repeating-linear-gradient(45deg,#fff,#fff 4px,#e2e8f0 4px,#e2e8f0 8px); } + .mm-preview { min-height: 22px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 8px; font-size: .86rem; color: var(--text-2); background: rgba(148,163,184,0.06); white-space: pre-wrap; word-break: break-word; line-height: 1.5; } + .mm-preview:empty::before { content: 'Превью формул появится здесь…'; color: var(--text-3); } + @media (max-width: 768px) { .mm-check { opacity: .85; } } @media (max-width: 768px) { .mm-body { flex-direction: column; } .mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; } @@ -109,7 +129,15 @@ + + +
Загрузка…
@@ -149,6 +177,9 @@ } return tmp.innerHTML; } + /* Live formula preview for the note editor (renders $…$ as you type). */ + function mmPreview(ta, prevId) { const p = document.getElementById(prevId); if (p) p.innerHTML = mathHtml(ta.value); } + window.mmPreview = mmPreview; function fmtDate(s) { if (!s) return ''; try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z')); @@ -170,9 +201,39 @@ if (u.startsWith('/lab')) return 'Лаборатория'; return 'Ссылка'; } + function parseTags(s) { return String(s || '').split(',').map(t => t.trim()).filter(Boolean); } + /* Only trust folder colors that look like a hex value (guards inline-style injection). */ + function safeColor(c) { return /^#[0-9a-fA-F]{3,8}$/.test(String(c || '')) ? c : ''; } + + /* Meta line: source title links back to the originating lesson when known. */ + function metaHtml(m) { + const date = fmtDate(m.created_at); + let src = ''; + if (m.source_title) { + src = m.source_session_id + ? `
${esc(m.source_title)}` + : esc(m.source_title); + src += ' · '; + } + return src + esc(date); + } + /* Tag chips (click → filter). data-t carries the raw value, dodging JS-string injection. */ + function tagsHtml(m) { + const tg = parseTags(m.tags); + if (!tg.length) return ''; + return `
${tg.map(t => `${esc(t)}`).join('')}
`; + } + /* Lazy-load the full note body — the list endpoint returns only a 1000-char preview. */ + async function ensureFullBody(m) { + if (!m || !m.body_trunc) return m; + try { const full = await LS.getMaterial(m.id); if (full && typeof full.body === 'string') { m.body = full.body; m.body_trunc = 0; } } catch (e) {} + return m; + } + let _mats = []; let _cols = []; - const _filter = { col: 'all', kind: 'all', q: '' }; + const _filter = { col: 'all', kind: 'all', q: '', sort: 'new', tag: '' }; + const _sel = new Set(); // ids selected for bulk actions /* ── Move-to-collection select ── */ function moveSelect(m) { @@ -183,7 +244,10 @@ function card(m) { const kind = KIND_LABEL[m.kind] || m.kind; - const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`; + const meta = metaHtml(m); + const tags = tagsHtml(m); + const selCls = _sel.has(m.id) ? ' mm-selected' : ''; + const cb = ``; const chip = `${kind}`; const del = ``; const edit = ``; @@ -196,12 +260,12 @@ const mv = moveSelect(m); if (m.kind === 'board' || m.kind === 'image') { - return `
+ return `
${cb}
${chip}
${esc(m.title || kind)}
-
${meta}
+
${meta}
${tags}
${mv} @@ -213,7 +277,7 @@ } if (m.kind === 'link') { - return `
+ return `
${cb} @@ -224,7 +288,7 @@
${chip}
${esc(m.title || kind)}
-
${meta}
+
${meta}
${tags}
${mv} Открыть @@ -235,29 +299,31 @@ } // note - return `
+ return `
${cb}
${mathHtml(m.body || '')}
${chip}
${esc(m.title || kind)}
-
${meta}
+
${meta}
${tags}
${mv}${fc}${sh}${edit}${del}
`; } /* ── Folder rail (вертикальный список папок слева) ── */ - function railItem(key, label, count, editId, droppable) { + function railItem(key, label, count, editId, droppable, color) { const active = _filter.col === key ? ' active' : ''; const ed = editId ? `${PENCIL}` : ''; const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder'); + const tint = safeColor(color); + const icStyle = (tint && !active) ? ` style="color:${tint}"` : ''; const drop = droppable ? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"` : ''; return `
- + ${esc(label)} ${count}${ed}
`; @@ -266,7 +332,7 @@ const bar = document.getElementById('mm-cols'); const noneCount = _mats.filter(m => !m.collection_id).length; let html = railItem('all', 'Все', _mats.length, null, false); - _cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true); }); + _cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true, c.color); }); html += railItem('none', 'Без папки', noneCount, null, true); bar.innerHTML = html; } @@ -299,17 +365,28 @@ window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd; window.mmDragOver = mmDragOver; window.mmDragLeave = mmDragLeave; window.mmDrop = mmDrop; + function sortRows(rows) { + const s = _filter.sort || 'new'; + if (s === 'new') return rows; // server already returns newest-first + const a = rows.slice(); + if (s === 'old') a.reverse(); + else if (s === 'title') a.sort((x, y) => (x.title || x.body || '').localeCompare(y.title || y.body || '', 'ru')); + else if (s === 'kind') a.sort((x, y) => (x.kind || '').localeCompare(y.kind || '')); + return a; + } function filtered() { - return _mats.filter(m => { + const rows = _mats.filter(m => { if (_filter.col === 'none' && m.collection_id) return false; if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false; if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false; + if (_filter.tag && !parseTags(m.tags).includes(_filter.tag)) return false; if (_filter.q) { const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase(); if (!hay.includes(_filter.q)) return false; } return true; }); + return sortRows(rows); } function renderGrid() { @@ -327,6 +404,7 @@ ? rows.map(card).join('') : `

Ничего не найдено

`; lucide.createIcons(); + renderBulk(); } async function load() { @@ -336,6 +414,7 @@ _cols = data.collections || []; renderCols(); renderGrid(); + renderTagFilter(); } catch (e) { document.getElementById('mm-grid').innerHTML = `
Ошибка загрузки
`; } @@ -345,7 +424,63 @@ function setCol(key) { _filter.col = key; renderCols(); renderGrid(); } function onKind(v) { _filter.kind = v; renderGrid(); } function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); } + function onSort(v) { _filter.sort = v; renderGrid(); } + function filterTag(t) { _filter.tag = String(t || ''); renderTagFilter(); renderGrid(); } + function clearTag() { _filter.tag = ''; renderTagFilter(); renderGrid(); } + function renderTagFilter() { + const el = document.getElementById('mm-tagfilter'); + if (!el) return; + el.innerHTML = _filter.tag + ? `#${esc(_filter.tag)} ` + : ''; + if (window.lucide) lucide.createIcons(); + } window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch; + window.onSort = onSort; window.filterTag = filterTag; window.clearTag = clearTag; + + /* ── Multi-select + bulk actions (reuse per-item endpoints) ── */ + function renderBulk() { + const bar = document.getElementById('mm-bulk'); + if (!bar) return; + const n = _sel.size; + if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; } + const opts = [''] + .concat(_cols.map(c => ``)).join(''); + bar.style.display = 'flex'; + bar.innerHTML = `Выбрано: ${n} + + + `; + if (window.lucide) lucide.createIcons(); + } + function toggleSel(e, id) { + e.stopPropagation(); + const cb = e.target; + if (cb.checked) _sel.add(id); else _sel.delete(id); + const cardEl = cb.closest('.mm-card'); + if (cardEl) cardEl.classList.toggle('mm-selected', cb.checked); + renderBulk(); + } + function clearSel() { _sel.clear(); renderGrid(); } + async function bulkMove(v) { + if (v === '') return; + const cid = v === '__none' ? null : Number(v); + const ids = [..._sel]; + try { + for (const id of ids) await LS.updateMaterial(id, { collection_id: cid }); + _sel.clear(); load(); LS.toast('Перемещено: ' + ids.length, 'success'); + } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } + async function bulkDelete() { + const ids = [..._sel]; + if (!ids.length) return; + if (!await LS.confirm(`Будет удалено материалов: ${ids.length}. Действие необратимо.`, { title: 'Удалить выбранные?', confirmText: 'Удалить' })) return; + try { + for (const id of ids) await LS.deleteMaterial(id); + _sel.clear(); load(); LS.toast('Удалено: ' + ids.length, 'success'); + } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } + window.toggleSel = toggleSel; window.clearSel = clearSel; window.bulkMove = bulkMove; window.bulkDelete = bulkDelete; /* ── Material actions ── */ async function moveMaterial(id, cid) { @@ -365,46 +500,56 @@ function createNote() { const content = `
- + +
+
`; const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [ { label: 'Отмена', onClick: () => m.close() }, { label: 'Создать', primary: true, onClick: async () => { const title = m.body.querySelector('#mm-nt-title').value.trim(); const text = m.body.querySelector('#mm-nt-body').value.trim(); + const tags = m.body.querySelector('#mm-nt-tags').value.trim() || null; if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; } const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null; - try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); } + try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } }, ] }); } window.createNote = createNote; - function editMaterial(id) { + async function editMaterial(id) { const mt = _mats.find(x => x.id === id); if (!mt) return; const isNote = mt.kind === 'note'; + if (isNote) await ensureFullBody(mt); const content = `
- ${isNote ? `` : ''} + ${isNote ? `
` : ''} +
`; const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [ { label: 'Отмена', onClick: () => m.close() }, { label: 'Сохранить', primary: true, onClick: async () => { - const data = { title: m.body.querySelector('#mm-ed-title').value.trim() }; + const data = { + title: m.body.querySelector('#mm-ed-title').value.trim(), + tags: m.body.querySelector('#mm-ed-tags').value.trim() || null, + }; if (isNote) data.body = m.body.querySelector('#mm-ed-body').value; try { await LS.updateMaterial(id, data); m.close(); load(); } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } }, ] }); + if (isNote) { const ta = m.body.querySelector('#mm-ed-body'); if (ta) mmPreview(ta, 'mm-ed-prev'); } } window.editMaterial = editMaterial; /* ── Просмотр материала в модалке (лайтбокс) ── */ - function openViewer(id) { + async function openViewer(id) { const mt = _mats.find(x => x.id === id); if (!mt) return false; + if (mt.kind === 'note') await ensureFullBody(mt); const kind = KIND_LABEL[mt.kind] || mt.kind; let body; if (mt.kind === 'image' || mt.kind === 'board') { @@ -431,24 +576,61 @@ window.openViewer = openViewer; /* ── Collection CRUD ── */ + const COL_PALETTE = ['#9b5de5', '#06b6d4', '#f97316', '#10b981', '#ef4444', '#eab308', '#3b82f6', '#ec4899']; + function colorPalette(sel) { + sel = safeColor(sel); + return `
Цвет
+
+ + ${COL_PALETTE.map(c => ``).join('')} +
`; + } + function pickSwatch(el) { el.parentNode.querySelectorAll('.mm-swatch').forEach(s => s.classList.remove('on')); el.classList.add('on'); } + function pickedColor(body) { const on = body.querySelector('.mm-swatch.on'); return on ? (on.dataset.c || null) : null; } + window.pickSwatch = pickSwatch; + function createCollection() { - const content = ``; + const content = `
+ + ${colorPalette(null)} +
`; const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [ { label: 'Отмена', onClick: () => m.close() }, { label: 'Создать', primary: true, onClick: async () => { const name = m.body.querySelector('#mm-col-name').value.trim(); if (!name) { LS.toast('Введите название', 'warn'); return; } - try { await LS.createMaterialCollection({ name }); m.close(); load(); } + try { await LS.createMaterialCollection({ name, color: pickedColor(m.body) }); m.close(); load(); } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } }, ] }); } window.createCollection = createCollection; + /* Reorder a folder up/down by normalizing sort_order to the new index order. */ + async function moveCollection(id, dir) { + const arr = _cols.slice(); + const i = arr.findIndex(c => c.id === id); + const j = i + dir; + if (i < 0 || j < 0 || j >= arr.length) return; + [arr[i], arr[j]] = [arr[j], arr[i]]; + try { + await Promise.all(arr.map((c, k) => c.sort_order !== k ? LS.updateMaterialCollection(c.id, { sortOrder: k }) : null).filter(Boolean)); + load(); + } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } + window.moveCollection = moveCollection; + function editCollection(id) { const col = _cols.find(c => c.id === id); if (!col) return; - const content = ``; + const content = `
+ + ${colorPalette(col.color)} +
+ + +
+
`; const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [ { label: 'Удалить', onClick: async () => { if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return; @@ -459,10 +641,11 @@ { label: 'Сохранить', primary: true, onClick: async () => { const name = m.body.querySelector('#mm-col-name').value.trim(); if (!name) { LS.toast('Введите название', 'warn'); return; } - try { await LS.updateMaterialCollection(id, { name }); m.close(); load(); } + try { await LS.updateMaterialCollection(id, { name, color: pickedColor(m.body) }); m.close(); load(); } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } }, ] }); + if (window.lucide) lucide.createIcons(); } window.editCollection = editCollection; @@ -525,6 +708,7 @@ async function toFlashcard(id) { const mt = _mats.find(x => x.id === id); if (!mt) return; + await ensureFullBody(mt); let decks = []; try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {} const opts = [''] diff --git a/js/api.js b/js/api.js index 4f19df5..5452359 100644 --- a/js/api.js +++ b/js/api.js @@ -1037,7 +1037,7 @@ window.LS = { crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession, crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory, crAdminGetAllHistory, crAdminGetTeachersList, - listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, + listMaterials, getMaterial, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete, customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink, @@ -1253,6 +1253,7 @@ async function uploadMaterialFile(formData) { } function downloadFileUrl(id) { return `${API}/files/${id}/download`; } async function listMaterials() { return req('GET', '/materials'); } +async function getMaterial(id) { return req('GET', `/materials/${id}`); } async function saveMaterial(data) { return req('POST', '/materials', data); } async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); } async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); } diff --git a/plans/my-materials/PLAN_V2.md b/plans/my-materials/PLAN_V2.md new file mode 100644 index 0000000..2e37408 --- /dev/null +++ b/plans/my-materials/PLAN_V2.md @@ -0,0 +1,57 @@ +# «Мои материалы» — v2: харднинг и доводка + +> Составлен Opus 2026-06-13. Базовый план (PLAN.md, Фазы 1–6) **полностью реализован**. +> Его раздел «Сквозные риски» отложил ровно то, что закрывает этот план: учёт/лимиты/чистку +> хранилища и `materials.test.js`. Источник истины по текущему состоянию — код +> (`studentMaterialsController.js`, `materials.js`, `my-materials.html`, `board-clip.js`, +> `material-save.js`) и [[reference_student_materials]]. + +Готчи проекта: новый `:id`-роут → `// @public-by-design` + проверка владельца; большие HTML — только Edit; +без эмодзи (inline SVG `.ic`); коммит поимённо + push; перезапуск сервера при правке backend; ветка +`feature/sim-builder` в рабочем дереве — НЕ коммитить чужие правки, только свои файлы. + +--- + +## Фаза 1 — Целостность и безопасность (backend, фундамент) ✅ цель этого захода + +1. **Ссылочно-подсчётная чистка файлов.** `DELETE /:id` и смена `url` (аннотация) сейчас оставляют + файл в `uploads/materials/` сиротой. `share` копирует `url` дословно → несколько строк ссылаются на + ОДИН файл, поэтому `unlink` только когда на `url` не ссылается ни одна строка. Хелпер + `releaseFileForUrl(url)` вызывается ПОСЛЕ delete/update. +2. **Allowlist схемы URL.** `create`/`update` принимали любой `url` → `link` со схемой `javascript:` + рендерится как рабочий `` (раздача делает это вектором учитель→ученики). Хелпер `safeUrl`: + только `http(s)://` или app-relative `/…` (не `//host`); иначе 400. +3. **Квота на пользователя.** Колонка `bytes` (мигр. 073), счёт `SUM(bytes)`/`COUNT(*)`. Лимит по числу + материалов — в `create()`; лимит по байтам — в `uploadPersonalFile` (до приёма файла). Конфигурируемо + через `MATERIALS_MAX_ITEMS` / `MATERIALS_MAX_BYTES` (для тестов — низкий потолок). +4. **`backend/tests/materials.test.js`** — CRUD, владелец (403/404), коллекции, share-копия + роль/owner, + валидация URL, лимит числа, ссылочная чистка (прямой вызов хелпера на временном файле). + +## Фаза 2 — Производительность ✅ (частично) +- ✅ `GET /api/materials` отдаёт **обрезанный** `body` (первые 1000 симв.) + флаг `body_trunc`; полный текст — + ленивый `GET /api/materials/:id` (`getOne`, owner-only). Клиент `ensureFullBody()` подгружает перед + просмотром/правкой/флешкартой (иначе правка сохранила бы усечённый текст). +- ⬜ Пагинация/keyset — отложено (клиент пока фильтрует в памяти; включить при росте объёмов). +- ⬜ Серверные миниатюры `board/image` — отложено (нужна обработка картинок; пока `loading=lazy`). + +## Фаза 3 — Доводка заложенных фич ✅ +- ✅ UI тегов: ввод в модалках создания/правки + чипы на карточке (клик → фильтр) + пилюля активного фильтра. +- ✅ Ссылка «открыть исходный урок» на карточке (`/my-lessons?session=`, есть `source_session_id`). +- ✅ Цвет папки (палитра 8 пресетов, тинт иконки в рейле) + сортировка папок «Выше/Ниже» в модалке правки + (нормализует `sort_order` к индексам). `safeColor` гейтит inline-style инъекцию (только hex). + +## Фаза 4 — UX ✅ +- ✅ Варианты сортировки (новые/старые/имя/тип) — селект в тулбаре. +- ✅ Множественный выбор (чекбокс на карточке) + панель массовых действий (переместить/удалить, reuse per-item API). +- ✅ Живое превью KaTeX в редакторе заметки (oninput → `mmPreview` → `mathHtml`). + +### Статус +Сделано и проверено: **Ф1 целиком** (16 backend-тестов), **Ф2/Ф3/Ф4 ✅** (headless-смоук `my-materials.html`: +синтаксис + рендер карточек с deep-link/тегами/чекбоксом + фильтр по тегу + bulk-bar + тинт папки). +Осталось ⬜ (инфра, отложено): пагинация/keyset списка, серверные миниатюры board/image. + +--- + +## Порядок +**Ф1 (этот заход) → Ф2 → Ф3 → Ф4.** Ф1 — серверный фундамент (риск-возврат, без него фронт-фичи множат +мусор). Дальше преимущественно фронтенд `my-materials.html` + точечные ручки API.