feat(access): доступ к учебникам и экзаменам по классам/ученикам из админ-панели
Модель allowlist (закрыто по умолчанию), правило ученика важнее класса. Управляют админ (все) и учителя (свои классы/ученики). - миграция 040: таблица content_access + непрерывный переход (всем существующим классам открыт текущий контент) - сервис contentAccess: резолвинг доступа, главы наследуют хаб - API /api/access (catalog/targets/rules) для admin+teacher - гейты: каталог учебников, router.param slug/examKey, фильтр tracks - клиентские редиректы на /403 (textbook-tracker, exam-prep boot) - раздел админки «Доступ к учебникам»: классы + ученики (tri-state) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
-- 040_content_access.sql
|
||||
-- Per-class / per-student access control for textbooks and exam modules.
|
||||
--
|
||||
-- Model (chosen 2026-05-30): ALLOWLIST — content is hidden by default and must
|
||||
-- be explicitly opened for a class or a student. A student-level rule always
|
||||
-- overrides the class-level rule (точечные исключения). allow = 1 → открыт,
|
||||
-- allow = 0 → закрыт (используется только как индивидуальное исключение).
|
||||
--
|
||||
-- content_ref:
|
||||
-- • content_type='textbook' → top-level textbook slug (parent_slug IS NULL).
|
||||
-- Главы (parent_slug != NULL) наследуют доступ родителя.
|
||||
-- • content_type='exam' → exam_tracks.exam_key (например 'math9').
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('textbook','exam')),
|
||||
content_ref TEXT NOT NULL,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('class','student')),
|
||||
target_id INTEGER NOT NULL, -- class_id (scope=class) или user_id (scope=student)
|
||||
allow INTEGER NOT NULL DEFAULT 1 CHECK (allow IN (0,1)),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (content_type, content_ref, scope, target_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_content_access_lookup ON content_access (content_type, content_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_access_target ON content_access (content_type, scope, target_id);
|
||||
|
||||
-- ── Непрерывный переход ───────────────────────────────────────────────────
|
||||
-- До этой миграции всё было открыто всем. Чтобы переход на allowlist не отнял
|
||||
-- доступ задним числом, выдаём каждому существующему классу доступ ко всем
|
||||
-- активным учебникам верхнего уровня и ко всем включённым экзамен-трекам.
|
||||
-- Новый контент, добавленный позже, по умолчанию закрыт — его нужно открыть
|
||||
-- явно из админ-панели.
|
||||
|
||||
INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow)
|
||||
SELECT 'textbook', t.slug, 'class', c.id, 1
|
||||
FROM textbooks t CROSS JOIN classes c
|
||||
WHERE t.is_active = 1 AND t.parent_slug IS NULL;
|
||||
|
||||
INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow)
|
||||
SELECT 'exam', e.exam_key, 'class', c.id, 1
|
||||
FROM exam_tracks e CROSS JOIN classes c
|
||||
WHERE e.enabled = 1;
|
||||
@@ -0,0 +1,168 @@
|
||||
'use strict';
|
||||
/* /api/access — управление доступом к учебникам и экзамен-модулям.
|
||||
*
|
||||
* Доступно админам (все классы/ученики) и учителям (только свои классы и
|
||||
* ученики своих классов / привязанные ученики). Модель — allowlist,
|
||||
* правило ученика важнее правила класса (см. services/contentAccess.js). */
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
|
||||
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();
|
||||
res.json({ textbooks, exams });
|
||||
});
|
||||
|
||||
/* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */
|
||||
/* 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/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);
|
||||
}
|
||||
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);
|
||||
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);
|
||||
|
||||
res.json({ ok: true, allow });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,9 +2,19 @@
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const access = require('../services/contentAccess');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
|
||||
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
||||
router.param('examKey', (req, res, next, examKey) => {
|
||||
if (!access.canAccessExam(req.user, examKey)) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этому экзамен-модулю' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/* ── Statements (prepared once) ────────────────────────────────── */
|
||||
const SQL = {
|
||||
listTracks: db.prepare(`
|
||||
@@ -399,8 +409,9 @@ const SQL = {
|
||||
|
||||
/* ── GET /api/exam-prep/tracks ──
|
||||
Public list of enabled exam tracks (for a future landing page). */
|
||||
router.get('/tracks', (_req, res) => {
|
||||
const tracks = SQL.listTracks.all();
|
||||
router.get('/tracks', (req, res) => {
|
||||
const tracks = SQL.listTracks.all()
|
||||
.filter(t => access.canAccessExam(req.user, t.exam_key));
|
||||
res.json({ tracks });
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,19 @@ const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { emit } = require('../sse');
|
||||
const access = require('../services/contentAccess');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/* Гейт доступа: любой маршрут с :slug проверяется по allowlist.
|
||||
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
||||
router.param('slug', (req, res, next, slug) => {
|
||||
if (!access.canAccessTextbook(req.user, slug)) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этому учебнику' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,...]; empty → [1..fallback] */
|
||||
function parseTextbookParas(spec, fallback) {
|
||||
if (!spec || !spec.trim()) return Array.from({ length: fallback || 0 }, (_, i) => i + 1);
|
||||
@@ -118,7 +128,8 @@ router.get('/', (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ textbooks });
|
||||
/* Allowlist: ученику показываем только открытые учебники. */
|
||||
res.json({ textbooks: access.filterTextbooks(req.user, textbooks) });
|
||||
});
|
||||
|
||||
/* GET /api/textbooks/bookmarks/all — all my bookmarks across textbooks */
|
||||
|
||||
@@ -53,6 +53,7 @@ const parentRoutes = require('./routes/parent');
|
||||
const exam9Routes = require('./routes/exam9');
|
||||
const examPrepRoutes = require('./routes/exam-prep');
|
||||
const textbookRoutes = require('./routes/textbooks');
|
||||
const accessRoutes = require('./routes/access');
|
||||
const teacherStudentsRoutes = require('./routes/teacherStudents');
|
||||
|
||||
const { requestId, errorHandler } = require('./middleware/errorHandler');
|
||||
@@ -174,6 +175,7 @@ app.use('/api/parent', parentRoutes);
|
||||
app.use('/api/exam9', exam9Routes);
|
||||
app.use('/api/exam-prep', examPrepRoutes);
|
||||
app.use('/api/textbooks', textbookRoutes);
|
||||
app.use('/api/access', accessRoutes);
|
||||
app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||
|
||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
/* contentAccess — резолвинг доступа к учебникам / экзамен-модулям.
|
||||
*
|
||||
* Модель: ALLOWLIST. По умолчанию закрыто. Правило ученика важнее правила
|
||||
* класса. См. миграцию 040_content_access.sql.
|
||||
*
|
||||
* canAccessTextbook(user, slug) → bool
|
||||
* canAccessExam(user, examKey) → bool
|
||||
* filterTextbooks(user, rows) → отфильтрованный список (rows[*].slug)
|
||||
* allowedRefs(userId, type) → Set<content_ref> доступных пользователю
|
||||
*/
|
||||
const db = require('../db/db');
|
||||
|
||||
const PRIVILEGED = new Set(['admin', 'teacher']);
|
||||
|
||||
const _parentOf = db.prepare('SELECT parent_slug FROM textbooks WHERE slug = ?');
|
||||
/* Глава наследует доступ родителя-хаба; верхнеуровневый учебник — ключ = own slug. */
|
||||
function textbookAccessKey(slug) {
|
||||
const row = _parentOf.get(slug);
|
||||
return row && row.parent_slug ? row.parent_slug : slug;
|
||||
}
|
||||
|
||||
const _studentRule = db.prepare(`
|
||||
SELECT allow FROM content_access
|
||||
WHERE content_type = ? AND content_ref = ? AND scope = 'student' AND target_id = ?
|
||||
`);
|
||||
const _classRule = db.prepare(`
|
||||
SELECT MAX(allow) AS any_allow, COUNT(*) AS n
|
||||
FROM content_access
|
||||
WHERE content_type = ? AND content_ref = ? AND scope = 'class'
|
||||
AND target_id IN (SELECT class_id FROM class_members WHERE user_id = ?)
|
||||
`);
|
||||
|
||||
function resolve(userId, type, ref) {
|
||||
const s = _studentRule.get(type, ref, userId);
|
||||
if (s) return s.allow === 1; // правило ученика побеждает
|
||||
const c = _classRule.get(type, ref, userId);
|
||||
if (c && c.n > 0) return c.any_allow === 1; // открыт хотя бы одним классом
|
||||
return false; // allowlist — по умолчанию закрыто
|
||||
}
|
||||
|
||||
function canAccess(user, type, ref) {
|
||||
if (!user) return false;
|
||||
if (PRIVILEGED.has(user.role)) return true; // админ/учитель видят весь контент
|
||||
return resolve(user.id, type, ref);
|
||||
}
|
||||
|
||||
function canAccessTextbook(user, slug) {
|
||||
return canAccess(user, 'textbook', textbookAccessKey(slug));
|
||||
}
|
||||
function canAccessExam(user, examKey) {
|
||||
return canAccess(user, 'exam', examKey);
|
||||
}
|
||||
|
||||
/* Множество доступных пользователю content_ref для типа (bulk, для каталога). */
|
||||
const _allStudentRules = db.prepare(`
|
||||
SELECT content_ref, allow FROM content_access
|
||||
WHERE content_type = ? AND scope = 'student' AND target_id = ?
|
||||
`);
|
||||
const _allClassRules = db.prepare(`
|
||||
SELECT content_ref, MAX(allow) AS any_allow
|
||||
FROM content_access
|
||||
WHERE content_type = ? AND scope = 'class'
|
||||
AND target_id IN (SELECT class_id FROM class_members WHERE user_id = ?)
|
||||
GROUP BY content_ref
|
||||
`);
|
||||
function allowedRefs(userId, type) {
|
||||
const out = new Set();
|
||||
for (const r of _allClassRules.all(type, userId)) {
|
||||
if (r.any_allow === 1) out.add(r.content_ref);
|
||||
}
|
||||
for (const r of _allStudentRules.all(type, userId)) {
|
||||
if (r.allow === 1) out.add(r.content_ref); // ученик-разрешение добавляет
|
||||
else out.delete(r.content_ref); // ученик-запрет снимает
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Фильтрует список учебников верхнего уровня (каждый row имеет .slug). */
|
||||
function filterTextbooks(user, rows) {
|
||||
if (!user) return [];
|
||||
if (PRIVILEGED.has(user.role)) return rows;
|
||||
const allow = allowedRefs(user.id, 'textbook');
|
||||
return rows.filter(r => allow.has(r.slug));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PRIVILEGED,
|
||||
textbookAccessKey,
|
||||
canAccess,
|
||||
canAccessTextbook,
|
||||
canAccessExam,
|
||||
allowedRefs,
|
||||
filterTextbooks,
|
||||
};
|
||||
Reference in New Issue
Block a user