const db = require('../db/db'); const path = require('path'); const fs = require('fs'); const { UPLOADS_DIR } = require('../config'); const { checkMagicBytes } = require('../utils/magic'); /* ── GET /api/files?subject=bio&my=1 ─────────────────────────────────── */ function listFiles(req, res) { const { subject, my } = req.query; const uid = req.user.id; const isTeacher = ['teacher', 'admin'].includes(req.user.role); let sql, args; const cols = `f.id, f.title, f.description, f.original_name, f.mimetype, f.size, f.subject_slug, f.is_public, f.folder_id, f.created_at, u.name AS uploader_name`; if (isTeacher && my === '1') { sql = `SELECT ${cols} FROM files f JOIN users u ON u.id = f.uploaded_by WHERE f.uploaded_by = ?`; args = [uid]; } else if (isTeacher) { sql = `SELECT ${cols} FROM files f JOIN users u ON u.id = f.uploaded_by WHERE (f.uploaded_by = ? OR f.is_public = 1)`; args = [uid]; } else { sql = ` SELECT DISTINCT ${cols} FROM files f JOIN users u ON u.id = f.uploaded_by WHERE ( f.is_public = 1 OR EXISTS (SELECT 1 FROM file_access fa WHERE fa.file_id = f.id AND fa.type = 'user' AND fa.target_id = ?) OR EXISTS (SELECT 1 FROM file_access fa JOIN class_members cm ON cm.class_id = fa.target_id AND cm.user_id = ? WHERE fa.file_id = f.id AND fa.type = 'class') ) `; args = [uid, uid]; } if (subject) { sql += ' AND f.subject_slug = ?'; args.push(subject); } sql += ' ORDER BY f.created_at DESC LIMIT 200'; res.json(db.prepare(sql).all(...args)); } /* ── GET /api/files/folders ──────────────────────────────────────────── */ function listFolders(req, res) { const uid = req.user.id; const isTeacher = ['teacher', 'admin'].includes(req.user.role); if (isTeacher) { const rows = db.prepare(` SELECT fo.id, fo.name, fo.created_by, fo.created_at, (SELECT COUNT(*) FROM files f WHERE f.folder_id = fo.id) AS file_count, (SELECT COUNT(*) FROM folder_access fa WHERE fa.folder_id = fo.id) AS access_count, u.name AS creator_name FROM folders fo JOIN users u ON u.id = fo.created_by ORDER BY fo.name ASC `).all(); return res.json(rows); } // Students: only folders with no restrictions, or where they have explicit access const rows = db.prepare(` SELECT fo.id, fo.name, fo.created_by, fo.created_at, (SELECT COUNT(*) FROM files f WHERE f.folder_id = fo.id) AS file_count, 0 AS access_count, u.name AS creator_name FROM folders fo JOIN users u ON u.id = fo.created_by WHERE ( NOT EXISTS (SELECT 1 FROM folder_access fa WHERE fa.folder_id = fo.id) OR EXISTS (SELECT 1 FROM folder_access fa WHERE fa.folder_id = fo.id AND fa.type = 'user' AND fa.target_id = ?) OR EXISTS ( SELECT 1 FROM folder_access fa JOIN class_members cm ON cm.class_id = fa.target_id AND cm.user_id = ? WHERE fa.folder_id = fo.id AND fa.type = 'class' ) ) ORDER BY fo.name ASC `).all(uid, uid); res.json(rows); } /* ── POST /api/files/folders ─────────────────────────────────────────── */ function createFolder(req, res) { const { name } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'name required' }); const r = db.prepare('INSERT INTO folders (name, created_by) VALUES (?, ?)').run(name.trim(), req.user.id); res.status(201).json({ id: r.lastInsertRowid, name: name.trim() }); } /* ── PUT /api/files/folders/:id ──────────────────────────────────────── */ function renameFolder(req, res) { const fo = db.prepare('SELECT * FROM folders WHERE id = ?').get(req.params.id); if (!fo) return res.status(404).json({ error: 'Folder not found' }); if (req.user.role !== 'admin' && fo.created_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); const { name } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'name required' }); db.prepare('UPDATE folders SET name = ? WHERE id = ?').run(name.trim(), fo.id); res.json({ ok: true }); } /* ── DELETE /api/files/folders/:id ──────────────────────────────────── */ function deleteFolder(req, res) { const fo = db.prepare('SELECT * FROM folders WHERE id = ?').get(req.params.id); if (!fo) return res.status(404).json({ error: 'Folder not found' }); if (req.user.role !== 'admin' && fo.created_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); // Move files back to root before deleting db.prepare('UPDATE files SET folder_id = NULL WHERE folder_id = ?').run(fo.id); db.prepare('DELETE FROM folders WHERE id = ?').run(fo.id); res.json({ ok: true }); } /* ── PATCH /api/files/:id/move ───────────────────────────────────────── */ function moveFile(req, res) { const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id); if (!f) return res.status(404).json({ error: 'File not found' }); if (req.user.role !== 'admin' && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); const folderId = req.body.folder_id ? Number(req.body.folder_id) : null; if (folderId !== null) { const fo = db.prepare('SELECT id FROM folders WHERE id = ?').get(folderId); if (!fo) return res.status(404).json({ error: 'Folder not found' }); } db.prepare('UPDATE files SET folder_id = ? WHERE id = ?').run(folderId, f.id); res.json({ ok: true }); } /* ── POST /api/files ─────────────────────────────────────────────────── */ function uploadFile(req, res) { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const { title, description, subject_slug, is_public, folder_id } = req.body; if (!title?.trim()) return res.status(400).json({ error: 'title required' }); // Magic bytes verification — reject files whose content doesn't match declared MIME 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: 'Содержимое файла не соответствует его расширению' }); } let r; try { r = db.prepare(` INSERT INTO files (title, description, original_name, stored_name, mimetype, size, subject_slug, is_public, folder_id, uploaded_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( title.trim(), description?.trim() || null, req.file.originalname, req.file.filename, req.file.mimetype, req.file.size, subject_slug || null, is_public === '0' ? 0 : 1, folder_id ? Number(folder_id) : null, req.user.id ); } catch (err) { // Clean up orphan file if DB insert failed 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 }); } /* ── 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; saved into uploads/materials and * served statically (public), so the returned URL renders in , 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' }); 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: 'Содержимое файла не соответствует его расширению' }); } res.status(201).json({ url: '/uploads/materials/' + req.file.filename }); } /* ── GET /api/files/:id/download ─────────────────────────────────────── */ function downloadFile(req, res) { const f = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id); if (!f) return res.status(404).json({ error: 'File not found' }); const uid = req.user.id; const isTeacher = ['teacher', 'admin'].includes(req.user.role); if (!f.is_public && !isTeacher && f.uploaded_by !== uid) { const hasAccess = db.prepare(` SELECT 1 FROM file_access fa WHERE fa.file_id = ? AND ( (fa.type = 'user' AND fa.target_id = ?) OR (fa.type = 'class' AND EXISTS ( SELECT 1 FROM class_members cm WHERE cm.class_id = fa.target_id AND cm.user_id = ? )) ) LIMIT 1 `).get(f.id, uid, uid); if (!hasAccess) return res.status(403).json({ error: 'Access denied' }); } const filePath = path.resolve(UPLOADS_DIR, f.stored_name); if (!filePath.startsWith(UPLOADS_DIR + path.sep) && filePath !== UPLOADS_DIR) return res.status(400).json({ error: 'Invalid file path' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing from storage' }); const inlineSafe = /^(image\/(png|jpeg|gif|webp)|application\/pdf)$/.test(f.mimetype); const disposition = inlineSafe ? 'inline' : 'attachment'; const encoded = encodeURIComponent(f.original_name); res.setHeader('Content-Disposition', `${disposition}; filename="${encoded}"; filename*=UTF-8''${encoded}`); res.setHeader('Content-Type', f.mimetype || 'application/octet-stream'); res.sendFile(filePath); } /* ── DELETE /api/files/:id ───────────────────────────────────────────── */ function deleteFile(req, res) { const f = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id); if (!f) return res.status(404).json({ error: 'File not found' }); const isAdmin = req.user.role === 'admin'; if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); const filePath = path.resolve(UPLOADS_DIR, f.stored_name); if (filePath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(filePath); } catch {} } db.prepare('DELETE FROM files WHERE id = ?').run(req.params.id); res.json({ ok: true }); } /* ── GET /api/files/:id/access ───────────────────────────────────────── */ function getFileAccess(req, res) { const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id); if (!f) return res.status(404).json({ error: 'File not found' }); const isAdmin = req.user.role === 'admin'; if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); const rows = db.prepare(` SELECT fa.id, fa.type, fa.target_id, CASE fa.type WHEN 'class' THEN (SELECT name FROM classes WHERE id = fa.target_id) WHEN 'user' THEN (SELECT name || ' (' || email || ')' FROM users WHERE id = fa.target_id) END AS target_name FROM file_access fa WHERE fa.file_id = ? ORDER BY fa.type, fa.id `).all(req.params.id); res.json(rows); } /* ── POST /api/files/:id/assign ──────────────────────────────────────── */ function assignFile(req, res) { const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id); if (!f) return res.status(404).json({ error: 'File not found' }); const isAdmin = req.user.role === 'admin'; if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); const { type, target_id, email } = req.body; if (!['class', 'user'].includes(type)) return res.status(400).json({ error: 'type must be class or user' }); let tid = Number(target_id); if (type === 'user' && email) { const u = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim().toLowerCase()); if (!u) return res.status(404).json({ error: 'Пользователь не найден' }); tid = u.id; } if (!tid) return res.status(400).json({ error: 'target_id required' }); try { db.prepare('INSERT INTO file_access (file_id, type, target_id) VALUES (?, ?, ?)').run(f.id, type, tid); } catch (e) { if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Уже назначен' }); throw e; } res.json({ ok: true }); } /* ── DELETE /api/files/:id/assign/:type/:targetId ────────────────────── */ function unassignFile(req, res) { const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id); if (!f) return res.status(404).json({ error: 'File not found' }); const isAdmin = req.user.role === 'admin'; if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); db.prepare('DELETE FROM file_access WHERE file_id = ? AND type = ? AND target_id = ?') .run(f.id, req.params.type, Number(req.params.targetId)); res.json({ ok: true }); } /* ── GET /api/files/folders/:id/access ───────────────────────────────── */ function getFolderAccess(req, res) { const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id); if (!fo) return res.status(404).json({ error: 'Folder not found' }); const rows = db.prepare(` SELECT fa.id, fa.type, fa.target_id, CASE fa.type WHEN 'class' THEN (SELECT name FROM classes WHERE id = fa.target_id) WHEN 'user' THEN (SELECT name || ' (' || email || ')' FROM users WHERE id = fa.target_id) END AS target_name FROM folder_access fa WHERE fa.folder_id = ? ORDER BY fa.type, fa.id `).all(fo.id); res.json(rows); } /* ── POST /api/files/folders/:id/assign ──────────────────────────────── */ function assignFolder(req, res) { const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id); if (!fo) return res.status(404).json({ error: 'Folder not found' }); if (req.user.role !== 'admin' && fo.created_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); const { type, target_id, email } = req.body; if (!['class', 'user'].includes(type)) return res.status(400).json({ error: 'type must be class or user' }); let tid = Number(target_id); if (type === 'user' && email) { const u = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim().toLowerCase()); if (!u) return res.status(404).json({ error: 'Пользователь не найден' }); tid = u.id; } if (!tid) return res.status(400).json({ error: 'target_id required' }); try { db.prepare('INSERT INTO folder_access (folder_id, type, target_id) VALUES (?, ?, ?)').run(fo.id, type, tid); } catch (e) { if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Уже назначен' }); throw e; } res.json({ ok: true }); } /* ── DELETE /api/files/folders/:id/assign/:type/:targetId ────────────── */ function unassignFolder(req, res) { const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id); if (!fo) return res.status(404).json({ error: 'Folder not found' }); if (req.user.role !== 'admin' && fo.created_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); db.prepare('DELETE FROM folder_access WHERE folder_id = ? AND type = ? AND target_id = ?') .run(fo.id, req.params.type, Number(req.params.targetId)); res.json({ ok: true }); } /* ── DELETE /api/files/folders/:id/access (clear all) ───────────────── */ function clearFolderAccess(req, res) { const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id); if (!fo) return res.status(404).json({ error: 'Folder not found' }); if (req.user.role !== 'admin' && fo.created_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); db.prepare('DELETE FROM folder_access WHERE folder_id = ?').run(fo.id); res.json({ ok: true }); } module.exports = { listFiles, uploadFile, uploadPersonalFile, downloadFile, deleteFile, getFileAccess, assignFile, unassignFile, listFolders, createFolder, renameFolder, deleteFolder, moveFile, getFolderAccess, assignFolder, unassignFolder, clearFolderAccess };