From 55c8c5fa518e703e5fab731a296e06bbab7f9221 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 14:21:18 +0300 Subject: [PATCH] =?UTF-8?q?fix(materials):=20=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BE=D0=BA=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20library.upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/files требует teacher/admin + library.upload — поэтому сохранение картинок в «Мои материалы» (вырезка области учебника, обрезка доски, рисунок, аннотация) падало с 403 у учеников и учителей без этого права. Добавлен auth-only эндпоинт POST /api/files/personal (только картинки, is_public=1) + LS.uploadMaterialFile. На него переключены board-clip, material-save, textbook-clip (вырезка области) и рисовалка в my-materials. Загрузка в учительскую библиотеку (library/lesson-editor) не тронута. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/fileController.js | 32 ++++++++++++++++++++++- backend/src/routes/files.js | 14 ++++++++++ frontend/js/board-clip.js | 2 +- frontend/js/material-save.js | 2 +- frontend/js/textbook-clip.js | 2 +- frontend/my-materials.html | 2 +- js/api.js | 15 ++++++++++- 7 files changed, 63 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/fileController.js b/backend/src/controllers/fileController.js index cb42c66..4b9c202 100644 --- a/backend/src/controllers/fileController.js +++ b/backend/src/controllers/fileController.js @@ -167,6 +167,36 @@ function uploadFile(req, res) { res.status(201).json({ id: r.lastInsertRowid }); } +/* ── POST /api/files/personal ──────────────────────────────────────────── + * Personal image upload for «Мои материалы» (board crops, drawings, textbook + * clips). Allowed for ANY authenticated user (incl. students) — unlike the + * teacher library upload above. Image-only; stored is_public=1 so the + * material's download URL resolves (and survives teacher «раздача»). */ +function uploadPersonalFile(req, res) { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + + const filePath = path.resolve(UPLOADS_DIR, req.file.filename); + if (!checkMagicBytes(filePath, req.file.mimetype)) { + try { fs.unlinkSync(filePath); } catch {} + return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' }); + } + + const title = (req.body && req.body.title && req.body.title.trim()) || req.file.originalname || 'Изображение'; + 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 ─────────────────────────────────────── */ function downloadFile(req, res) { const f = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id); @@ -343,4 +373,4 @@ function clearFolderAccess(req, res) { res.json({ ok: true }); } -module.exports = { listFiles, uploadFile, downloadFile, deleteFile, getFileAccess, assignFile, unassignFile, listFolders, createFolder, renameFolder, deleteFolder, moveFile, getFolderAccess, assignFolder, unassignFolder, clearFolderAccess }; +module.exports = { listFiles, uploadFile, uploadPersonalFile, downloadFile, deleteFile, getFileAccess, assignFile, unassignFile, listFolders, createFolder, renameFolder, deleteFolder, moveFile, getFolderAccess, assignFolder, unassignFolder, clearFolderAccess }; diff --git a/backend/src/routes/files.js b/backend/src/routes/files.js index cb9dffe..cf41c18 100644 --- a/backend/src/routes/files.js +++ b/backend/src/routes/files.js @@ -47,11 +47,25 @@ const upload = multer({ }, }); +/* Personal image upload (Мои материалы): image-only, no library permission. */ +const IMG_MIME = ['image/png','image/jpeg','image/gif','image/webp']; +const IMG_EXT = new Set(['.png','.jpg','.jpeg','.gif','.webp']); +const imageUpload = multer({ + storage, + limits: { fileSize: 20 * 1024 * 1024 }, // 20 MB + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname || '').toLowerCase(); + cb(null, IMG_MIME.includes(file.mimetype) && (IMG_EXT.has(ext) || ext === '')); + }, +}); + /* ── routes ─────────────────────────────────────────────────────────────── */ router.use(authMiddleware); router.get('/', ctrl.listFiles); 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) +router.post('/personal', imageUpload.single('file'), fixUtf8Name, ctrl.uploadPersonalFile); /* ── folder routes (must be before /:id to avoid conflicts) ── */ router.get('/folders', ctrl.listFolders); diff --git a/frontend/js/board-clip.js b/frontend/js/board-clip.js index a7d5dda..cf1e108 100644 --- a/frontend/js/board-clip.js +++ b/frontend/js/board-clip.js @@ -20,7 +20,7 @@ async function uploadBlob(blob, name) { const fd = new FormData(); fd.append('file', blob, name); - const up = await LS.uploadFile(fd); + const up = await LS.uploadMaterialFile(fd); return LS.downloadFileUrl(up.id); } diff --git a/frontend/js/material-save.js b/frontend/js/material-save.js index 1634b8c..3725b9a 100644 --- a/frontend/js/material-save.js +++ b/frontend/js/material-save.js @@ -42,7 +42,7 @@ if (o.blob) { const fd = new FormData(); fd.append('file', o.blob, o.name || 'image.png'); - const up = await LS.uploadFile(fd); + const up = await LS.uploadMaterialFile(fd); url = LS.downloadFileUrl(up.id); } if (!url) throw new Error('Нет изображения'); diff --git a/frontend/js/textbook-clip.js b/frontend/js/textbook-clip.js index e9e7616..8c6ef8e 100644 --- a/frontend/js/textbook-clip.js +++ b/frontend/js/textbook-clip.js @@ -180,7 +180,7 @@ try { var fd = new FormData(); fd.append('file', blob, 'textbook-region.png'); - var up = await LS.uploadFile(fd); + var up = await LS.uploadMaterialFile(fd); await LS.saveMaterial({ kind: 'image', title: input.value.trim() || sectionTitle(), diff --git a/frontend/my-materials.html b/frontend/my-materials.html index 9a54b66..b0cd0b3 100644 --- a/frontend/my-materials.html +++ b/frontend/my-materials.html @@ -379,7 +379,7 @@ try { if (!blob) throw new Error('Не удалось сохранить рисунок'); const fd = new FormData(); fd.append('file', blob, 'drawing.png'); - const up = await LS.uploadFile(fd); + const up = await LS.uploadMaterialFile(fd); await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: LS.downloadFileUrl(up.id), sourceTitle: o.sourceTitle || null }); close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success'); } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; } diff --git a/js/api.js b/js/api.js index d567ab6..a9ea8ca 100644 --- a/js/api.js +++ b/js/api.js @@ -1029,7 +1029,7 @@ window.LS = { myAssignments, teacherAssignments, startAssignment, assignmentResults, assignmentSessionReview, assignmentQuestionStats, getTests, createTest, getTest, updateTest, deleteTest, addQuestionsToTest, removeQFromTest, getGamificationMe, getGamAchievements, getLeaderboard, getXPHistory, getChallenges, claimChallenge, setGoalTier, getFrames, setFrame, reportLabActivity, - getFiles, uploadFile, downloadFileUrl, deleteFile, getFileAccess, assignFile, unassignFile, + getFiles, uploadFile, uploadMaterialFile, downloadFileUrl, deleteFile, getFileAccess, assignFile, unassignFile, getFolders, createFolder, renameFolder, deleteFolder, moveFile, getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList, submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl, @@ -1244,6 +1244,19 @@ async function uploadFile(formData) { if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); return data; } +/* Personal image upload for «Мои материалы» — any logged-in user (students too). + * Separate from uploadFile() which is the teacher library (role + permission). */ +async function uploadMaterialFile(formData) { + const token = getToken(); + const res = await fetch(API + '/files/personal', { + method: 'POST', + headers: token ? { 'Authorization': `Bearer ${token}` } : {}, + body: formData, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); + return data; +} function downloadFileUrl(id) { return `${API}/files/${id}/download`; } async function listMaterials() { return req('GET', '/materials'); } async function saveMaterial(data) { return req('POST', '/materials', data); }