LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
/* ── 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, downloadFile, deleteFile, getFileAccess, assignFile, unassignFile, listFolders, createFolder, renameFolder, deleteFolder, moveFile, getFolderAccess, assignFolder, unassignFolder, clearFolderAccess };
|
||||
Reference in New Issue
Block a user