feat(materials): «Мои материалы» v2 — харднинг безопасности и доводка UX
Безопасность/целостность: 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <a href> 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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user