b702b04ed2
История: GET /api/access/log (admin-only) — кто/когда открыл/закрыл/сбросил правило для контента (из admin_audit_log, имена классов/учеников резолвятся). Клиент LS.accessLog; в режиме «По контенту» — кнопка «История изменений». Пресет: в режиме «По классу» — «Скопировать доступ из класса [выбор]» (дополняет текущие правила открытыми правилами класса-источника). Тест: история (admin видит запись, учителю 403). content-access 13/13. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288 lines
13 KiB
JavaScript
288 lines
13 KiB
JavaScript
'use strict';
|
|
/* /api/access — управление доступом к учебникам и экзамен-модулям.
|
|
*
|
|
* Доступно админам (все классы/ученики) и учителям (только свои классы и
|
|
* ученики своих классов / привязанные ученики). Модель — allowlist,
|
|
* правило ученика важнее правила класса (см. services/contentAccess.js). */
|
|
const router = require('express').Router();
|
|
const db = require('../db/db');
|
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
|
const { audit } = require('../utils/audit');
|
|
|
|
router.use(authMiddleware);
|
|
router.use(requireRole('admin', 'teacher'));
|
|
|
|
const isAdmin = (req) => req.user.role === 'admin';
|
|
|
|
/* ── Каталог контента, которым можно управлять ─────────────────────────── */
|
|
/* GET /api/access/catalog → { textbooks:[...], exams:[...] } */
|
|
router.get('/catalog', (_req, res) => {
|
|
const textbooks = db.prepare(`
|
|
SELECT slug, title, subject, grade, color
|
|
FROM textbooks
|
|
WHERE is_active = 1 AND parent_slug IS NULL
|
|
ORDER BY sort_order, subject, grade
|
|
`).all();
|
|
const exams = db.prepare(`
|
|
SELECT exam_key, title, subject_slug, grade
|
|
FROM exam_tracks
|
|
WHERE enabled = 1
|
|
ORDER BY sort_order, exam_key
|
|
`).all();
|
|
let sims = [];
|
|
try {
|
|
sims = db.prepare(`
|
|
SELECT id, title, cat AS subject, grade
|
|
FROM lab_sims
|
|
WHERE enabled = 1
|
|
ORDER BY sort_order, id
|
|
`).all();
|
|
} catch (_e) { /* lab_sims может отсутствовать на старом инстансе — деградация */ }
|
|
let courses = [];
|
|
try {
|
|
courses = db.prepare(`
|
|
SELECT CAST(id AS TEXT) AS id, title, subject_slug AS subject
|
|
FROM courses
|
|
WHERE is_published = 1
|
|
ORDER BY subject_slug, order_index, id
|
|
`).all();
|
|
} catch (_e) { /* деградация */ }
|
|
res.json({ textbooks, exams, sims, courses });
|
|
});
|
|
|
|
/* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */
|
|
/* GET /api/access/targets → { classes:[{id,name,students:[...]}], looseStudents:[...] } */
|
|
router.get('/targets', (req, res) => {
|
|
const admin = isAdmin(req);
|
|
|
|
const classes = admin
|
|
? db.prepare(`SELECT c.id, c.name, u.name AS teacher_name
|
|
FROM classes c LEFT JOIN users u ON u.id = c.teacher_id
|
|
ORDER BY c.name`).all()
|
|
: db.prepare(`SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name`)
|
|
.all(req.user.id);
|
|
|
|
const membersStmt = db.prepare(`
|
|
SELECT u.id, u.name, u.email
|
|
FROM class_members cm JOIN users u ON u.id = cm.user_id
|
|
WHERE cm.class_id = ?
|
|
ORDER BY u.name
|
|
`);
|
|
const classList = classes.map(c => ({ ...c, students: membersStmt.all(c.id) }));
|
|
|
|
/* «Отдельные ученики» — без класса (admin) / привязанные не в моих классах (teacher). */
|
|
let looseStudents;
|
|
if (admin) {
|
|
looseStudents = db.prepare(`
|
|
SELECT id, name, email FROM users
|
|
WHERE role IN ('student','free_student')
|
|
AND id NOT IN (SELECT user_id FROM class_members)
|
|
ORDER BY name LIMIT 500
|
|
`).all();
|
|
} else {
|
|
looseStudents = db.prepare(`
|
|
SELECT u.id, u.name, u.email
|
|
FROM teacher_students ts JOIN users u ON u.id = ts.student_id
|
|
WHERE ts.teacher_id = ?
|
|
AND u.id NOT IN (
|
|
SELECT cm.user_id FROM class_members cm
|
|
JOIN classes c ON c.id = cm.class_id
|
|
WHERE c.teacher_id = ?)
|
|
ORDER BY u.name
|
|
`).all(req.user.id, req.user.id);
|
|
}
|
|
|
|
res.json({ classes: classList, looseStudents });
|
|
});
|
|
|
|
/* ── Сводка: сколько классов открыто по каждому контенту ───────────────── */
|
|
/* GET /api/access/summary
|
|
→ { totalClasses, textbooks:{[slug]:openCount}, exams:{[key]:openCount} } */
|
|
router.get('/summary', (req, res) => {
|
|
const admin = isAdmin(req);
|
|
const totalClasses = admin
|
|
? db.prepare('SELECT COUNT(*) n FROM classes').get().n
|
|
: db.prepare('SELECT COUNT(*) n FROM classes WHERE teacher_id = ?').get(req.user.id).n;
|
|
|
|
const rows = admin
|
|
? db.prepare(`
|
|
SELECT content_type, content_ref, COUNT(DISTINCT target_id) n
|
|
FROM content_access
|
|
WHERE scope = 'class' AND allow = 1
|
|
GROUP BY content_type, content_ref`).all()
|
|
: db.prepare(`
|
|
SELECT content_type, content_ref, COUNT(DISTINCT target_id) n
|
|
FROM content_access
|
|
WHERE scope = 'class' AND allow = 1
|
|
AND target_id IN (SELECT id FROM classes WHERE teacher_id = ?)
|
|
GROUP BY content_type, content_ref`).all(req.user.id);
|
|
|
|
const out = { textbook: {}, exam: {}, sim: {}, course: {} };
|
|
for (const r of rows) { (out[r.content_type] || (out[r.content_type] = {}))[r.content_ref] = r.n; }
|
|
res.json({ totalClasses, textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course });
|
|
});
|
|
|
|
/* ── Матрица класс × контент (обзор и правка одним экраном) ────────────── */
|
|
/* GET /api/access/matrix
|
|
→ { classes:[{id,name}], open:{ [class_id]:{ textbook:[ref], exam:[ref] } } } */
|
|
router.get('/matrix', (req, res) => {
|
|
const admin = isAdmin(req);
|
|
const classes = admin
|
|
? db.prepare('SELECT id, name FROM classes ORDER BY name').all()
|
|
: db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(req.user.id);
|
|
const open = {};
|
|
classes.forEach(c => { open[c.id] = { textbook: [], exam: [], sim: [], course: [] }; });
|
|
const ids = classes.map(c => c.id);
|
|
if (ids.length) {
|
|
const ph = ids.map(() => '?').join(',');
|
|
const rows = db.prepare(`
|
|
SELECT content_type, content_ref, target_id FROM content_access
|
|
WHERE scope = 'class' AND allow = 1 AND target_id IN (${ph})`).all(...ids);
|
|
for (const r of rows) {
|
|
const o = open[r.target_id];
|
|
if (o && o[r.content_type]) o[r.content_type].push(r.content_ref);
|
|
}
|
|
}
|
|
res.json({ classes, open });
|
|
});
|
|
|
|
/* ── История изменений правил доступа к контенту (только админ) ────────── */
|
|
/* GET /api/access/log?content_type=&content_ref=
|
|
→ [{ action:'grant'|'deny'|'inherit', actor, targetName, at }] (последние 50) */
|
|
router.get('/log', requireRole('admin'), (req, res) => {
|
|
const { content_type, content_ref } = req.query;
|
|
if (!['textbook', 'exam', 'sim', 'course'].includes(content_type) || !content_ref) {
|
|
return res.status(400).json({ error: 'content_type и content_ref обязательны' });
|
|
}
|
|
const rows = db.prepare(`
|
|
SELECT a.action, a.detail, a.created_at, u.name AS actor
|
|
FROM admin_audit_log a LEFT JOIN users u ON u.id = a.admin_id
|
|
WHERE a.action LIKE 'access.%' AND a.target = ?
|
|
ORDER BY a.id DESC LIMIT 50
|
|
`).all(content_type + ':' + content_ref);
|
|
|
|
const out = rows.map(r => {
|
|
const [scope, tid] = String(r.detail || '').split(':');
|
|
let targetName = r.detail || '';
|
|
if (scope === 'class') {
|
|
const c = db.prepare('SELECT name FROM classes WHERE id = ?').get(tid);
|
|
targetName = c ? `класс «${c.name}»` : `класс #${tid}`;
|
|
} else if (scope === 'student') {
|
|
const s = db.prepare('SELECT name, email FROM users WHERE id = ?').get(tid);
|
|
targetName = s ? `ученик ${s.name || s.email}` : `ученик #${tid}`;
|
|
}
|
|
return { action: r.action.replace('access.', ''), actor: r.actor || '—', targetName, at: r.created_at };
|
|
});
|
|
res.json(out);
|
|
});
|
|
|
|
/* ── Текущие правила для одного контента ───────────────────────────────── */
|
|
/* GET /api/access/rules?content_type=&content_ref=
|
|
→ { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */
|
|
router.get('/rules', (req, res) => {
|
|
const { content_type, content_ref } = req.query;
|
|
if (!['textbook', 'exam'].includes(content_type) || !content_ref) {
|
|
return res.status(400).json({ error: 'content_type и content_ref обязательны' });
|
|
}
|
|
const rows = db.prepare(`
|
|
SELECT scope, target_id, allow FROM content_access
|
|
WHERE content_type = ? AND content_ref = ?
|
|
`).all(content_type, content_ref);
|
|
|
|
const classRules = {}, studentRules = {};
|
|
for (const r of rows) {
|
|
if (r.scope === 'class') classRules[r.target_id] = r.allow;
|
|
else studentRules[r.target_id] = r.allow;
|
|
}
|
|
res.json({ classRules, studentRules });
|
|
});
|
|
|
|
/* ── Проверка прав учителя на конкретную цель ──────────────────────────── */
|
|
function teacherOwnsClass(teacherId, classId) {
|
|
return !!db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, teacherId);
|
|
}
|
|
|
|
/* ── Открытый классу контент (для вида «по классу») ────────────────────── */
|
|
/* GET /api/access/class/:id → { textbooks:[slug], exams:[exam_key] } (allow=1) */
|
|
router.get('/class/:id', requireRole('admin', 'teacher'), (req, res) => {
|
|
const cid = Number(req.params.id);
|
|
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id' });
|
|
if (!isAdmin(req) && !teacherOwnsClass(req.user.id, cid)) {
|
|
return res.status(403).json({ error: 'Нет прав на этот класс' });
|
|
}
|
|
const rows = db.prepare(`
|
|
SELECT content_type, content_ref FROM content_access
|
|
WHERE scope = 'class' AND target_id = ? AND allow = 1
|
|
`).all(cid);
|
|
const out = { textbook: [], exam: [], sim: [], course: [] };
|
|
for (const r of rows) (out[r.content_type] || (out[r.content_type] = [])).push(r.content_ref);
|
|
res.json({ textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course });
|
|
});
|
|
function teacherCanManageStudent(teacherId, studentId) {
|
|
const inClass = db.prepare(`
|
|
SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id
|
|
WHERE c.teacher_id = ? AND cm.user_id = ? LIMIT 1`).get(teacherId, studentId);
|
|
if (inClass) return true;
|
|
return !!db.prepare('SELECT 1 FROM teacher_students WHERE teacher_id = ? AND student_id = ?')
|
|
.get(teacherId, studentId);
|
|
}
|
|
|
|
/* ── Установка / снятие правила ────────────────────────────────────────── */
|
|
/* POST /api/access/rules
|
|
body: { content_type, content_ref, scope, target_id, allow }
|
|
allow: 1 (открыть) | 0 (закрыть-исключение) | null/'inherit' (удалить правило) */
|
|
router.post('/rules', (req, res) => {
|
|
const { content_type, content_ref, scope, target_id } = req.body || {};
|
|
let { allow } = req.body || {};
|
|
|
|
if (!['textbook', 'exam'].includes(content_type)) {
|
|
return res.status(400).json({ error: 'неверный content_type' });
|
|
}
|
|
if (!['class', 'student'].includes(scope)) {
|
|
return res.status(400).json({ error: 'неверный scope' });
|
|
}
|
|
const tid = Number(target_id);
|
|
if (!Number.isInteger(tid) || tid <= 0) {
|
|
return res.status(400).json({ error: 'неверный target_id' });
|
|
}
|
|
|
|
/* Валидация существования контента. */
|
|
if (content_type === 'textbook') {
|
|
const ok = db.prepare('SELECT 1 FROM textbooks WHERE slug = ? AND parent_slug IS NULL').get(content_ref);
|
|
if (!ok) return res.status(404).json({ error: 'учебник не найден' });
|
|
} else {
|
|
const ok = db.prepare('SELECT 1 FROM exam_tracks WHERE exam_key = ?').get(content_ref);
|
|
if (!ok) return res.status(404).json({ error: 'экзамен-трек не найден' });
|
|
}
|
|
|
|
/* Скоупинг учителя. */
|
|
if (!isAdmin(req)) {
|
|
const allowed = scope === 'class'
|
|
? teacherOwnsClass(req.user.id, tid)
|
|
: teacherCanManageStudent(req.user.id, tid);
|
|
if (!allowed) return res.status(403).json({ error: 'Нет прав на эту цель' });
|
|
}
|
|
|
|
/* allow === null / 'inherit' / undefined → удалить правило (наследование). */
|
|
if (allow === null || allow === undefined || allow === 'inherit') {
|
|
db.prepare(`DELETE FROM content_access
|
|
WHERE content_type = ? AND content_ref = ? AND scope = ? AND target_id = ?`)
|
|
.run(content_type, content_ref, scope, tid);
|
|
audit(req, 'access.inherit', `${content_type}:${content_ref}`, `${scope}:${tid}`);
|
|
return res.json({ ok: true, allow: null });
|
|
}
|
|
|
|
allow = (allow === 1 || allow === true || allow === '1') ? 1 : 0;
|
|
db.prepare(`
|
|
INSERT INTO content_access (content_type, content_ref, scope, target_id, allow, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (content_type, content_ref, scope, target_id)
|
|
DO UPDATE SET allow = excluded.allow, created_by = excluded.created_by, created_at = datetime('now')
|
|
`).run(content_type, content_ref, scope, tid, allow, req.user.id);
|
|
|
|
audit(req, allow ? 'access.grant' : 'access.deny', `${content_type}:${content_ref}`, `${scope}:${tid}`);
|
|
res.json({ ok: true, allow });
|
|
});
|
|
|
|
module.exports = router;
|