952a54f97c
## P0 - admin.html:2608, red-book-ecosystem.html:489-495 — XSS: u.name/node.name_ru/description обернуты в LS.esc() - classController.js getAnnouncements — добавлена проверка teacher_id (B14: учитель A не может читать объявления класса B) ## P1 — auth & validation - authController.js — минимум пароля 6→8 символов (register + change password + login.html) - gamificationController adminAward — валидация max XP/coins (1M), Number coercion - shopController adminAwardCoins — валидация max + проверка changes>0 ## P1 — race conditions - petController.buyBg — atomic UPDATE WHERE coins>=? (race-safe) - shopController.purchaseItem — atomic conditional UPDATE - liveController — добавлен question_id в live_answers (миграция с пересозданием таблицы), история ответов сохраняется при смене вопроса учителем - ws-server: invalidateDrawCache экспортирован, classroomController grant/revoke вызывают его → permission revoke применяется мгновенно (раньше до 10s stale) ## P1 — rate limits & retry - rateLimit middleware: новый параметр byUser=true (использует req.user.id вместо IP — не блокирует пользователей за NAT) - routes/classroom.js: reactionLimiter (15/5s) на /chat/:msgId/react, handLimiter (5/5s) на raise/lower hand - api.js sendAnswer — retry 3x с exp backoff (300/1200/2700ms), не повторяет на 4xx (F5) ## P1 — performance - classroomController.getStrokes — LIMIT 5000 + флаг hasMore (защита от OOM на 10K+ strokes) - whiteboard.js _liveStrokes — TTL 1.5s на каждый live preview (auto-cleanup при крашe ремоут юзера) ## Infrastructure - config.js: TURN_URL/USER/PASS env vars - server.js: GET /api/ice-servers возвращает STUN + опциональный TURN из env - classroom-rtc.js: фетчит /api/ice-servers вместо хардкода (поддержка TURN для NAT/CGNAT школьных сетей) - .env.example: документация TURN - db.js: PRAGMA synchronous=NORMAL (5x быстрее с WAL), cache_size 16MB, temp_store=MEMORY - ws-server.js closeAll() + server.js shutdown — graceful WS shutdown при SIGTERM ## False positives (не баги, агенты ошиблись) - assignmentController FK на tests — на самом деле users (migrate.js:317-318) - .env в git — gitignore корректно исключает - admin.html без requireAuth — есть LS.initPage() который вызывает requireAuth - submissionsController IDOR — обе ручки уже проверяют teacher_id - screenSender = null inside try/catch — на самом деле снаружи - SSE без backoff — есть exponential 2s→30s - sessionController NOT IN на пустом массиве — есть guard usedIds.length>0 - getChat без LIMIT — есть LIMIT 100/200 - trust proxy — установлен на server.js:105 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
298 lines
14 KiB
JavaScript
298 lines
14 KiB
JavaScript
'use strict';
|
|
const db = require('../db/db');
|
|
const { awardCoins } = require('./gamificationController');
|
|
|
|
const BG_SHOP = [
|
|
{ id:'space', name:'Космос', price:50, desc:'Звёздный простор' },
|
|
{ id:'forest', name:'Лес', price:50, desc:'Таинственный лес' },
|
|
{ id:'aqua', name:'Океан', price:75, desc:'Морская глубина' },
|
|
{ id:'sunset', name:'Закат', price:75, desc:'Пурпурный закат' },
|
|
];
|
|
|
|
function _parseOwned(raw) {
|
|
try { return JSON.parse(raw || '[]'); } catch { return []; }
|
|
}
|
|
|
|
function _petLevel(xp) {
|
|
if (xp >= 80000) return 8;
|
|
if (xp >= 40000) return 7;
|
|
if (xp >= 20000) return 6;
|
|
if (xp >= 10000) return 5;
|
|
if (xp >= 5000) return 4;
|
|
if (xp >= 2000) return 3;
|
|
if (xp >= 500) return 2;
|
|
return 1;
|
|
}
|
|
|
|
function _mood(streak, daysSinceLogin) {
|
|
if (daysSinceLogin >= 14) return 'sleeping';
|
|
if (daysSinceLogin >= 7) return 'hungry';
|
|
if (daysSinceLogin >= 3) return 'sad';
|
|
if (streak >= 7) return 'ecstatic';
|
|
if (streak >= 3) return 'happy';
|
|
if (streak >= 1) return 'neutral';
|
|
return 'sad';
|
|
}
|
|
|
|
function _accessories(user) {
|
|
const acc = [];
|
|
if (user.streak_best >= 7) acc.push('hat');
|
|
if (user.level >= 5) acc.push('glasses');
|
|
if (user.xp >= 5000) acc.push('crown');
|
|
if (user.level >= 10) acc.push('star');
|
|
return acc;
|
|
}
|
|
|
|
function _quests(userId, streakCurrent) {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const xpToday = db.prepare(
|
|
"SELECT COALESCE(SUM(amount),0) AS total FROM xp_log WHERE user_id=? AND date(created_at)=date(?)"
|
|
).get(userId, today)?.total || 0;
|
|
const testsToday = db.prepare(
|
|
"SELECT COUNT(*) AS cnt FROM test_sessions WHERE user_id=? AND status='completed' AND date(finished_at)=date(?)"
|
|
).get(userId, today)?.cnt || 0;
|
|
return [
|
|
{ id:'xp30', icon:'⭐', label:'Набери 30 XP сегодня', done: xpToday >= 30, progress: Math.min(xpToday, 30), goal: 30 },
|
|
{ id:'test1', icon:'📝', label:'Пройди 1 тест', done: testsToday >= 1, progress: Math.min(testsToday, 1), goal: 1 },
|
|
{ id:'streak2', icon:'🔥', label:'Серия 2+ дней', done: streakCurrent >= 2 },
|
|
];
|
|
}
|
|
|
|
function _moodForecast(daysSince) {
|
|
if (daysSince >= 7) return null; // already hungry/sleeping
|
|
if (daysSince >= 3) return { mood: 'hungry', inDays: Math.max(0, 7 - daysSince) };
|
|
const toSad = Math.max(0, 3 - daysSince);
|
|
if (toSad <= 2) return { mood: 'sad', inDays: toSad };
|
|
return null;
|
|
}
|
|
|
|
/* ── GET /api/pet ─────────────────────────────────────────────────────── */
|
|
function getPet(req, res) {
|
|
const user = db.prepare(
|
|
`SELECT xp, level, streak_current, streak_best, streak_date, coins,
|
|
pet_name, last_login, pet_color, pet_last_petted, pet_petting_streak,
|
|
pet_bg, pet_bg_owned, pet_last_fed
|
|
FROM users WHERE id = ?`
|
|
).get(req.user.id);
|
|
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
const now = new Date();
|
|
|
|
const lastXpRow = db.prepare("SELECT MAX(created_at) AS t FROM xp_log WHERE user_id = ?").get(req.user.id);
|
|
const lastSessRow = db.prepare("SELECT MAX(finished_at) AS t FROM test_sessions WHERE user_id = ? AND status = 'completed'").get(req.user.id);
|
|
const candidates = [
|
|
user.last_login ? new Date(user.last_login) : null,
|
|
lastXpRow?.t ? new Date(lastXpRow.t) : null,
|
|
lastSessRow?.t ? new Date(lastSessRow.t) : null,
|
|
].filter(Boolean);
|
|
const lastActive = candidates.length ? new Date(Math.max(...candidates.map(d => d.getTime()))) : now;
|
|
const daysSince = Math.max(0, Math.floor((now - lastActive) / 86400000));
|
|
|
|
const since7 = new Date(now - 7 * 86400000).toISOString();
|
|
const recentXP = db.prepare(
|
|
'SELECT COALESCE(SUM(amount),0) AS total FROM xp_log WHERE user_id = ? AND created_at >= ?'
|
|
).get(req.user.id, since7)?.total || 0;
|
|
|
|
const petLvl = _petLevel(user.xp);
|
|
const mood = _mood(user.streak_current, daysSince);
|
|
const accessories = _accessories(user);
|
|
|
|
const thresholds = [0, 500, 2000, 5000, 10000, 20000, 40000, 80000, Infinity];
|
|
const xpForNext = thresholds[petLvl] ?? Infinity;
|
|
const xpForCurr = thresholds[petLvl - 1] ?? 0;
|
|
|
|
const d0 = new Date(now); d0.setDate(d0.getDate() - 6);
|
|
const weekStart = d0.toISOString().slice(0, 10);
|
|
const weekRows = db.prepare(
|
|
"SELECT date(created_at) AS d, COALESCE(SUM(amount),0) AS xp FROM xp_log WHERE user_id = ? AND date(created_at) >= date(?) GROUP BY date(created_at)"
|
|
).all(req.user.id, weekStart);
|
|
const weekMap = new Map(weekRows.map(r => [r.d, r.xp]));
|
|
const weeklyXP = [];
|
|
for (let i = 6; i >= 0; i--) {
|
|
const d = new Date(now); d.setDate(d.getDate() - i);
|
|
const dateStr = d.toISOString().slice(0, 10);
|
|
weeklyXP.push({ day: d.toLocaleDateString('ru', { weekday: 'short' }), xp: weekMap.get(dateStr) || 0 });
|
|
}
|
|
|
|
const SOURCE_LABELS = {
|
|
hangman_win: 'Виселица', crossword_win: 'Кроссворд',
|
|
test_answer: 'Тест', challenge: 'Задание',
|
|
lab_activity: 'Лаборатория', assignment: 'Домашнее задание',
|
|
pet_petting: 'Поглаживание', pet_feeding: 'Кормёжка питомца',
|
|
mood_ecstatic: 'Бонус настроения', correct_answers: 'Тест',
|
|
test_complete: 'Тест', 'test_90+': 'Тест 90%+', test_perfect: 'Тест 100%',
|
|
};
|
|
const rawActivity = db.prepare(
|
|
"SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 6"
|
|
).all(req.user.id);
|
|
const recentActivity = rawActivity.map(r => ({
|
|
xp: r.amount, label: SOURCE_LABELS[r.reason] || r.reason, at: r.created_at,
|
|
}));
|
|
|
|
const pettingCooldown = user.pet_last_petted
|
|
? Math.max(0, 60 - Math.floor((now - new Date(user.pet_last_petted)) / 1000))
|
|
: 0;
|
|
const feedCooldown = user.pet_last_fed
|
|
? Math.max(0, 1800 - Math.floor((now - new Date(user.pet_last_fed)) / 1000))
|
|
: 0;
|
|
|
|
res.json({
|
|
petName: user.pet_name || 'Квантик',
|
|
petLevel: petLvl,
|
|
petColor: user.pet_color || 'purple',
|
|
mood,
|
|
daysSinceLogin: daysSince,
|
|
accessories,
|
|
xp: user.xp,
|
|
level: user.level,
|
|
streakCurrent: user.streak_current,
|
|
streakBest: user.streak_best,
|
|
coins: user.coins,
|
|
pettingStreak: user.pet_petting_streak || 0,
|
|
recentXP,
|
|
weeklyXP,
|
|
recentActivity,
|
|
xpForCurrLevel: xpForCurr,
|
|
xpForNextLevel: xpForNext === Infinity ? null : xpForNext,
|
|
quests: _quests(req.user.id, user.streak_current),
|
|
moodForecast: _moodForecast(daysSince),
|
|
pettingCooldown,
|
|
feedCooldown,
|
|
petBg: user.pet_bg || 'default',
|
|
petBgOwned: _parseOwned(user.pet_bg_owned),
|
|
});
|
|
}
|
|
|
|
/* ── POST /api/pet/pet ────────────────────────────────────────────────── */
|
|
function petAction(req, res) {
|
|
const user = db.prepare('SELECT pet_last_petted, pet_petting_streak FROM users WHERE id=?').get(req.user.id);
|
|
if (!user) return res.status(404).json({ error: 'not found' });
|
|
|
|
const now = new Date();
|
|
if (user.pet_last_petted) {
|
|
const diff = (now - new Date(user.pet_last_petted)) / 1000;
|
|
if (diff < 60) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(60 - diff) });
|
|
}
|
|
|
|
const today = now.toISOString().slice(0, 10);
|
|
const yesterday = new Date(now - 86400000).toISOString().slice(0, 10);
|
|
let streak = user.pet_petting_streak || 0;
|
|
if (user.pet_last_petted) {
|
|
const lastDate = user.pet_last_petted.slice(0, 10);
|
|
if (lastDate === today) { /* same day, keep */ }
|
|
else if (lastDate === yesterday) streak++;
|
|
else streak = 1;
|
|
} else {
|
|
streak = 1;
|
|
}
|
|
|
|
try { awardCoins(req.user.id, 2, 'pet_petting'); } catch {}
|
|
db.prepare('UPDATE users SET pet_last_petted=?, pet_petting_streak=? WHERE id=?')
|
|
.run(now.toISOString(), streak, req.user.id);
|
|
|
|
res.json({ ok: true, coins: 2, pettingStreak: streak });
|
|
}
|
|
|
|
/* ── PATCH /api/pet/name ──────────────────────────────────────────────── */
|
|
function renamePet(req, res) {
|
|
let { name } = req.body;
|
|
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
|
|
name = name.trim().slice(0, 24);
|
|
if (!name) return res.status(400).json({ error: 'name required' });
|
|
db.prepare('UPDATE users SET pet_name = ? WHERE id = ?').run(name, req.user.id);
|
|
res.json({ ok: true, name });
|
|
}
|
|
|
|
/* ── PATCH /api/pet/color ─────────────────────────────────────────────── */
|
|
function updateColor(req, res) {
|
|
const { color } = req.body;
|
|
const VALID = ['purple', 'cyan', 'gold', 'red', 'green', 'blue'];
|
|
if (!VALID.includes(color)) return res.status(400).json({ error: 'invalid color' });
|
|
db.prepare('UPDATE users SET pet_color = ? WHERE id = ?').run(color, req.user.id);
|
|
res.json({ ok: true, color });
|
|
}
|
|
|
|
/* ── GET /api/pet/shop ────────────────────────────────────────────────── */
|
|
function getShop(req, res) {
|
|
const user = db.prepare('SELECT coins, pet_bg, pet_bg_owned FROM users WHERE id=?').get(req.user.id);
|
|
if (!user) return res.status(404).json({ error: 'not found' });
|
|
const owned = _parseOwned(user.pet_bg_owned);
|
|
res.json({
|
|
coins: user.coins || 0,
|
|
currentBg: user.pet_bg || 'default',
|
|
items: BG_SHOP.map(item => ({ ...item, owned: owned.includes(item.id) })),
|
|
});
|
|
}
|
|
|
|
/* ── POST /api/pet/shop/buy ───────────────────────────────────────────── */
|
|
function buyBg(req, res) {
|
|
const { id } = req.body;
|
|
const item = BG_SHOP.find(b => b.id === id);
|
|
if (!item) return res.status(400).json({ error: 'invalid item' });
|
|
const user = db.prepare('SELECT coins, pet_bg_owned FROM users WHERE id=?').get(req.user.id);
|
|
if (!user) return res.status(404).json({ error: 'not found' });
|
|
const owned = _parseOwned(user.pet_bg_owned);
|
|
if (!owned.includes(id)) {
|
|
// Atomic conditional UPDATE — race-safe even under concurrent purchase attempts
|
|
const result = db.prepare(
|
|
'UPDATE users SET coins = coins - ?, pet_bg_owned = ?, pet_bg = ? WHERE id = ? AND coins >= ?'
|
|
).run(item.price, JSON.stringify([...owned, id]), id, req.user.id, item.price);
|
|
if (result.changes === 0) return res.status(400).json({ error: 'insufficient_coins' });
|
|
} else {
|
|
db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id);
|
|
}
|
|
const updated = db.prepare('SELECT coins FROM users WHERE id=?').get(req.user.id);
|
|
res.json({ ok: true, bg: id, coins: updated.coins });
|
|
}
|
|
|
|
/* ── PATCH /api/pet/bg ────────────────────────────────────────────────── */
|
|
function setBg(req, res) {
|
|
const { id } = req.body;
|
|
if (id !== 'default') {
|
|
const owned = _parseOwned(db.prepare('SELECT pet_bg_owned FROM users WHERE id=?').get(req.user.id)?.pet_bg_owned);
|
|
if (!owned.includes(id)) return res.status(403).json({ error: 'not owned' });
|
|
}
|
|
db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id);
|
|
res.json({ ok: true, bg: id });
|
|
}
|
|
|
|
/* ── POST /api/pet/star ───────────────────────────────────────────────── */
|
|
function starCatch(req, res) {
|
|
const user = db.prepare('SELECT pet_last_star FROM users WHERE id=?').get(req.user.id);
|
|
if (!user) return res.status(404).json({ error: 'not found' });
|
|
const now = new Date();
|
|
if (user.pet_last_star) {
|
|
const diff = (now - new Date(user.pet_last_star)) / 1000;
|
|
if (diff < 3600) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(3600 - diff) });
|
|
}
|
|
try { awardCoins(req.user.id, 5, 'star_catch'); } catch {}
|
|
db.prepare('UPDATE users SET pet_last_star=? WHERE id=?').run(now.toISOString(), req.user.id);
|
|
res.json({ ok: true, coins: 5 });
|
|
}
|
|
|
|
/* ── POST /api/pet/feed ───────────────────────────────────────────────── */
|
|
// Called when mini-game "feed pet" is correctly answered on frontend.
|
|
// 30-minute cooldown; awards 15 XP + marks pet as fed.
|
|
function feedPet(req, res) {
|
|
const user = db.prepare('SELECT pet_last_fed FROM users WHERE id=?').get(req.user.id);
|
|
if (!user) return res.status(404).json({ error: 'not found' });
|
|
const now = new Date();
|
|
const COOLDOWN_SEC = 1800; // 30 minutes
|
|
if (user.pet_last_fed) {
|
|
const diff = (now - new Date(user.pet_last_fed)) / 1000;
|
|
if (diff < COOLDOWN_SEC) {
|
|
return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(COOLDOWN_SEC - diff) });
|
|
}
|
|
}
|
|
try {
|
|
const { awardXP } = require('./gamificationController');
|
|
awardXP(req.user.id, 15, 'pet_feeding');
|
|
} catch (e) { console.error('[feedPet] awardXP:', e.message); }
|
|
db.prepare('UPDATE users SET pet_last_fed=? WHERE id=?').run(now.toISOString(), req.user.id);
|
|
const updated = db.prepare('SELECT xp, coins FROM users WHERE id=?').get(req.user.id);
|
|
res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins });
|
|
}
|
|
|
|
module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, feedPet };
|