const db = require('../db/db'); const fs = require('fs'); const path = require('path'); const { stripTags } = require('../utils/sanitize'); const { audit } = require('../utils/audit'); const { purgeAccessFor } = require('../services/contentAccess'); const sysReset = require('../services/systemReset'); /* ── Prepared statements ──────────────────────────────────────────────── */ const stmts = { totalUsers: db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'student'"), totalTests: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE status = 'completed'"), avgScore: db.prepare( "SELECT ROUND(AVG(CAST(score AS REAL) / total * 100), 1) AS avg FROM test_sessions WHERE status = 'completed'" ), bySubject: db.prepare(` SELECT s.name, s.slug, COUNT(ts.id) AS tests, ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct FROM test_sessions ts JOIN subjects s ON s.id = ts.subject_id WHERE ts.status = 'completed' GROUP BY s.id ORDER BY s.id `), }; /* ── GET /api/admin/stats ─────────────────────────────────────────────── */ function getStats(_req, res) { res.json({ totalUsers: stmts.totalUsers.get().n, totalTests: stmts.totalTests.get().n, avgScore: stmts.avgScore.get().avg, bySubject: stmts.bySubject.all(), }); } /* ── Overview (Phase 3 dashboard) — prepared statements ───────────────── */ const overviewStmts = { newUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE created_at >= datetime('now', '-24 hours')"), newSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours')"), activeUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE last_login IS NOT NULL AND last_login >= datetime('now', '-24 hours')"), abandonedSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours') AND status = 'abandoned'"), classesTotal: db.prepare('SELECT COUNT(*) AS n FROM classes'), stuckSessions: db.prepare(` SELECT ts.id, u.name AS user_name, s.name AS subject_name, ts.started_at FROM test_sessions ts JOIN users u ON u.id = ts.user_id LEFT JOIN subjects s ON s.id = ts.subject_id WHERE ts.status = 'in_progress' AND ts.started_at < datetime('now', '-1 hour') ORDER BY ts.started_at LIMIT 5 `), // No banned_at column — fall back to audit log for recent bans (last 7 days) bannedThisWeek: db.prepare(` SELECT u.id, u.name, u.email, al.created_at AS banned_at FROM admin_audit_log al JOIN users u ON u.id = CAST(SUBSTR(al.target, 6) AS INTEGER) WHERE al.action = 'user.ban' AND al.created_at >= datetime('now', '-7 days') AND u.is_banned = 1 GROUP BY u.id ORDER BY al.created_at DESC LIMIT 10 `), topSessions24h: db.prepare(` SELECT ts.id, u.name AS user_name, s.name AS subject_name, ts.score, ts.total, ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent, ts.finished_at FROM test_sessions ts JOIN users u ON u.id = ts.user_id LEFT JOIN subjects s ON s.id = ts.subject_id WHERE ts.status = 'completed' AND ts.finished_at >= datetime('now', '-24 hours') AND ts.total > 0 ORDER BY (CAST(ts.score AS REAL) / ts.total) DESC, ts.finished_at DESC LIMIT 5 `), worstSessions24h: db.prepare(` SELECT ts.id, u.name AS user_name, s.name AS subject_name, ts.score, ts.total, ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent, ts.finished_at FROM test_sessions ts JOIN users u ON u.id = ts.user_id LEFT JOIN subjects s ON s.id = ts.subject_id WHERE ts.status = 'completed' AND ts.finished_at >= datetime('now', '-24 hours') AND ts.total > 0 ORDER BY (CAST(ts.score AS REAL) / ts.total) ASC, ts.finished_at DESC LIMIT 5 `), sparkUsers7d: db.prepare(` SELECT date(created_at) AS d, COUNT(*) AS n FROM users WHERE created_at >= date('now', '-6 days') GROUP BY d ORDER BY d `), sparkSessions7d: db.prepare(` SELECT date(started_at) AS d, COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now', '-6 days') GROUP BY d ORDER BY d `), sparkActiveUsers7d: db.prepare(` SELECT date(last_login) AS d, COUNT(DISTINCT id) AS n FROM users WHERE last_login IS NOT NULL AND last_login >= date('now', '-6 days') GROUP BY d ORDER BY d `), inventory: db.prepare(` SELECT (SELECT COUNT(*) FROM questions) AS questions, (SELECT COUNT(*) FROM tests) AS tests, (SELECT COUNT(*) FROM courses) AS courses, (SELECT COUNT(*) FROM classes) AS classes `), sessionsBySubject24h: db.prepare(` SELECT s.slug, s.name, COUNT(*) AS n FROM test_sessions ts JOIN subjects s ON s.id = ts.subject_id WHERE ts.started_at >= datetime('now', '-24 hours') GROUP BY s.id ORDER BY n DESC `), }; /* ── GET /api/admin/overview ──────────────────────────────────────────── */ function getOverview(_req, res) { try { res.json({ newUsers24h: overviewStmts.newUsers24h.get().n, newSessions24h: overviewStmts.newSessions24h.get().n, activeUsers24h: overviewStmts.activeUsers24h.get().n, classesTotal: overviewStmts.classesTotal.get().n, abandonedSessions24h: overviewStmts.abandonedSessions24h.get().n, stuckSessions: overviewStmts.stuckSessions.all(), bannedThisWeek: overviewStmts.bannedThisWeek.all(), topSessions24h: overviewStmts.topSessions24h.all(), worstSessions24h: overviewStmts.worstSessions24h.all(), inventory: overviewStmts.inventory.get(), sessionsBySubject24h: overviewStmts.sessionsBySubject24h.all(), sparks: { users: overviewStmts.sparkUsers7d.all(), sessions: overviewStmts.sparkSessions7d.all(), active: overviewStmts.sparkActiveUsers7d.all(), }, }); } catch (err) { res.status(500).json({ error: err.message }); } } /* ── Global search (Phase 4 command palette) — prepared statements ────── */ const searchStmts = { users: db.prepare(` SELECT id, name, email, role FROM users WHERE name LIKE ? OR email LIKE ? ORDER BY (CASE WHEN name LIKE ? THEN 0 ELSE 1 END), id DESC LIMIT 5 `), tests: db.prepare(` SELECT id, title AS name, subject_slug FROM tests WHERE title LIKE ? ORDER BY id DESC LIMIT 3 `), classes: db.prepare(` SELECT id, name, invite_code AS code FROM classes WHERE name LIKE ? OR invite_code LIKE ? ORDER BY id DESC LIMIT 3 `), }; /* ── GET /api/admin/search?q=X ────────────────────────────────────────── */ function globalSearch(req, res) { const q = (req.query.q || '').trim(); if (q.length < 2) { return res.json({ users: [], tests: [], classes: [] }); } try { const like = `%${q}%`; const prefix = `${q}%`; res.json({ users: searchStmts.users.all(like, like, prefix), tests: searchStmts.tests.all(like), classes: searchStmts.classes.all(like, like), }); } catch (err) { console.error('[admin.search]', err.message); res.status(500).json({ error: 'Search failed' }); } } /* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */ function getUsers(req, res) { const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50)); const cursor = Number(req.query.cursor) || 0; const role = req.query.role; const search = req.query.q?.trim(); let where = 'WHERE 1=1'; const args = []; if (role) { where += ' AND u.role = ?'; args.push(role); } if (search) { where += ' AND (u.name LIKE ? OR u.email LIKE ?)'; args.push(`%${search}%`, `%${search}%`); } // Cursor-based pagination if (cursor) { const cursorWhere = where + ' AND u.id < ?'; const cursorArgs = [...args, cursor]; const users = db.prepare(` SELECT u.id, u.name, u.email, u.role, u.custom_role, u.created_at, u.last_login, u.is_banned, COUNT(ts.id) AS tests_count, ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct FROM users u LEFT JOIN test_sessions ts ON ts.user_id = u.id AND ts.status = 'completed' ${cursorWhere} GROUP BY u.id ORDER BY u.id DESC LIMIT ? `).all(...cursorArgs, limit); const nextCursor = users.length === limit ? users[users.length - 1].id : null; return res.json({ users, nextCursor, limit }); } // Offset-based (legacy) const page = Math.max(1, Number(req.query.page) || 1); const offset = (page - 1) * limit; const { total } = db.prepare(`SELECT COUNT(*) AS total FROM users u ${where}`).get(...args); const users = db.prepare(` SELECT u.id, u.name, u.email, u.role, u.created_at, u.last_login, u.is_banned, COUNT(ts.id) AS tests_count, ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct FROM users u LEFT JOIN test_sessions ts ON ts.user_id = u.id AND ts.status = 'completed' ${where} GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ? `).all(...args, limit, offset); res.json({ users, total, page, limit }); } /* ── PATCH /api/admin/users/:id/role ─────────────────────────────────── */ function updateRole(req, res) { const { role } = req.body; const BUILTIN = ['student', 'teacher', 'admin', 'free_student']; // Кастомная роль: хранится как функциональная база (base_roles[0]) в users.role // + имя в users.custom_role. Встроенная роль: role = built-in, custom_role = NULL. let baseRole = role, customRole = null; if (!BUILTIN.includes(role)) { let cr = null; try { cr = db.prepare('SELECT base_roles, is_builtin FROM roles WHERE name = ?').get(role); } catch (_e) { cr = null; } if (!cr || cr.is_builtin) return res.status(400).json({ error: `Unknown role: ${role}` }); let bases = []; try { bases = JSON.parse(cr.base_roles || '[]'); } catch (_e) { bases = []; } baseRole = bases.find(b => BUILTIN.includes(b)) || 'student'; customRole = role; } const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); if (Number(req.params.id) === req.user.id) return res.status(400).json({ error: 'Cannot change your own role' }); const oldRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id)?.role; db.prepare('UPDATE users SET role = ?, custom_role = ?, token_version = token_version + 1 WHERE id = ?') .run(baseRole, customRole, req.params.id); audit(req, 'user.role_change', `user:${req.params.id}`, `${oldRole} -> ${customRole || baseRole}`); res.json({ id: Number(req.params.id), role: customRole || baseRole, base: baseRole }); } /* ── GET /api/admin/users/:id/sessions ───────────────────────────────── */ function getUserSessions(req, res) { const user = db.prepare('SELECT id, name, email, role, is_banned FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); const sessions = db.prepare(` SELECT ts.id, ts.mode, ts.score, ts.total, ts.status, ts.started_at, ts.finished_at, s.name AS subject_name, s.slug AS subject_slug FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id WHERE ts.user_id = ? ORDER BY ts.started_at DESC LIMIT 100 `).all(req.params.id); res.json({ user, sessions }); } /* ── GET /api/admin/sessions ─────────────────────────────────────────── */ function getAllSessions(req, res) { const { subject, user_id, status } = req.query; const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200)); const offset = Math.max(0, Number(req.query.offset) || 0); // По умолчанию показываем и завершённые, и НЕзавершённые (in_progress) — иначе зависшие // сессии не находились в списке (см. алерт «Зависла»). Опционально сужаем по ?status=. const where = []; const params = []; if (status && ['completed', 'in_progress', 'abandoned'].includes(status)) { where.push('ts.status = ?'); params.push(status); } if (subject) { where.push('s.slug = ?'); params.push(subject); } if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); } params.push(limit, offset); const sessions = db.prepare(` SELECT ts.id, ts.mode, ts.score, ts.total, ts.status, ts.started_at, ts.finished_at, s.name AS subject_name, s.slug AS subject_slug, u.id AS user_id, u.name AS user_name, u.email AS user_email, ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, CAST((julianday(ts.finished_at) - julianday(ts.started_at)) * 86400 AS INTEGER) AS duration_sec FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id JOIN users u ON u.id = ts.user_id ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY ts.started_at DESC LIMIT ? OFFSET ? `).all(...params); res.json(sessions); } /* ── GET /api/admin/sessions/:id ─────────────────────────────────────── */ function getSessionDetail(req, res) { const session = db.prepare(` SELECT ts.id, ts.mode, ts.score, ts.total, ts.status, ts.started_at, ts.finished_at, s.name AS subject_name, s.slug AS subject_slug, u.id AS user_id, u.name AS user_name, u.email AS user_email, CAST((julianday(ts.finished_at) - julianday(ts.started_at)) * 86400 AS INTEGER) AS duration_sec FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id JOIN users u ON u.id = ts.user_id WHERE ts.id = ? `).get(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); const questions = db.prepare(` SELECT q.id, q.text, q.explanation, q.difficulty, ua.chosen_option_id, ua.is_correct, ua.time_spent_sec, sq.order_index FROM session_questions sq JOIN questions q ON q.id = sq.question_id LEFT JOIN user_answers ua ON ua.session_id = sq.session_id AND ua.question_id = q.id WHERE sq.session_id = ? ORDER BY sq.order_index `).all(req.params.id); if (questions.length) { const ids = questions.map(q => q.id); const allOptions = db.prepare( `SELECT id, question_id, text, is_correct FROM options WHERE question_id IN (${ids.map(() => '?').join(',')}) ORDER BY order_index` ).all(...ids); const byQ = {}; for (const o of allOptions) { (byQ[o.question_id] ||= []).push(o); } session.questions = questions.map(q => ({ ...q, options: byQ[q.id] || [] })); } else { session.questions = []; } res.json(session); } /* ── DELETE /api/admin/sessions/:id ──────────────────────────────────── */ const _deleteSessionTx = db.transaction((sid) => { // assignment_sessions references test_sessions with ON DELETE SET NULL, // but we explicitly null it so the assignment slot stays usable. db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?').run(sid); // user_answers / session_questions cascade via ON DELETE CASCADE, // but delete explicitly for visibility and to mirror clearUserSessions(). db.prepare('DELETE FROM user_answers WHERE session_id = ?').run(sid); db.prepare('DELETE FROM session_questions WHERE session_id = ?').run(sid); db.prepare('DELETE FROM test_sessions WHERE id = ?').run(sid); }); function deleteSession(req, res, next) { const sid = Number(req.params.id); if (!Number.isInteger(sid) || sid <= 0) return res.status(400).json({ error: 'Invalid session id' }); try { const sess = db.prepare('SELECT id, user_id, mode FROM test_sessions WHERE id = ?').get(sid); if (!sess) return res.status(404).json({ error: 'Session not found' }); _deleteSessionTx(sid); audit(req, 'session.delete', `session:${sid}`, `user:${sess.user_id} mode:${sess.mode}`); res.json({ ok: true }); } catch (err) { next(err); } } /* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */ function clearUserSessions(req, res, next) { const uid = Number(req.params.id); try { const user = db.prepare('SELECT id FROM users WHERE id = ?').get(uid); if (!user) return res.status(404).json({ error: 'User not found' }); const sessions = db.prepare('SELECT id FROM test_sessions WHERE user_id = ?').all(uid); const n = sessions.length; if (n > 0) { const stmtNullAsgn = db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?'); const stmtDelAns = db.prepare('DELETE FROM user_answers WHERE session_id = ?'); const stmtDelSQ = db.prepare('DELETE FROM session_questions WHERE session_id = ?'); const stmtDelSess = db.prepare('DELETE FROM test_sessions WHERE id = ?'); db.transaction(() => { for (const { id } of sessions) { stmtNullAsgn.run(id); stmtDelAns.run(id); stmtDelSQ.run(id); stmtDelSess.run(id); } })(); } audit(req, 'user.clear_sessions', `user:${uid}`, `${n} sessions deleted`); res.json({ ok: true, deleted: n }); } catch (err) { next(err); } } /* ── PATCH /api/admin/users/:id ──────────────────────────────────────── */ const bcrypt = require('bcryptjs'); const { BCRYPT_ROUNDS } = require('../config'); async function updateUser(req, res, next) { try { const { name, email, password } = req.body; const uid = Number(req.params.id); const user = db.prepare('SELECT id FROM users WHERE id = ?').get(uid); if (!user) return res.status(404).json({ error: 'User not found' }); if (uid === req.user.id) return res.status(400).json({ error: 'Cannot edit your own data here' }); if (name !== undefined && !name?.trim()) return res.status(400).json({ error: 'Name cannot be empty' }); if (email !== undefined) { if (!email?.trim() || !/\S+@\S+\.\S+/.test(email.trim())) return res.status(400).json({ error: 'Invalid email format' }); const existing = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email.trim().toLowerCase(), uid); if (existing) return res.status(400).json({ error: 'Email already in use' }); } if (password !== undefined && password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' }); const fields = []; const vals = []; if (name !== undefined) { fields.push('name = ?'); vals.push(stripTags(name.trim())); } if (email !== undefined) { fields.push('email = ?'); vals.push(email.trim().toLowerCase()); } if (password !== undefined) { const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); fields.push('password_hash = ?', 'token_version = token_version + 1'); vals.push(hash); } if (!fields.length) return res.status(400).json({ error: 'Nothing to update' }); vals.push(uid); db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...vals); const changed = []; if (name !== undefined) changed.push('name'); if (email !== undefined) changed.push('email'); if (password !== undefined) changed.push('password'); audit(req, 'user.edit', `user:${uid}`, changed.join(', ')); res.json({ ok: true }); } catch (err) { next(err); } } /* ── PATCH /api/admin/users/:id/ban { banned: true|false } ──────────── */ function banUser(req, res) { const uid = Number(req.params.id); if (uid === req.user.id) return res.status(400).json({ error: 'Нельзя заблокировать себя' }); const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.role === 'admin') return res.status(400).json({ error: 'Нельзя заблокировать администратора' }); const banned = req.body.banned ? 1 : 0; db.prepare('UPDATE users SET is_banned = ?, token_version = token_version + 1 WHERE id = ?').run(banned, uid); audit(req, banned ? 'user.ban' : 'user.unban', `user:${uid}`, target.role); res.json({ ok: true, banned: !!banned }); } /* ── DELETE /api/admin/users/:id ─────────────────────────────────────── */ const _deleteUserTx = db.transaction((uid) => { // Tables with REFERENCES users(id) WITHOUT ON DELETE CASCADE: db.prepare('DELETE FROM questions WHERE created_by = ?').run(uid); db.prepare('DELETE FROM assignments WHERE created_by = ?').run(uid); // The rest cascades via ON DELETE CASCADE, but explicitly clean large tables: db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid); db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid); // Персональные правила доступа к контенту (нет FK — единая чистка): purgeAccessFor('student', uid); db.prepare('DELETE FROM users WHERE id = ?').run(uid); }); function deleteUser(req, res) { const uid = Number(req.params.id); if (uid === req.user.id) return res.status(400).json({ error: 'Нельзя удалить себя' }); const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.role === 'admin') return res.status(400).json({ error: 'Нельзя удалить администратора' }); const uname = db.prepare('SELECT name, email FROM users WHERE id = ?').get(uid); _deleteUserTx(uid); audit(req, 'user.delete', `user:${uid}`, `${uname?.name} (${uname?.email})`); res.json({ ok: true }); } /* ── GET /api/admin/features ─────────────────────────────────────────── */ function getFeatures(_req, res) { const rows = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'").all(); const features = {}; for (const r of rows) { const name = r.key.replace('feature_', '').replace('_enabled', ''); features[name] = r.value === '1'; } res.json(features); } /* ── PATCH /api/admin/features ──────────────────────────────────────── */ function updateFeatures(req, res) { const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection', 'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom', 'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes']; const updates = req.body; const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?"); let touchedGamification = false; for (const [name, enabled] of Object.entries(updates)) { if (!allowed.includes(name)) continue; const settingKey = `feature_${name}_enabled`; const oldRow = getOld.get(settingKey); const oldVal = oldRow ? oldRow.value : null; const newVal = enabled ? '1' : '0'; stmt.run(settingKey, newVal); audit(req, 'feature.update', `feature:${name}`, `${oldVal} -> ${newVal}`); if (name === 'gamification') touchedGamification = true; } // Invalidate the gamification cache so the kill-switch takes effect // immediately (otherwise awardXP keeps running for up to 30s). if (touchedGamification) { try { const { invalidateGamificationCache } = require('./gamification/_shared'); invalidateGamificationCache(); } catch { /* defensive — shouldn't fail */ } } res.json({ ok: true }); } /* ── GET /api/admin/free-student-features ────────────────────────────── */ const FREE_STUDENT_MODULES = [ 'gamification', 'hangman', 'crossword', 'pet', 'red_book', 'collection', 'lab', 'knowledge_map', 'flashcards', 'board', 'biochem', 'live_quiz', ]; function getFreeStudentFeatures(_req, res) { const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get(); let features = {}; if (row?.value) { try { features = JSON.parse(row.value); } catch {} } // Default: all enabled const result = {}; for (const m of FREE_STUDENT_MODULES) { result[m] = features[m] !== false; } res.json(result); } /* ── PATCH /api/admin/free-student-features ─────────────────────────── */ function updateFreeStudentFeatures(req, res) { const updates = req.body; const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get(); let current = {}; if (row?.value) { try { current = JSON.parse(row.value); } catch {} } for (const [key, val] of Object.entries(updates)) { if (!FREE_STUDENT_MODULES.includes(key)) continue; current[key] = Boolean(val); } db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('free_student_features', ?)").run(JSON.stringify(current)); res.json({ ok: true }); } /* ── GET /api/admin/reset-system/plan ────────────────────────────────── План «чистого запуска»: что переназначится / сотрётся / неизвестно. Без изменений. */ function getResetPlan(req, res) { try { const plan = sysReset.classify(db); // Текущий админ остаётся залогиненным — сохраняем именно его, не min-id. res.json({ ...plan, keptAdmin: { id: req.user.id, email: req.user.email, name: req.user.name } }); } catch (e) { res.status(500).json({ error: 'Не удалось построить план: ' + e.message }); } } /* ── POST /api/admin/reset-system ────────────────────────────────────── ⚠️ ДЕСТРУКТИВНО. Только admin. Требует body.confirm === 'СБРОС' (или 'RESET'). Делает бэкап БД, сохраняет ТЕКУЩЕГО админа (оператор остаётся в системе), стирает остальных пользователей + активность, переназначает контент. */ function resetSystem(req, res) { const confirm = (req.body && req.body.confirm) || ''; if (confirm !== 'СБРОС' && confirm !== 'RESET') { return res.status(400).json({ error: 'Подтверждение не совпало. Введите СБРОС.' }); } const keptId = req.user.id; // 1) Бэкап ДО любых изменений (checkpoint WAL → копия основного файла). let backupName = null; try { const dbPath = db._path; if (!dbPath) throw new Error('путь к БД неизвестен'); const backupsDir = path.join(path.dirname(dbPath), 'backups'); if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true }); try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* не WAL — ок */ } const d = new Date(); const p2 = n => String(n).padStart(2, '0'); const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`; backupName = `learnspace-prereset-${ts}.db`; fs.copyFileSync(dbPath, path.join(backupsDir, backupName)); } catch (e) { return res.status(500).json({ error: 'Бэкап не удался — сброс отменён: ' + e.message }); } // 2) Сброс (бросает при ошибке → откат внутри сервиса, данные целы). let summary; try { summary = sysReset.runReset(db, keptId); } catch (e) { return res.status(500).json({ error: 'Сброс не выполнен (откат): ' + e.message, backup: backupName }); } // 3) Аудит ПОСЛЕ сброса (admin_audit_log очищается сбросом — пишем первой записью). try { audit(req, 'system.reset', 'system', `keptAdmin=${keptId} backup=${backupName} deleted=${summary.deletedUsers}`); } catch {} res.json({ ok: true, backup: backupName, ...summary }); } /* ── GET /api/admin/audit-log ───────────────────────────────────────── */ function getAuditLog(req, res) { const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100)); const rows = db.prepare(` SELECT al.*, u.name AS admin_name, u.email AS admin_email FROM admin_audit_log al LEFT JOIN users u ON u.id = al.admin_id ORDER BY al.created_at DESC LIMIT ? `).all(limit); res.json(rows); } /* ── DELETE /api/admin/audit-log ────────────────────────────────────── */ function clearAuditLog(req, res) { db.prepare('DELETE FROM admin_audit_log').run(); res.json({ ok: true }); } /* ── GET /api/admin/error-log ──────────────────────────────────────── */ function getErrorLog(req, res) { const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100)); const rows = db.prepare(` SELECT * FROM error_log ORDER BY created_at DESC LIMIT ? `).all(limit); res.json(rows); } /* ── DELETE /api/admin/error-log ───────────────────────────────────── */ function clearErrorLog(req, res) { db.prepare('DELETE FROM error_log').run(); res.json({ ok: true }); } /* ── GET /api/admin/security-log?limit&category&q ────────────────────── Журнал событий безопасности/входа (security_events, миграция 047). category ∈ auth | access_denied | rate_limit; q — поиск по email/ip/route/detail/event. */ function getSecurityLog(req, res) { const limit = Math.min(1000, Math.max(1, Number(req.query.limit) || 200)); const category = req.query.category; const q = (req.query.q || '').trim(); const where = [], args = []; if (['auth', 'access_denied', 'rate_limit'].includes(category)) { where.push('se.category = ?'); args.push(category); } if (q) { where.push('(se.email LIKE ? OR se.ip LIKE ? OR se.route LIKE ? OR se.detail LIKE ? OR se.event LIKE ?)'); const like = `%${q}%`; args.push(like, like, like, like, like); } const rows = db.prepare(` SELECT se.*, u.name AS user_name, u.email AS user_email FROM security_events se LEFT JOIN users u ON u.id = se.user_id ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY se.created_at DESC LIMIT ? `).all(...args, limit); res.json(rows); } /* ── DELETE /api/admin/security-log[?days=N] ─────────────────────────── Без days — очистить весь журнал; с days=N — прунинг записей старше N дней. */ function clearSecurityLog(req, res) { const days = Number(req.query.days); if (Number.isInteger(days) && days > 0) { const r = db.prepare(`DELETE FROM security_events WHERE created_at < datetime('now', ?)`).run(`-${days} days`); return res.json({ ok: true, deleted: r.changes }); } const r = db.prepare('DELETE FROM security_events').run(); res.json({ ok: true, deleted: r.changes }); } /* ── GET /api/admin/health ─────────────────────────────────────────── */ const os = require('os'); const { execSync } = require('child_process'); const { monitorEventLoopDelay } = require('perf_hooks'); const sse = require('../sse'); const { DB_PATH, UPLOADS_DIR, NODE_ENV } = require('../config'); // Монитор лага event-loop (включается один раз при загрузке модуля). const _eluMonitor = monitorEventLoopDelay({ resolution: 20 }); _eluMonitor.enable(); // Версия приложения + git-commit (кэшируются один раз). const _appVersion = (() => { try { return require('../../package.json').version; } catch { return '?'; } })(); const _gitCommit = (() => { try { return execSync('git rev-parse --short HEAD', { cwd: __dirname, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; } })(); function _dirSize(dir) { let total = 0; try { for (const f of fs.readdirSync(dir)) { try { total += fs.statSync(path.join(dir, f)).size; } catch {} } } catch {} return total; } // Топ таблиц БД по числу строк (исключая служебные sqlite_* и _migrations). function _dbTables() { try { const tables = db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_%' ESCAPE '\\'" ).all(); const rows = tables.map(t => { let n = 0; try { n = db.prepare(`SELECT COUNT(*) AS n FROM "${t.name}"`).get().n; } catch {} return { name: t.name, rows: n }; }); rows.sort((a, b) => b.rows - a.rows); return rows.slice(0, 12); } catch { return []; } } // Активные проверки: отклик БД (мс) и тест записи на диск рядом с БД. function _runChecks() { let dbPingMs = null, dbOk = false; try { const t = process.hrtime.bigint(); db.prepare('SELECT 1 AS ok').get(); dbPingMs = Number(process.hrtime.bigint() - t) / 1e6; dbOk = true; } catch {} let diskWritable = false; try { const f = path.join(path.dirname(path.resolve(DB_PATH)), '.health-write-test'); fs.writeFileSync(f, 'ok'); fs.unlinkSync(f); diskWritable = true; } catch {} return { dbPingMs, dbOk, diskWritable }; } const _recentErrStmt = db.prepare( "SELECT id, level, message, route, method, created_at FROM error_log ORDER BY id DESC LIMIT 8" ); function getHealth(_req, res) { const uptimeSec = process.uptime(); const mem = process.memoryUsage(); const dbSizeBytes = (() => { try { return fs.statSync(DB_PATH).size; } catch { return 0; } })(); const walSizeBytes = (() => { try { return fs.statSync(DB_PATH + '-wal').size; } catch { return 0; } })(); const uploadsSizeBytes = _dirSize(UPLOADS_DIR); // Свободное место на разделе, где лежит БД. let disk = null; try { const s = fs.statfsSync(path.dirname(path.resolve(DB_PATH))); disk = { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize }; } catch {} const totalMem = os.totalmem(), freeMem = os.freemem(); const memPercent = totalMem ? (totalMem - freeMem) / totalMem : 0; const eventLoopLagMs = _eluMonitor.mean / 1e6; const totalUsers = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; const todaySessions = db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now')").get().n; const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM test_sessions').get().n; const totalQuestions = db.prepare('SELECT COUNT(*) AS n FROM questions').get().n; const recentErrors = db.prepare("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= datetime('now', '-24 hours')").get().n; let sseStats = { users: 0, guests: 0, connections: 0 }; try { sseStats = sse.stats(); } catch {} // Активные health-проверки (Level 4): отклик БД и запись на диск. const checks = _runChecks(); const recentErrorList = (() => { try { return _recentErrStmt.all(); } catch { return []; } })(); // Вердикт здоровья по порогам. const reasons = []; let status = 'ok'; const warn = (m) => { reasons.push(m); if (status === 'ok') status = 'warning'; }; const crit = (m) => { reasons.push(m); status = 'critical'; }; if (memPercent > 0.92) crit(`Память ${Math.round(memPercent * 100)}%`); else if (memPercent > 0.80) warn(`Память ${Math.round(memPercent * 100)}%`); if (disk) { if (disk.freeBytes < 500e6) crit('Мало места на диске (<500 МБ)'); else if (disk.freeBytes < 2e9) warn('Места на диске <2 ГБ'); } if (recentErrors > 50) crit(`${recentErrors} ошибок за 24ч`); else if (recentErrors > 5) warn(`${recentErrors} ошибок за 24ч`); if (eventLoopLagMs > 200) crit(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`); else if (eventLoopLagMs > 70) warn(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`); if (dbSizeBytes > 1.5e9) warn('БД >1.5 ГБ'); if (!checks.dbOk) crit('БД недоступна'); if (!checks.diskWritable) crit('Диск недоступен для записи'); if (checks.dbPingMs != null && checks.dbPingMs > 100) warn(`Медленный отклик БД ${checks.dbPingMs.toFixed(0)} мс`); res.json({ status, reasons, checks, recentErrorList, uptime: uptimeSec, startedAt: new Date(Date.now() - uptimeSec * 1000).toISOString(), memory: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal }, memPercent, eventLoopLagMs, loadavg: os.loadavg(), disk, db: { sizeBytes: dbSizeBytes, walBytes: walSizeBytes, totalUsers, totalSessions, todaySessions, totalQuestions, tables: _dbTables() }, uploads: { sizeBytes: uploadsSizeBytes }, sse: sseStats, node: process.version, platform: os.platform(), arch: os.arch(), cpus: os.cpus().length, freeMem, totalMem, pid: process.pid, env: NODE_ENV, version: _appVersion, commit: _gitCommit, recentErrors, }); } /* ── GET /api/admin/metrics — метрики HTTP-запросов (Level 2) ──────────── */ const metrics = require('../utils/metrics'); function getMetrics(_req, res) { res.json(metrics.snapshot()); } /* ── Topics CRUD ─────────────────────────────────────────────────────── */ function getTopics(req, res) { const { subject_id } = req.query; let rows; if (subject_id) { rows = db.prepare(` SELECT t.*, s.name AS subject_name, s.slug AS subject_slug, (SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS question_count FROM topics t JOIN subjects s ON s.id = t.subject_id WHERE t.subject_id = ? ORDER BY t.order_index, t.name `).all(Number(subject_id)); } else { rows = db.prepare(` SELECT t.*, s.name AS subject_name, s.slug AS subject_slug, (SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS question_count FROM topics t JOIN subjects s ON s.id = t.subject_id ORDER BY s.name, t.order_index, t.name `).all(); } res.json(rows); } function createTopic(req, res) { const { subject_id, name } = req.body; if (!subject_id || !name?.trim()) return res.status(400).json({ error: 'subject_id and name required' }); const subj = db.prepare('SELECT id FROM subjects WHERE id = ?').get(Number(subject_id)); if (!subj) return res.status(404).json({ error: 'Subject not found' }); const maxOrder = db.prepare('SELECT MAX(order_index) AS m FROM topics WHERE subject_id = ?').get(Number(subject_id)); const order = (maxOrder?.m || 0) + 1; const r = db.prepare('INSERT INTO topics (subject_id, name, order_index) VALUES (?, ?, ?)').run(Number(subject_id), stripTags(name.trim()), order); audit(req, 'topic.create', `topic:${r.lastInsertRowid}`, name.trim()); res.status(201).json({ id: r.lastInsertRowid, name: name.trim(), order_index: order }); } function updateTopic(req, res) { const id = Number(req.params.id); const topic = db.prepare('SELECT * FROM topics WHERE id = ?').get(id); if (!topic) return res.status(404).json({ error: 'Topic not found' }); const { name, order_index } = req.body; const newName = name !== undefined ? stripTags(name.trim()) : topic.name; const newOrder = order_index !== undefined ? Number(order_index) : topic.order_index; if (!newName) return res.status(400).json({ error: 'Name cannot be empty' }); db.prepare('UPDATE topics SET name = ?, order_index = ? WHERE id = ?').run(newName, newOrder, id); audit(req, 'topic.update', `topic:${id}`, newName); res.json({ ok: true }); } function deleteTopic(req, res) { const id = Number(req.params.id); const topic = db.prepare('SELECT t.*, (SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS qcount FROM topics t WHERE t.id = ?').get(id); if (!topic) return res.status(404).json({ error: 'Topic not found' }); if (topic.qcount > 0) return res.status(400).json({ error: `Cannot delete topic with ${topic.qcount} questions. Reassign questions first.` }); db.prepare('DELETE FROM topics WHERE id = ?').run(id); audit(req, 'topic.delete', `topic:${id}`, topic.name); res.json({ ok: true }); } /* ── Broadcast notification ──────────────────────────────────────────── */ const { pushNotif } = require('../utils/notifications'); function broadcast(req, res) { const { message, role, link } = req.body; if (!message?.trim()) return res.status(400).json({ error: 'message required' }); const msg = stripTags(message.trim()).slice(0, 500); const lnk = link?.trim() || null; let users; if (role && role !== 'all') { users = db.prepare('SELECT id FROM users WHERE role = ? AND is_banned = 0').all(role); } else { users = db.prepare('SELECT id FROM users WHERE is_banned = 0').all(); } const ins = db.prepare("INSERT INTO notifications (user_id, type, message, link) VALUES (?, 'broadcast', ?, ?)"); db.transaction(() => { for (const u of users) ins.run(u.id, msg, lnk); })(); audit(req, 'broadcast', role || 'all', `${users.length} users: ${msg.slice(0, 80)}`); res.json({ ok: true, sent: users.length }); } /* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */ const ASSISTANT_PRESETS = [ { name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-ultra-550b-a55b:free' }, { name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' }, { name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' }, { name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' }, ]; // Проверенные бесплатные модели Kilo (чистый русский) — для выпадающего списка // Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким. // ctx — окно контекста, out — макс. токенов в ответе (данные из /api/openrouter/models). Все бесплатные ($0). const KILO_MODELS = [ { id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман (1M)', ctx: 1000000, out: 65536 }, { id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс (1M)', ctx: 1000000, out: 262144 }, { id: 'nex-agi/nex-n2-pro:free', label: 'Nex N2 Pro — чистый русский (262K)', ctx: 262144, out: 65536 }, { id: 'openrouter/owl-alpha', label: 'Owl Alpha — чистый русский (1M)', ctx: 1048756, out: 262144 }, { id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30B — быстрая (256K)', ctx: 256000, out: 65536 }, { id: 'poolside/laguna-m.1:free', label: 'Laguna M.1 — быстрая (262K)', ctx: 262144, out: 32768 }, { id: 'poolside/laguna-xs.2:free', label: 'Laguna XS — лёгкая (262K)', ctx: 262144, out: 32768 }, ]; function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } } function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); } function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); } function getAssistant(_req, res) { // Миграция legacy-настроек в список провайдеров (один раз) if (!_aset('assistant_providers')) { const lurl = _aset('assistant_llm_url') || process.env.ASSISTANT_LLM_URL; const lkey = _aset('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY; const lmodel = _aset('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL; if (lurl || lkey || lmodel) { _aSetProviders([{ id: 'p1', name: 'Провайдер 1', url: lurl || ASSISTANT_PRESETS[1].url, model: lmodel || ASSISTANT_PRESETS[1].model, key: lkey || '' }]); if (!_aset('assistant_active')) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', 'p1')").run(); } } const list = _aProviders(); const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) })); const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null; const ap = list.find(p => p.id === activeId); const active = !!(ap && (ap.key || _aIsLocal(ap.url))); let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 }; try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {} try { const t = db.prepare('SELECT model_calls, cache_hits, faq FROM assistant_usage WHERE day = ?').get(new Date().toISOString().slice(0, 10)); if (t) usage = t; const s = db.prepare("SELECT COALESCE(SUM(model_calls),0) model_calls, COALESCE(SUM(cache_hits),0) cache_hits, COALESCE(SUM(faq),0) faq FROM assistant_usage WHERE day > date('now','-30 days')").get(); if (s) usage30 = s; } catch (e) {} let feedback = { up: 0, down: 0, recent: [] }; try { const f = db.prepare("SELECT COALESCE(SUM(CASE WHEN rating=1 THEN 1 ELSE 0 END),0) up, COALESCE(SUM(CASE WHEN rating=-1 THEN 1 ELSE 0 END),0) down FROM assistant_feedback WHERE created_at > date('now','-30 days')").get(); if (f) { feedback.up = f.up; feedback.down = f.down; } feedback.recent = db.prepare("SELECT q, created_at FROM assistant_feedback WHERE rating=-1 AND q IS NOT NULL AND q <> '' ORDER BY id DESC LIMIT 5").all(); } catch (e) {} let failover = null; try { var fv = _aset('assistant_failover'); if (fv) failover = JSON.parse(fv); } catch (e) {} res.json({ providers, activeId, active, rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1', memory: _aset('assistant_memory') !== '0', chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS, }); } /* PATCH /api/admin/assistant — только тумблеры (RAG, кнопки экзамена) */ function saveAssistant(req, res) { const set = (k, v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, v); const b = req.body || {}; if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0'); if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0'); if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0'); if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} } audit(req, 'assistant.config', 'assistant', 'настройки'); res.json({ ok: true }); } /* POST /api/admin/assistant/provider — добавить/обновить провайдера */ function saveProvider(req, res) { const arr = _aProviders(); const b = req.body || {}; let p; if (b.id) { p = arr.find(x => x.id === b.id); if (!p) { p = { id: b.id }; arr.push(p); } } else { p = { id: 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1000) }; arr.push(p); } const prevModel = p.model; if (typeof b.name === 'string') p.name = b.name.trim().slice(0, 40) || p.name || 'Провайдер'; if (typeof b.url === 'string') p.url = b.url.trim().slice(0, 300); if (typeof b.model === 'string') p.model = b.model.trim().slice(0, 120); if (typeof b.key === 'string' && b.key.trim()) p.key = b.key.trim().slice(0, 400); if (!p.name) p.name = 'Провайдер'; // Лимиты модели (ctx/out/free): из тела или сброс при смене модели (перезапросятся авто) if (b.ctx !== undefined || b.out !== undefined || b.free !== undefined) { if (b.ctx !== undefined) p.ctx = (b.ctx === null || b.ctx === '') ? null : (Number(b.ctx) || null); if (b.out !== undefined) p.out = (b.out === null || b.out === '') ? null : (Number(b.out) || null); if (b.free !== undefined) p.free = (typeof b.free === 'boolean') ? b.free : null; } else if (typeof b.model === 'string' && p.model !== prevModel) { p.ctx = null; p.out = null; p.free = null; } _aSetProviders(arr); if (b.makeActive || arr.length === 1) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(p.id); audit(req, 'assistant.provider', p.id, 'сохранён'); res.json({ ok: true, id: p.id }); } /* DELETE /api/admin/assistant/provider/:id */ function deleteProvider(req, res) { let arr = _aProviders(); arr = arr.filter(x => x.id !== req.params.id); _aSetProviders(arr); if (_aset('assistant_active') === req.params.id && arr[0]) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(arr[0].id); audit(req, 'assistant.provider.del', req.params.id, 'удалён'); res.json({ ok: true }); } /* Запрос списка моделей провайдера с лимитами. Понимает OpenAI-совместимый /models * (context_length + max_completion_tokens + pricing) и нативный Google generativelanguage * (inputTokenLimit / outputTokenLimit). */ async function _fetchModels(url, key) { if (typeof fetch !== 'function') return { error: 'fetch недоступен' }; url = String(url || ''); const isGoogle = /generativelanguage\.googleapis\.com/.test(url); let ep; const headers = {}; if (isGoogle) { const base = url.replace(/\/openai\/chat\/completions.*$/, '').replace(/\/chat\/completions.*$/, ''); ep = base + '/models?pageSize=200' + (key ? '&key=' + encodeURIComponent(key) : ''); } else { ep = url.replace(/\/chat\/completions.*$/, '/models'); if (key) headers.Authorization = 'Bearer ' + key; } const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000); try { const r = await fetch(ep, { headers, signal: ctrl.signal }); if (!r.ok) return { error: 'HTTP ' + r.status, status: r.status }; const j = await r.json(); const raw = j.data || j.models || []; const models = []; for (const m of raw) { const methods = m.supportedGenerationMethods; if (methods && methods.indexOf('generateContent') === -1) continue; // Google: только генеративные const id = String(m.id || m.name || '').replace(/^models\//, ''); if (!id) continue; const tp = m.top_provider || {}; const pr = m.pricing || {}; const ctx = m.context_length || tp.context_length || m.inputTokenLimit || null; const out = tp.max_completion_tokens || m.outputTokenLimit || null; let free = null; if (pr && (pr.prompt != null || pr.completion != null)) free = Number(pr.prompt) === 0 && Number(pr.completion) === 0; models.push({ id, ctx: ctx || null, out: out || null, free }); } return { models }; } catch (e) { return { error: e.name === 'AbortError' ? 'timeout' : 'network' }; } finally { clearTimeout(timer); } } /* GET /api/admin/assistant/models?id=&url=&key= — модели провайдера с лимитами. * С id: берём сохранённого провайдера и кэшируем лимиты его текущей модели. */ async function getProviderModels(req, res) { const id = req.query.id ? String(req.query.id) : ''; let url = req.query.url, key = req.query.key, prov = null; if (id) { prov = _aProviders().find(x => x.id === id); if (!prov) return res.json({ error: 'провайдер не найден' }); url = prov.url; key = prov.key; } const r = await _fetchModels(url, key); if (r.error) return res.json({ error: r.error, status: r.status }); let current = null; if (prov) { const arr = _aProviders(); const p2 = arr.find(x => x.id === id); const m = p2 && r.models.find(x => x.id === p2.model); if (p2 && m) { p2.ctx = m.ctx; p2.out = m.out; p2.free = m.free; _aSetProviders(arr); current = { ctx: m.ctx, out: m.out, free: m.free }; } } res.json({ models: r.models, current }); } /* POST /api/admin/assistant/active { id } — выбрать активного провайдера */ function setActiveProvider(req, res) { const id = String((req.body && req.body.id) || ''); db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(id); try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} // смена активного снимает старый флаг audit(req, 'assistant.active', id, 'активный провайдер'); res.json({ ok: true }); } /* POST /api/admin/assistant/reindex — переиндексировать учебники для RAG */ function reindexTextbooks(req, res) { try { const { reindex } = require('../../scripts/index-textbooks'); const r = reindex(); audit(req, 'assistant.reindex', 'assistant', `chunks:${r.chunks || 0}`); res.json(r); } catch (e) { res.status(500).json({ error: e.message || 'reindex failed' }); } } async function testAssistant(req, res) { const a = require('./assistantController'); const b = req.body || {}; let override; if (b.id) { const p = _aProviders().find(x => x.id === b.id); if (!p) return res.json({ ok: false, error: 'провайдер не найден' }); // если ключ пуст (не вводили) — берём сохранённый у этого провайдера override = { url: (b.url && b.url.trim()) || p.url, model: (b.model && b.model.trim()) || p.model, key: (b.key && b.key.trim()) || p.key }; } else { const cfg = a.llmConfig(); override = { url: (typeof b.url === 'string' && b.url.trim()) ? b.url.trim() : cfg.url, model: (typeof b.model === 'string' && b.model.trim()) ? b.model.trim() : cfg.model, key: (typeof b.key === 'string' && b.key.trim()) ? b.key.trim() : cfg.key, }; } override.local = _aIsLocal(override.url); override.on = !!(override.key || override.local); const r = await a.pingLLM(override); // Успешный тест активного провайдера снимает устаревший флаг failover try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {} res.json(r); } /* ── Генерация картинок (Cloudflare Workers AI) ──────────────────────────── */ const IMGGEN_MODELS = [ { id: '@cf/black-forest-labs/flux-1-schnell', label: 'FLUX.1 schnell — рекомендуется, быстрый' }, { id: '@cf/stabilityai/stable-diffusion-xl-base-1.0', label: 'Stable Diffusion XL' }, { id: '@cf/bytedance/stable-diffusion-xl-lightning', label: 'SDXL Lightning — очень быстрый' }, { id: '@cf/lykon/dreamshaper-8-lcm', label: 'DreamShaper 8 LCM' }, ]; function _imgCfg() { try { return JSON.parse(_aset('imggen_provider') || '{}') || {}; } catch (e) { return {}; } } function getImggen(_req, res) { const c = _imgCfg(); let stats = { count: 0, bytes: 0 }; try { stats = require('./imggenController').stats(); } catch (e) {} const cd = Number(c.cooldownMs), dc = Number(c.dailyCap); const configured = !!(c.provider === 'cloudflare' && c.accountId && c.token); const on = c.on !== false; res.json({ provider: c.provider || 'cloudflare', accountId: c.accountId || '', model: c.model || '@cf/black-forest-labs/flux-1-schnell', hasToken: !!c.token, cooldownMs: Number.isFinite(cd) && cd >= 0 ? cd : 4000, dailyCap: Number.isFinite(dc) && dc >= 0 ? dc : 40, on, configured, enabled: configured && on, models: IMGGEN_MODELS, stats, }); } function saveImggen(req, res) { const b = req.body || {}; const c = _imgCfg(); c.provider = b.provider || c.provider || 'cloudflare'; if (typeof b.on === 'boolean') c.on = b.on; if (b.accountId !== undefined) c.accountId = String(b.accountId || '').trim(); if (b.model !== undefined) c.model = String(b.model || '').trim(); if (b.clearToken) c.token = ''; else if (b.token && b.token !== '••••••••') c.token = String(b.token).trim(); if (b.cooldownMs !== undefined) { const n = Number(b.cooldownMs); if (Number.isFinite(n) && n >= 0) c.cooldownMs = n; } if (b.dailyCap !== undefined) { const n = Number(b.dailyCap); if (Number.isFinite(n) && n >= 0) c.dailyCap = n; } db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('imggen_provider', ?)").run(JSON.stringify(c)); audit(req, 'imggen.config', 'imggen', 'настройки генерации картинок'); res.json({ ok: true }); } async function testImggen(req, res) { const prompt = (String((req.body && req.body.prompt) || '').trim()) || 'a cute friendly mascot, flat illustration, warm tones'; try { const out = await require('./imggenController').generateImage(prompt, 0); res.json({ ok: true, url: out.url, prompt: out.prompt }); } catch (e) { res.status(502).json({ ok: false, error: e.message || 'Ошибка', detail: e.detail }); } } module.exports = { getStats, getOverview, globalSearch, getImggen, saveImggen, testImggen, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, clearUserSessions, deleteSession, updateUser, banUser, deleteUser, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, getResetPlan, resetSystem, getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics, getSecurityLog, clearSecurityLog, getTopics, createTopic, updateTopic, deleteTopic, broadcast, getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels, };