fix(materials): картинки материалов отдаются публично (рендер/открытие/скачивание)

/api/files/:id/download требует Bearer-заголовок, поэтому <img>, переход по
ссылке и «Скачать» для сохранённых картинок ломались (битое изображение,
клик не открывал). Теперь личные картинки складываются в uploads/materials и
отдаются статикой (как flashcards): POST /api/files/personal возвращает
{ url:'/uploads/materials/<file>' }. board-clip, material-save, textbook-clip
и рисовалка в my-materials сохраняют этот публичный url.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 14:30:47 +03:00
parent 55c8c5fa51
commit 53e996e2e0
7 changed files with 25 additions and 24 deletions
+5 -17
View File
@@ -170,31 +170,19 @@ function uploadFile(req, res) {
/* ── POST /api/files/personal ──────────────────────────────────────────── /* ── POST /api/files/personal ────────────────────────────────────────────
* Personal image upload for «Мои материалы» (board crops, drawings, textbook * Personal image upload for «Мои материалы» (board crops, drawings, textbook
* clips). Allowed for ANY authenticated user (incl. students) — unlike the * clips). Allowed for ANY authenticated user (incl. students) — unlike the
* teacher library upload above. Image-only; stored is_public=1 so the * teacher library upload above. Image-only; saved into uploads/materials and
* material's download URL resolves (and survives teacher «раздача»). */ * 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) { function uploadPersonalFile(req, res) {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const filePath = path.resolve(UPLOADS_DIR, req.file.filename); const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) { if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {} try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' }); return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
} }
const title = (req.body && req.body.title && req.body.title.trim()) || req.file.originalname || 'Изображение'; res.status(201).json({ url: '/uploads/materials/' + req.file.filename });
let r;
try {
r = db.prepare(`
INSERT INTO files (title, original_name, stored_name, mimetype, size, is_public, uploaded_by)
VALUES (?, ?, ?, ?, ?, 1, ?)
`).run(title, req.file.originalname, req.file.filename, req.file.mimetype, req.file.size, req.user.id);
} catch (err) {
const fp = path.resolve(UPLOADS_DIR, req.file.filename);
if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} }
throw err;
}
res.status(201).json({ id: r.lastInsertRowid });
} }
/* ── GET /api/files/:id/download ─────────────────────────────────────── */ /* ── GET /api/files/:id/download ─────────────────────────────────────── */
+15 -3
View File
@@ -47,11 +47,23 @@ const upload = multer({
}, },
}); });
/* Personal image upload (Мои материалы): image-only, no library permission. */ /* Personal image upload (Мои материалы): image-only, no library permission.
* Stored in uploads/materials and served statically (public) so the saved
* <img>/open/download URL works without an auth header — these are personal
* study clips (board crops, drawings, textbook regions), not gated files. */
const MATERIALS_DIR = path.join(UPLOADS_DIR, 'materials');
try { require('fs').mkdirSync(MATERIALS_DIR, { recursive: true }); } catch (e) { /* exists */ }
const IMG_MIME = ['image/png','image/jpeg','image/gif','image/webp']; const IMG_MIME = ['image/png','image/jpeg','image/gif','image/webp'];
const IMG_EXT = new Set(['.png','.jpg','.jpeg','.gif','.webp']); const IMG_EXT = new Set(['.png','.jpg','.jpeg','.gif','.webp']);
const materialStorage = multer.diskStorage({
destination: MATERIALS_DIR,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname || '') || '.png';
cb(null, require('crypto').randomBytes(16).toString('hex') + ext);
},
});
const imageUpload = multer({ const imageUpload = multer({
storage, storage: materialStorage,
limits: { fileSize: 20 * 1024 * 1024 }, // 20 MB limits: { fileSize: 20 * 1024 * 1024 }, // 20 MB
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname || '').toLowerCase(); const ext = path.extname(file.originalname || '').toLowerCase();
@@ -65,7 +77,7 @@ router.use(authMiddleware);
router.get('/', ctrl.listFiles); router.get('/', ctrl.listFiles);
router.post('/', requireRole('teacher','admin'), requirePermission('library.upload'), upload.single('file'), fixUtf8Name, ctrl.uploadFile); router.post('/', requireRole('teacher','admin'), requirePermission('library.upload'), upload.single('file'), fixUtf8Name, ctrl.uploadFile);
// Personal materials upload — any authenticated user (covered by router-level authMiddleware) // Personal materials upload — any authenticated user (covered by router-level authMiddleware)
router.post('/personal', imageUpload.single('file'), fixUtf8Name, ctrl.uploadPersonalFile); router.post('/personal', imageUpload.single('file'), ctrl.uploadPersonalFile);
/* ── folder routes (must be before /:id to avoid conflicts) ── */ /* ── folder routes (must be before /:id to avoid conflicts) ── */
router.get('/folders', ctrl.listFolders); router.get('/folders', ctrl.listFolders);
+1
View File
@@ -335,6 +335,7 @@ app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache));
app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache)); app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache));
app.use('/avatars', express.static(path.join(__dirname, '../uploads/avatars'), { maxAge: '1d' })); app.use('/avatars', express.static(path.join(__dirname, '../uploads/avatars'), { maxAge: '1d' }));
app.use('/uploads/flashcards', express.static(path.join(__dirname, '../uploads/flashcards'), { maxAge: '7d' })); app.use('/uploads/flashcards', express.static(path.join(__dirname, '../uploads/flashcards'), { maxAge: '7d' }));
app.use('/uploads/materials', express.static(path.join(__dirname, '../uploads/materials'), { maxAge: '7d' }));
// Redirect legacy .html URLs → clean URLs (301) // Redirect legacy .html URLs → clean URLs (301)
app.use((req, res, next) => { app.use((req, res, next) => {
+1 -1
View File
@@ -21,7 +21,7 @@
const fd = new FormData(); const fd = new FormData();
fd.append('file', blob, name); fd.append('file', blob, name);
const up = await LS.uploadMaterialFile(fd); const up = await LS.uploadMaterialFile(fd);
return LS.downloadFileUrl(up.id); return up.url;
} }
async function persist(meta, kind, url) { async function persist(meta, kind, url) {
+1 -1
View File
@@ -43,7 +43,7 @@
const fd = new FormData(); const fd = new FormData();
fd.append('file', o.blob, o.name || 'image.png'); fd.append('file', o.blob, o.name || 'image.png');
const up = await LS.uploadMaterialFile(fd); const up = await LS.uploadMaterialFile(fd);
url = LS.downloadFileUrl(up.id); url = up.url;
} }
if (!url) throw new Error('Нет изображения'); if (!url) throw new Error('Нет изображения');
await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, sourceTitle: o.sourceTitle || null }); await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, sourceTitle: o.sourceTitle || null });
+1 -1
View File
@@ -184,7 +184,7 @@
await LS.saveMaterial({ await LS.saveMaterial({
kind: 'image', kind: 'image',
title: input.value.trim() || sectionTitle(), title: input.value.trim() || sectionTitle(),
url: LS.downloadFileUrl(up.id), url: up.url,
sourceTitle: chapterTitle() sourceTitle: chapterTitle()
}); });
toast('Сохранено в «Мои материалы»', 'success'); toast('Сохранено в «Мои материалы»', 'success');
+1 -1
View File
@@ -380,7 +380,7 @@
if (!blob) throw new Error('Не удалось сохранить рисунок'); if (!blob) throw new Error('Не удалось сохранить рисунок');
const fd = new FormData(); fd.append('file', blob, 'drawing.png'); const fd = new FormData(); fd.append('file', blob, 'drawing.png');
const up = await LS.uploadMaterialFile(fd); const up = await LS.uploadMaterialFile(fd);
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: LS.downloadFileUrl(up.id), sourceTitle: o.sourceTitle || null }); await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, sourceTitle: o.sourceTitle || null });
close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success'); close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
}); });