Files
Learn_System/backend/src/controllers/fileController.js
T
Maxim Dolgolyov 53e996e2e0 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>
2026-06-04 14:30:47 +03:00

365 lines
17 KiB
JavaScript

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 <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' });
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 };