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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user