53e996e2e0
/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>
365 lines
17 KiB
JavaScript
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 };
|