Files
Learn_System/backend/src/middleware/features.js
T
Maxim Dolgolyov 81bf5d75eb fix(features): админ-оверрайд в requireFeature — API отключённого модуля 404-ил админа
Симптом: collection выключен, админ открывал страницу (фронтовый админ-оверрайд),
но GET /api/collection отдавал 404 — requireFeature 404-ил всех. requireFeature
идёт ДО authMiddleware (req.user нет), поэтому сам декодирую Bearer-токен: если
роль admin — пропускаем к API даже выключенного модуля. Для student/teacher всё
по-прежнему 404 (модуль скрыт). Зеркалит фронтовый _isAdminUser. Чинит ВСЕ
отключённые модули для админа, не только коллекцию.

Проверено: admin→bypass, student/teacher/нет токена/мусор/чужой секрет→404 (6/6).

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

108 lines
4.2 KiB
JavaScript

'use strict';
/**
* Feature-flag middleware + helpers.
*
* requireFeature(name)
* Express middleware that 404s when the GLOBAL feature flag is off.
* Doesn't see per-class overrides or free_student restrictions —
* those are layered on in computeFeaturesForUser below.
*
* computeFeaturesForUser(userId, role)
* Synchronous merge of three sources, mirroring /api/features in
* server.js (kept in one place so other endpoints — gamification
* achievements, shop, etc. — can apply the same view of "is X
* enabled for THIS user right now"):
* 1) global app_settings.feature_<name>_enabled (default = ON)
* 2) class overlay (classes.features JSON, only `false` wins)
* 3) free_student role overlay (app_settings.free_student_features)
* Returns { featureName: boolean } where boolean = is-enabled.
*
* isFeatureEnabledForUser(userId, role, featureName)
* Thin wrapper around computeFeaturesForUser for single-feature
* checks. Returns true if the feature is on (or unknown — opt-in
* disable model).
*
* Usage:
* app.use('/api/pet', requireFeature('pet'), petRoutes);
* const feats = computeFeaturesForUser(userId, role);
* if (feats.exam9 === false) { ... }
*/
const db = require('../db/db');
const jwt = require('jsonwebtoken');
// Админ-оверрайд: requireFeature идёт ДО authMiddleware (req.user ещё нет),
// поэтому декодируем Bearer-токен сами — админ открывает и отключённые модули
// (зеркалит фронтовый _isAdminUser, см. project_gamification_killswitch).
function _isAdminReq(req) {
try {
const h = req.headers.authorization || '';
if (!h.startsWith('Bearer ')) return false;
const p = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
return !!(p && p.role === 'admin');
} catch (e) { return false; }
}
const _stmtSingle = db.prepare("SELECT value FROM app_settings WHERE key = ?");
const _stmtGlobalFeats = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'");
const _stmtClassFeats = db.prepare(
'SELECT c.features FROM classes c JOIN class_members cm ON cm.class_id = c.id WHERE cm.user_id = ?'
);
const _stmtFreeStudentFeats = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'");
function requireFeature(name) {
const settingKey = `feature_${name}_enabled`;
return (req, res, next) => {
const row = _stmtSingle.get(settingKey);
if (row && row.value === '0' && !_isAdminReq(req)) { // админ проходит к API даже выключенного модуля
return res.status(404).json({ error: 'Feature disabled' });
}
next();
};
}
function computeFeaturesForUser(userId, role) {
// 1) Globals — missing = ON.
const features = {};
for (const r of _stmtGlobalFeats.all()) {
const name = r.key.replace('feature_', '').replace('_enabled', '');
features[name] = r.value === '1';
}
// 2) Class overlay — only `false` wins (more restrictive).
if (userId && (role === 'student' || role === 'free_student')) {
for (const row of _stmtClassFeats.all(userId)) {
if (!row.features) continue;
try {
const f = JSON.parse(row.features);
for (const [key, val] of Object.entries(f)) {
if (val === false) features[key] = false;
}
} catch { /* malformed JSON — ignore */ }
}
}
// 3) free_student overlay.
if (role === 'free_student') {
const fsRow = _stmtFreeStudentFeats.get();
if (fsRow?.value) {
try {
const fsFeats = JSON.parse(fsRow.value);
for (const [key, val] of Object.entries(fsFeats)) {
if (val === false) features[key] = false;
}
} catch { /* malformed — ignore */ }
}
}
return features;
}
function isFeatureEnabledForUser(userId, role, featureName) {
if (!featureName) return true;
const feats = computeFeaturesForUser(userId, role);
// Missing key (no row in app_settings) → treat as ON (opt-in disable).
return feats[featureName] !== false;
}
module.exports = { requireFeature, computeFeaturesForUser, isFeatureEnabledForUser };