91917f952c
Подхвачено из закрытой параллельной сессии (план 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>
187 lines
10 KiB
JavaScript
187 lines
10 KiB
JavaScript
const db = require('../db/db');
|
|
|
|
/* Вопросы теста без зафиксированного правильного ответа (нет верного варианта И
|
|
* нет correct_text). matching исключаем (там ответ — пары match_pair).
|
|
* Такой вопрос нельзя оценить → не пускаем тест к ученикам. */
|
|
function unanswerableInTest(testId) {
|
|
return db.prepare(`
|
|
SELECT tq.question_id AS id FROM test_questions tq JOIN questions q ON q.id = tq.question_id
|
|
WHERE tq.test_id = ? AND q.type <> 'matching'
|
|
AND (q.correct_text IS NULL OR TRIM(q.correct_text) = '')
|
|
AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id = q.id AND o.is_correct = 1)
|
|
`).all(testId).map(r => r.id);
|
|
}
|
|
|
|
/* ── GET /api/tests ─────────────────────────────────────────────────────── */
|
|
function list(req, res) {
|
|
const { subject } = req.query;
|
|
const { role, id: uid } = req.user;
|
|
const args = [];
|
|
let where = '1=1';
|
|
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
|
|
const isStudent = role === 'student' || role === 'free_student';
|
|
// Ученик видит каталог тестов, помеченных доступными; учитель — только свои; админ — все.
|
|
if (isStudent) { where += ' AND t.available_to_students = 1'; }
|
|
else if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
|
|
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
|
|
// не показываем их во вкладке «Тесты (шаблоны)» админки.
|
|
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
|
|
|
|
let rows = db.prepare(`
|
|
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
|
|
u.name AS creator_name,
|
|
COUNT(tq.question_id) AS question_count
|
|
FROM tests t
|
|
JOIN users u ON u.id = t.created_by
|
|
LEFT JOIN test_questions tq ON tq.test_id = t.id
|
|
WHERE ${where}
|
|
GROUP BY t.id ORDER BY t.created_at DESC
|
|
`).all(...args);
|
|
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
|
|
res.json(rows);
|
|
}
|
|
|
|
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
|
|
function create(req, res) {
|
|
const { title, subject_slug, description, show_answers = 1, time_limit, available_to_students = 0 } = req.body;
|
|
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
|
|
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
|
|
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
|
|
const r = db.prepare(
|
|
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, available_to_students, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, available_to_students ? 1 : 0, req.user.id);
|
|
res.status(201).json({ id: r.lastInsertRowid });
|
|
}
|
|
|
|
/* ── GET /api/tests/:id ──────────────────────────────────────────────────── */
|
|
function getOne(req, res) {
|
|
const t = db.prepare(`
|
|
SELECT t.*, u.name AS creator_name
|
|
FROM tests t JOIN users u ON u.id = t.created_by WHERE t.id = ?
|
|
`).get(req.params.id);
|
|
if (!t) return res.status(404).json({ error: 'Not found' });
|
|
|
|
// Доступ как в list(): ученик видит только помеченные доступными и не служебные
|
|
// экзамен-варианты; учитель — только свои; админ — все. Иначе по id можно было бы
|
|
// прочитать тексты заданий из черновиков/вариантов.
|
|
const { role, id: uid } = req.user;
|
|
const isStudent = role === 'student' || role === 'free_student';
|
|
if (isStudent) {
|
|
const isVariant = db.prepare('SELECT 1 FROM exam9_variant_tests WHERE test_id = ?').get(t.id);
|
|
if (!t.available_to_students || isVariant) return res.status(404).json({ error: 'Not found' });
|
|
} else if (role !== 'admin' && t.created_by !== uid) {
|
|
return res.status(404).json({ error: 'Not found' });
|
|
}
|
|
|
|
const questions = db.prepare(`
|
|
SELECT q.id, q.text, q.type, q.difficulty, q.explanation,
|
|
tp.name AS topic, s.name AS subject_name,
|
|
(SELECT json_group_array(json_object('id',o.id,'text',o.text,'is_correct',o.is_correct,'order_index',o.order_index,'match_pair',o.match_pair) ORDER BY o.order_index)
|
|
FROM options o WHERE o.question_id = q.id) AS options_json,
|
|
tq.order_index
|
|
FROM test_questions tq
|
|
JOIN questions q ON q.id = tq.question_id
|
|
LEFT JOIN topics tp ON tp.id = q.topic_id
|
|
LEFT JOIN subjects s ON s.id = q.subject_id
|
|
WHERE tq.test_id = ? ORDER BY tq.order_index
|
|
`).all(req.params.id).map(r => ({
|
|
...r,
|
|
options: JSON.parse(r.options_json || '[]'),
|
|
options_json: undefined,
|
|
}));
|
|
|
|
// Hide is_correct from students — only teachers/admins see correct answers
|
|
const isPrivileged = req.user.role === 'teacher' || req.user.role === 'admin';
|
|
if (!isPrivileged) {
|
|
questions.forEach(q => {
|
|
q.options.forEach(o => { delete o.is_correct; });
|
|
delete q.explanation;
|
|
});
|
|
}
|
|
|
|
res.json({ ...t, questions });
|
|
}
|
|
|
|
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
|
|
function update(req, res) {
|
|
const b = req.body;
|
|
const t = req.resource; // ownership verified by requireOwnership middleware
|
|
// Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
|
|
// присылающий только available_to_students, обнулил бы title/subject и т.п.).
|
|
const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
|
|
const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
|
|
const description = b.description !== undefined ? (b.description?.trim() || null) : t.description;
|
|
const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers;
|
|
const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit;
|
|
const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students;
|
|
// Гард целостности: нельзя публиковать тест ученикам с вопросами без правильного ответа.
|
|
if (available === 1) {
|
|
const broken = unanswerableInTest(t.id);
|
|
if (broken.length) return res.status(400).json({ error: `Нельзя опубликовать: ${broken.length} вопрос(ов) без правильного ответа. Исправьте их в банке.`, brokenQuestions: broken });
|
|
}
|
|
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ?, available_to_students = ? WHERE id = ?')
|
|
.run(title, subject_slug, description, show_answers, time_limit, available, t.id);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* ── DELETE /api/tests/:id ───────────────────────────────────────────────── */
|
|
function remove(req, res) {
|
|
db.prepare('DELETE FROM tests WHERE id = ?').run(req.resource.id);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* ── POST /api/tests/:id/questions ──────────────────────────────────────── */
|
|
function addQuestions(req, res) {
|
|
const { question_ids } = req.body;
|
|
if (!Array.isArray(question_ids) || !question_ids.length)
|
|
return res.status(400).json({ error: 'question_ids[] required' });
|
|
|
|
const testId = req.resource.id; // ownership verified by requireOwnership middleware
|
|
|
|
const { mx } = db.prepare(
|
|
'SELECT COALESCE(MAX(order_index), -1) AS mx FROM test_questions WHERE test_id = ?'
|
|
).get(testId);
|
|
|
|
let idx = mx + 1;
|
|
const ins = db.prepare(
|
|
'INSERT OR IGNORE INTO test_questions (test_id, question_id, order_index) VALUES (?, ?, ?)'
|
|
);
|
|
try {
|
|
db.transaction(() => { question_ids.forEach(qid => ins.run(testId, qid, idx++)); })();
|
|
} catch (e) {
|
|
console.error('[testController] addQuestions error:', e.message);
|
|
return res.status(500).json({ error: 'Ошибка добавления вопросов' });
|
|
}
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* ── DELETE /api/tests/:id/questions/:qid ───────────────────────────────── */
|
|
function removeQuestion(req, res) {
|
|
db.prepare('DELETE FROM test_questions WHERE test_id = ? AND question_id = ?')
|
|
.run(req.resource.id, req.params.qid);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* ── PATCH /api/tests/:id/questions/reorder { ids: [qid, qid, ...] } ──── */
|
|
function reorderQuestions(req, res) {
|
|
const { ids } = req.body;
|
|
if (!Array.isArray(ids) || !ids.length)
|
|
return res.status(400).json({ error: 'ids[] required' });
|
|
|
|
const testId = req.resource.id;
|
|
const upd = db.prepare(
|
|
'UPDATE test_questions SET order_index = ? WHERE test_id = ? AND question_id = ?'
|
|
);
|
|
try {
|
|
db.transaction(() => {
|
|
ids.forEach((qid, i) => upd.run(i, testId, qid));
|
|
})();
|
|
} catch (e) {
|
|
console.error('[testController] reorderQuestions error:', e.message);
|
|
return res.status(500).json({ error: 'Ошибка сортировки' });
|
|
}
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
module.exports = { list, create, getOne, update, remove, addQuestions, removeQuestion, reorderQuestions };
|