81bf5d75eb
Симптом: 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>
108 lines
4.2 KiB
JavaScript
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 };
|