fix(materials): личная загрузка картинок без права library.upload
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) <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,36 @@ function uploadFile(req, res) {
|
|||||||
res.status(201).json({ id: r.lastInsertRowid });
|
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 ─────────────────────────────────────── */
|
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
|
||||||
function downloadFile(req, res) {
|
function downloadFile(req, res) {
|
||||||
const f = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id);
|
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 });
|
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 };
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────── */
|
/* ── routes ─────────────────────────────────────────────────────────────── */
|
||||||
router.use(authMiddleware);
|
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)
|
||||||
|
router.post('/personal', imageUpload.single('file'), fixUtf8Name, 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);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
async function uploadBlob(blob, name) {
|
async function uploadBlob(blob, name) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', blob, name);
|
fd.append('file', blob, name);
|
||||||
const up = await LS.uploadFile(fd);
|
const up = await LS.uploadMaterialFile(fd);
|
||||||
return LS.downloadFileUrl(up.id);
|
return LS.downloadFileUrl(up.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
if (o.blob) {
|
if (o.blob) {
|
||||||
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.uploadFile(fd);
|
const up = await LS.uploadMaterialFile(fd);
|
||||||
url = LS.downloadFileUrl(up.id);
|
url = LS.downloadFileUrl(up.id);
|
||||||
}
|
}
|
||||||
if (!url) throw new Error('Нет изображения');
|
if (!url) throw new Error('Нет изображения');
|
||||||
|
|||||||
@@ -180,7 +180,7 @@
|
|||||||
try {
|
try {
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('file', blob, 'textbook-region.png');
|
fd.append('file', blob, 'textbook-region.png');
|
||||||
var up = await LS.uploadFile(fd);
|
var up = await LS.uploadMaterialFile(fd);
|
||||||
await LS.saveMaterial({
|
await LS.saveMaterial({
|
||||||
kind: 'image',
|
kind: 'image',
|
||||||
title: input.value.trim() || sectionTitle(),
|
title: input.value.trim() || sectionTitle(),
|
||||||
|
|||||||
@@ -379,7 +379,7 @@
|
|||||||
try {
|
try {
|
||||||
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.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 });
|
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: LS.downloadFileUrl(up.id), 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; }
|
||||||
|
|||||||
@@ -1029,7 +1029,7 @@ window.LS = {
|
|||||||
myAssignments, teacherAssignments, startAssignment, assignmentResults, assignmentSessionReview, assignmentQuestionStats,
|
myAssignments, teacherAssignments, startAssignment, assignmentResults, assignmentSessionReview, assignmentQuestionStats,
|
||||||
getTests, createTest, getTest, updateTest, deleteTest, addQuestionsToTest, removeQFromTest,
|
getTests, createTest, getTest, updateTest, deleteTest, addQuestionsToTest, removeQFromTest,
|
||||||
getGamificationMe, getGamAchievements, getLeaderboard, getXPHistory, getChallenges, claimChallenge, setGoalTier, getFrames, setFrame, reportLabActivity,
|
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,
|
getFolders, createFolder, renameFolder, deleteFolder, moveFile,
|
||||||
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
|
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
|
||||||
submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl,
|
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 });
|
if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status });
|
||||||
return data;
|
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`; }
|
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
|
||||||
async function listMaterials() { return req('GET', '/materials'); }
|
async function listMaterials() { return req('GET', '/materials'); }
|
||||||
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
||||||
|
|||||||
Reference in New Issue
Block a user