feat(materials): серверные миниатюры (sharp) + пагинация рендера списка

Миниатюры: uploadPersonalFile генерирует webp ≤480px (sharp), возвращает {url, thumbUrl}; колонка thumb_url (мигр.074); грид рисует <img> на миниатюре, просмотр/скачивание/аннотация — полный url. Ссылочная чистка матчит url И thumb_url; share копирует thumb; квота учитывает файл+миниатюру. Сейверы board-clip/material-save/textbook-clip/draw пробрасывают thumbUrl.

Пагинация: клиент рендерит PAGE_SIZE=60 карточек + «Показать ещё» (сброс на смену фильтра), сохраняя клиентский поиск/сортировку над полным списком.

Тесты: materials.test.js 16→19. План V2 выполнен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 14:40:23 +03:00
parent abe84b9f90
commit 786419ce01
9 changed files with 179 additions and 70 deletions
+45
View File
@@ -194,4 +194,49 @@ describe('/api/materials', () => {
assert.equal(ctrl.measureBytes('note', null, 'abc'), 3);
assert.equal(ctrl.measureBytes('link', 'https://x', null), 0);
});
it('thumb_url: create stores it, list/getOne return it; bad scheme → 400', async () => {
const ok = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/a.png', thumbUrl: '/uploads/materials/a_thumb.webp' }, studentToken);
assert.equal(ok.status, 201, JSON.stringify(ok.body));
const id = ok.body.id;
const l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === id).thumb_url, '/uploads/materials/a_thumb.webp');
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
assert.equal(one.body.thumb_url, '/uploads/materials/a_thumb.webp');
const bad = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/b.png', thumbUrl: 'javascript:alert(1)' }, studentToken);
assert.equal(bad.status, 400, JSON.stringify(bad.body));
});
it('releaseFileForUrl ref-counts files referenced as a thumbnail (thumb_url column)', () => {
const dir = path.join(__dirname, '..', 'uploads', 'materials');
fs.mkdirSync(dir, { recursive: true });
const fname = 'th_' + Date.now() + '.webp';
const fpath = path.join(dir, fname);
const url = '/uploads/materials/' + fname;
fs.writeFileSync(fpath, Buffer.from([0x52, 0x49, 0x46, 0x46]));
const rid = db.prepare('INSERT INTO student_materials (user_id, kind, thumb_url) VALUES (?, ?, ?)').run(studentId, 'image', url).lastInsertRowid;
ctrl.releaseFileForUrl(url);
assert.ok(fs.existsSync(fpath), 'kept while a row references it as thumb_url');
db.prepare('DELETE FROM student_materials WHERE id = ?').run(rid);
ctrl.releaseFileForUrl(url);
assert.ok(!fs.existsSync(fpath), 'unlinked once orphaned');
});
it('DELETE removes the material\'s full image AND thumbnail files', async () => {
const dir = path.join(__dirname, '..', 'uploads', 'materials');
fs.mkdirSync(dir, { recursive: true });
const base = 'del_' + Date.now();
const fFull = path.join(dir, base + '.png'), fThumb = path.join(dir, base + '_thumb.webp');
fs.writeFileSync(fFull, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
fs.writeFileSync(fThumb, Buffer.from([0x52, 0x49, 0x46, 0x46]));
const c = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/' + base + '.png', thumbUrl: '/uploads/materials/' + base + '_thumb.webp' }, studentToken);
assert.equal(c.status, 201);
const d = await inject('DELETE', `/api/materials/${c.body.id}`, null, studentToken);
assert.equal(d.status, 200);
assert.ok(!fs.existsSync(fFull), 'full image unlinked');
assert.ok(!fs.existsSync(fThumb), 'thumbnail unlinked');
});
});