Files
Learn_System/backend/src/controllers/avatarController.js
T
Maxim Dolgolyov 91917f952c fix(security): харднинг загрузки файлов, контроль доступа и XSS
Подхвачено из закрытой параллельной сессии (план project_hardening_2026).

Загрузки: magic.js получает safeExt/EXT_FOR_MIME — имя файла на диске берёт
расширение из проверенного MIME, а не из client originalname (анти stored-XSS
.html/.svg). avatar/flashcard/chat-загрузки дополнительно проверяют magic-байты:
содержимое должно соответствовать MIME, иначе файл удаляется и 400.

Доступ: fileController.getFolderAccess отдаёт список раздачи только владельцу
или админу (была утечка имён/email учеников). testController.getOne гейтит
видимость как list() — ученик не прочитает тексты заданий черновиков/вариантов
по id.

XSS: classes.html escJ() экранирует строку для JS-литерала в inline-onclick
(имя ученика с кавычкой больше не ломает обработчик).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:03:06 +03:00

158 lines
6.9 KiB
JavaScript

const path = require('path');
const fs = require('fs');
const db = require('../db/db');
const { audit } = require('../utils/audit');
const { checkMagicBytes } = require('../utils/magic');
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
/* ── POST /api/avatar/request ── student submits a new avatar ───────────── */
function requestAvatar(req, res) {
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
// Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем).
const filePath = path.join(AVATARS_DIR, req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не является изображением' });
}
// Cancel any previous pending request from this user (replace it)
const prev = db.prepare(
"SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'"
).get(req.user.id);
if (prev) {
// Delete old file
try { fs.unlinkSync(path.join(AVATARS_DIR, prev.filename)); } catch {}
db.prepare("DELETE FROM avatar_requests WHERE user_id=? AND status='pending'").run(req.user.id);
}
db.prepare(`
INSERT INTO avatar_requests (user_id, filename, status, created_at)
VALUES (?, ?, 'pending', datetime('now'))
`).run(req.user.id, req.file.filename);
res.json({ ok: true, filename: req.file.filename });
}
/* ── GET /api/avatar/my-status ── student polls their request status ────── */
function myStatus(req, res) {
const row = db.prepare(`
SELECT ar.id, ar.filename, ar.status, ar.reject_msg, ar.created_at, ar.reviewed_at
FROM avatar_requests ar
WHERE ar.user_id = ?
ORDER BY ar.created_at DESC LIMIT 1
`).get(req.user.id);
const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id);
res.json({ request: row || null, current_avatar: user?.avatar_url || null });
}
/* ── GET /api/avatar/pending ── moderator sees all pending requests ──────── */
function getPending(req, res) {
const rows = db.prepare(`
SELECT ar.id, ar.filename, ar.status, ar.created_at,
u.id AS user_id, u.name AS user_name, u.email AS user_email,
u.avatar_url AS current_avatar
FROM avatar_requests ar
JOIN users u ON u.id = ar.user_id
WHERE ar.status = 'pending'
ORDER BY ar.created_at ASC
`).all();
res.json(rows);
}
/* ── POST /api/avatar/:id/approve ── moderator approves ─────────────────── */
function approveAvatar(req, res) {
const row = db.prepare('SELECT * FROM avatar_requests WHERE id=?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Заявка не найдена' });
if (row.status !== 'pending') return res.status(400).json({ error: 'Заявка уже обработана' });
// Remove old avatar file if exists
const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(row.user_id);
if (user?.avatar_url) {
try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {}
}
db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(row.filename, row.user_id);
db.prepare(`
UPDATE avatar_requests SET status='approved', reviewer_id=?, reviewed_at=datetime('now')
WHERE id=?
`).run(req.user.id, row.id);
audit(req, 'avatar.approve', `user:${row.user_id}`, row.filename);
res.json({ ok: true });
}
/* ── POST /api/avatar/:id/reject ── moderator rejects ───────────────────── */
function rejectAvatar(req, res) {
const row = db.prepare('SELECT * FROM avatar_requests WHERE id=?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Заявка не найдена' });
if (row.status !== 'pending') return res.status(400).json({ error: 'Заявка уже обработана' });
const msg = (req.body.reason || '').toString().slice(0, 200).trim();
// Delete uploaded file
try { fs.unlinkSync(path.join(AVATARS_DIR, row.filename)); } catch {}
db.prepare(`
UPDATE avatar_requests
SET status='rejected', reviewer_id=?, reject_msg=?, reviewed_at=datetime('now')
WHERE id=?
`).run(req.user.id, msg || null, row.id);
audit(req, 'avatar.reject', `user:${row.user_id}`, msg || '');
res.json({ ok: true });
}
/* ── DELETE /api/avatar/me ── student removes their approved avatar ──────── */
function removeAvatar(req, res) {
const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id);
if (user?.avatar_url) {
if (!/^preset_\d{2}\.png$/.test(user.avatar_url)) {
try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {}
}
db.prepare('UPDATE users SET avatar_url=NULL WHERE id=?').run(req.user.id);
}
res.json({ ok: true });
}
/* ── GET /api/avatar/presets ── list available preset avatars ─────────────── */
function listPresets(_req, res) {
const files = fs.readdirSync(AVATARS_DIR)
.filter(f => /^preset_\d{2}\.png$/.test(f))
.sort();
res.json({ presets: files });
}
/* ── POST /api/avatar/preset ── set avatar to a preset (no moderation) ────── */
function setPreset(req, res) {
const filename = String(req.body.filename || '');
if (!/^preset_\d{2}\.png$/.test(filename)) {
return res.status(400).json({ error: 'Некорректный пресет' });
}
if (!fs.existsSync(path.join(AVATARS_DIR, filename))) {
return res.status(404).json({ error: 'Пресет не найден' });
}
// Remove old uploaded avatar file (but never delete preset files)
const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id);
if (user?.avatar_url && !/^preset_\d{2}\.png$/.test(user.avatar_url)) {
try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {}
}
// Cancel any pending moderation request from this user
const prev = db.prepare(
"SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'"
).get(req.user.id);
if (prev) {
try { fs.unlinkSync(path.join(AVATARS_DIR, prev.filename)); } catch {}
db.prepare("DELETE FROM avatar_requests WHERE user_id=? AND status='pending'").run(req.user.id);
}
db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(filename, req.user.id);
res.json({ ok: true, avatar_url: filename });
}
module.exports = { requestAvatar, myStatus, getPending, approveAvatar, rejectAvatar, removeAvatar, listPresets, setPreset };