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:
@@ -1,6 +1,7 @@
|
||||
const db = require('../db/db');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const { UPLOADS_DIR } = require('../config');
|
||||
|
||||
const { checkMagicBytes } = require('../utils/magic');
|
||||
@@ -173,25 +174,41 @@ function uploadFile(req, res) {
|
||||
* teacher library upload above. Image-only; saved into uploads/materials and
|
||||
* served statically (public), so the returned URL renders in <img>, opens in
|
||||
* a new tab and downloads without an auth header. Returns { url }. */
|
||||
function uploadPersonalFile(req, res) {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
async function uploadPersonalFile(req, res) {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
|
||||
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
|
||||
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
|
||||
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
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: 'Превышен лимит хранилища материалов' });
|
||||
}
|
||||
|
||||
const url = '/uploads/materials/' + req.file.filename;
|
||||
// Server-side thumbnail (downscaled webp) for fast grid rendering; the full
|
||||
// image stays for viewing/annotating/download. Best-effort — on any failure
|
||||
// (animated gif, decode error) thumbUrl is null and the client uses `url`.
|
||||
let thumbUrl = null;
|
||||
try {
|
||||
const thumbName = path.basename(req.file.filename, path.extname(req.file.filename)) + '_thumb.webp';
|
||||
const thumbPath = path.resolve(UPLOADS_DIR, 'materials', thumbName);
|
||||
await sharp(filePath).rotate().resize(480, 480, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(thumbPath);
|
||||
thumbUrl = '/uploads/materials/' + thumbName;
|
||||
} catch (e) { thumbUrl = null; }
|
||||
|
||||
return res.status(201).json({ url, thumbUrl });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Upload failed' });
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
|
||||
|
||||
@@ -30,22 +30,27 @@ function safeUrl(raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Bytes attributed to a material for quota accounting (server-measured).
|
||||
function measureBytes(kind, url, body) {
|
||||
// Size on disk of a local materials file (0 if absent / non-local).
|
||||
function fileBytes(u) {
|
||||
if (typeof u !== 'string' || !u.startsWith('/uploads/materials/')) return 0;
|
||||
try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(u))).size; } catch (e) { return 0; }
|
||||
}
|
||||
|
||||
// Bytes attributed to a material for quota accounting (server-measured): for
|
||||
// image/board it's the full file + its thumbnail.
|
||||
function measureBytes(kind, url, body, thumbUrl) {
|
||||
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; }
|
||||
}
|
||||
if (kind === 'image' || kind === 'board') return fileBytes(url) + fileBytes(thumbUrl);
|
||||
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.
|
||||
// row references it any more — as either its `url` OR its `thumb_url` (share/
|
||||
// annotate can alias one physical file across rows and columns). Call AFTER the
|
||||
// delete/url-update so the freed row no longer counts. 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;
|
||||
if (db.prepare('SELECT 1 FROM student_materials WHERE url = ? OR thumb_url = ? LIMIT 1').get(url, 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 */ } }
|
||||
}
|
||||
@@ -59,7 +64,7 @@ function list(req, res) {
|
||||
const materials = db.prepare(`
|
||||
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
|
||||
url, thumb_url, source_session_id, source_title, collection_id, tags, created_at
|
||||
FROM student_materials
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
@@ -78,7 +83,7 @@ function list(req, res) {
|
||||
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
|
||||
SELECT id, user_id, kind, title, body, url, thumb_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' });
|
||||
@@ -113,6 +118,11 @@ function create(req, res) {
|
||||
url = safeUrl(b.url);
|
||||
if (url === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' });
|
||||
}
|
||||
let thumbUrl = null;
|
||||
if (b.thumbUrl != null && b.thumbUrl !== '') {
|
||||
thumbUrl = safeUrl(b.thumbUrl);
|
||||
if (thumbUrl === 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' });
|
||||
@@ -126,18 +136,18 @@ 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 bytes = measureBytes(kind, url, body, thumbUrl);
|
||||
const r = db.prepare(`
|
||||
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);
|
||||
INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(req.user.id, kind, title, body, url, thumbUrl, 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, url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
const row = db.prepare('SELECT user_id, url, thumb_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 || {};
|
||||
@@ -149,26 +159,33 @@ function update(req, res) {
|
||||
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.thumbUrl !== undefined) {
|
||||
let nt = null;
|
||||
if (b.thumbUrl != null && b.thumbUrl !== '') { nt = safeUrl(b.thumbUrl); if (nt === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' }); }
|
||||
fields.push('thumb_url = ?'); args.push(nt);
|
||||
}
|
||||
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);
|
||||
// Recompute quota bytes from the persisted row; free the old file(s) if the url
|
||||
// or thumbnail changed (annotate overwrites both) and nothing else references them.
|
||||
const cur = db.prepare('SELECT kind, url, thumb_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, cur.thumb_url), req.params.id);
|
||||
if (b.url !== undefined && row.url && row.url !== cur.url) releaseFileForUrl(row.url);
|
||||
if (b.thumbUrl !== undefined && row.thumb_url && row.thumb_url !== cur.thumb_url) releaseFileForUrl(row.thumb_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, url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
const row = db.prepare('SELECT user_id, url, thumb_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
|
||||
releaseFileForUrl(row.url); // unlink the full image if no other material aliases it
|
||||
releaseFileForUrl(row.thumb_url); // …and its thumbnail
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -214,7 +231,7 @@ function deleteCollection(req, res) {
|
||||
a student. Each recipient gets an independent COPY (survives later edits/
|
||||
deletes by the teacher). Body: { classId } | { userId }. */
|
||||
function share(req, res) {
|
||||
const mat = db.prepare('SELECT user_id, kind, title, body, url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
const mat = db.prepare('SELECT user_id, kind, title, body, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
if (!mat) return res.status(404).json({ error: 'not found' });
|
||||
if (mat.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
|
||||
|
||||
@@ -243,13 +260,13 @@ function share(req, res) {
|
||||
|
||||
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
|
||||
const srcTitle = 'Раздатка: ' + teacherName;
|
||||
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,?,?)`);
|
||||
const bytes = measureBytes(mat.kind, mat.url, mat.body, mat.thumb_url); // each copy counts toward the recipient's quota
|
||||
const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, thumb_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, bytes);
|
||||
ins.run(uid, mat.kind, mat.title, mat.body, mat.url, mat.thumb_url, srcTitle, bytes);
|
||||
try {
|
||||
emit(uid, { type: 'notification', notif_type: 'material_shared',
|
||||
message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' });
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 074: Thumbnail URL for image/board materials
|
||||
--
|
||||
-- Server-generated downscaled preview (sharp → webp, ≤480px) shown in the grid;
|
||||
-- the full image is still used for viewing / annotating / download. NULL when no
|
||||
-- thumb exists (generation failed, animated gif, or a non-uploaded url) — the
|
||||
-- client falls back to the full `url`.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE student_materials ADD COLUMN thumb_url TEXT;
|
||||
@@ -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