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>
198 lines
9.3 KiB
JavaScript
198 lines
9.3 KiB
JavaScript
const db = require('../db/db');
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════
|
|
Shop — Items, Purchases, Coins
|
|
═══════════════════════════════════════════════════════════════════════ */
|
|
|
|
/* GET /api/shop/items — list all active shop items + owned status */
|
|
function getItems(req, res) {
|
|
const userId = req.user.id;
|
|
const items = db.prepare(`
|
|
SELECT si.*,
|
|
(SELECT 1 FROM user_purchases up WHERE up.item_id = si.id AND up.user_id = ?) AS owned
|
|
FROM shop_items si
|
|
WHERE si.is_active = 1
|
|
ORDER BY si.price
|
|
`).all(userId);
|
|
|
|
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
|
|
res.json({ items, coins: (user && user.coins) || 0 });
|
|
}
|
|
|
|
/* POST /api/shop/items/:id/purchase — buy an item (atomic transaction) */
|
|
function purchaseItem(req, res) {
|
|
const userId = req.user.id;
|
|
const itemId = Number(req.params.id);
|
|
|
|
const item = db.prepare('SELECT * FROM shop_items WHERE id = ? AND is_active = 1').get(itemId);
|
|
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
|
|
|
|
const alreadyOwned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
|
|
if (alreadyOwned) return res.status(400).json({ error: 'Вы уже купили этот предмет' });
|
|
|
|
// Atomic: conditional UPDATE (race-safe even under concurrent purchases)
|
|
const doPurchase = db.transaction(() => {
|
|
const upd = db.prepare(
|
|
'UPDATE users SET coins = coins - ? WHERE id = ? AND coins >= ?'
|
|
).run(item.price, userId, item.price);
|
|
if (upd.changes === 0) return { err: 'Недостаточно монет' };
|
|
db.prepare('INSERT INTO user_purchases (user_id, item_id) VALUES (?, ?)').run(userId, itemId);
|
|
const updated = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
|
|
return { coins: (updated && updated.coins) || 0 };
|
|
});
|
|
|
|
const result = doPurchase();
|
|
if (result.err) return res.status(400).json({ error: result.err });
|
|
res.json({ ok: true, coins: result.coins, item });
|
|
}
|
|
|
|
/* GET /api/shop/purchases — list user's purchases with item details */
|
|
function getPurchases(req, res) {
|
|
const rows = db.prepare(`
|
|
SELECT up.id AS purchase_id, up.purchased_at, si.*
|
|
FROM user_purchases up
|
|
JOIN shop_items si ON si.id = up.item_id
|
|
WHERE up.user_id = ?
|
|
ORDER BY up.purchased_at DESC
|
|
`).all(req.user.id);
|
|
res.json(rows);
|
|
}
|
|
|
|
/* GET /api/shop/coins — return user's coin balance */
|
|
function getCoins(req, res) {
|
|
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(req.user.id);
|
|
res.json({ coins: (user && user.coins) || 0 });
|
|
}
|
|
|
|
/* GET /api/shop/my-active — return user's active cosmetics */
|
|
function getMyActive(req, res) {
|
|
const u = db.prepare('SELECT avatar_frame, active_title, active_effect FROM users WHERE id = ?').get(req.user.id);
|
|
if (!u) return res.json({});
|
|
// Resolve full data for each active item
|
|
const result = { frame: null, title: null, effect: null };
|
|
|
|
// Frame from avatar_frame (gamification frames) — handled separately
|
|
// Shop frame override
|
|
if (u.avatar_frame && u.avatar_frame !== 'default') {
|
|
result.frame = { id: u.avatar_frame };
|
|
}
|
|
|
|
if (u.active_title) {
|
|
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_title);
|
|
if (item) try { result.title = JSON.parse(item.data); } catch {}
|
|
}
|
|
if (u.active_effect) {
|
|
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_effect);
|
|
if (item) try { result.effect = JSON.parse(item.data); } catch {}
|
|
}
|
|
res.json(result);
|
|
}
|
|
|
|
/* POST /api/shop/activate — activate a purchased item (or deactivate with itemId=null) */
|
|
function activateItem(req, res) {
|
|
const userId = req.user.id;
|
|
const { itemId } = req.body;
|
|
|
|
// Deactivate: pass itemId = null and type
|
|
if (!itemId) {
|
|
const { type } = req.body;
|
|
if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId);
|
|
if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId);
|
|
if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId);
|
|
return res.json({ ok: true });
|
|
}
|
|
|
|
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(itemId);
|
|
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
|
|
|
|
const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
|
|
if (!owned) return res.status(403).json({ error: 'Предмет не куплен' });
|
|
|
|
let data;
|
|
try { data = JSON.parse(item.data); } catch { data = {}; }
|
|
|
|
if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId);
|
|
if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId);
|
|
if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId);
|
|
|
|
res.json({ ok: true, type: item.type, data });
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════
|
|
Admin — CRUD shop items, award coins, stats
|
|
═══════════════════════════════════════════════════════════════════════ */
|
|
|
|
/* GET /api/shop/admin/items — all items (including inactive) */
|
|
function adminGetItems(_req, res) {
|
|
const items = db.prepare(`
|
|
SELECT si.*,
|
|
(SELECT COUNT(*) FROM user_purchases up WHERE up.item_id = si.id) AS sold_count
|
|
FROM shop_items si ORDER BY si.id
|
|
`).all();
|
|
res.json(items);
|
|
}
|
|
|
|
/* POST /api/shop/admin/items — create item */
|
|
function adminCreateItem(req, res) {
|
|
const { name, description, type, category, price, data, icon, is_active } = req.body;
|
|
if (!name || !type || price == null) return res.status(400).json({ error: 'name, type, price required' });
|
|
const r = db.prepare(
|
|
'INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) VALUES (?,?,?,?,?,?,?,?)'
|
|
).run(name, description || '', type, category || 'cosmetic', price, data || '{}', icon || 'star', is_active ?? 1);
|
|
res.json({ ok: true, id: r.lastInsertRowid });
|
|
}
|
|
|
|
/* PUT /api/shop/admin/items/:id — update item */
|
|
function adminUpdateItem(req, res) {
|
|
const id = Number(req.params.id);
|
|
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(id);
|
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
|
const { name, description, type, category, price, data, icon, is_active } = req.body;
|
|
db.prepare(`UPDATE shop_items SET
|
|
name=COALESCE(?,name), description=COALESCE(?,description), type=COALESCE(?,type),
|
|
category=COALESCE(?,category), price=COALESCE(?,price), data=COALESCE(?,data),
|
|
icon=COALESCE(?,icon), is_active=COALESCE(?,is_active) WHERE id=?`
|
|
).run(name, description, type, category, price, data, icon, is_active, id);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* DELETE /api/shop/admin/items/:id — delete item */
|
|
function adminDeleteItem(req, res) {
|
|
const id = Number(req.params.id);
|
|
db.prepare('DELETE FROM user_purchases WHERE item_id = ?').run(id);
|
|
db.prepare('DELETE FROM shop_items WHERE id = ?').run(id);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* POST /api/shop/admin/award-coins — award coins to user */
|
|
const SHOP_AWARD_MAX = 1_000_000;
|
|
function adminAwardCoins(req, res) {
|
|
const { userId, amount, reason } = req.body;
|
|
const amt = Number(amount);
|
|
if (!userId || !Number.isFinite(amt) || amt <= 0 || amt > SHOP_AWARD_MAX)
|
|
return res.status(400).json({ error: `userId and amount (1..${SHOP_AWARD_MAX}) required` });
|
|
const result = db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?').run(amt, userId);
|
|
if (result.changes === 0) return res.status(404).json({ error: 'User not found' });
|
|
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
|
|
res.json({ ok: true, coins: user?.coins || 0 });
|
|
}
|
|
|
|
/* GET /api/shop/admin/stats — shop stats */
|
|
function adminShopStats(_req, res) {
|
|
const totalItems = db.prepare('SELECT COUNT(*) as c FROM shop_items').get().c;
|
|
const activeItems = db.prepare('SELECT COUNT(*) as c FROM shop_items WHERE is_active=1').get().c;
|
|
const totalPurchases = db.prepare('SELECT COUNT(*) as c FROM user_purchases').get().c;
|
|
const totalCoinsInCirculation = db.prepare('SELECT COALESCE(SUM(coins),0) as c FROM users').get().c;
|
|
const topItems = db.prepare(`
|
|
SELECT si.name, si.price, COUNT(up.id) as sold
|
|
FROM shop_items si LEFT JOIN user_purchases up ON up.item_id = si.id
|
|
GROUP BY si.id ORDER BY sold DESC LIMIT 5
|
|
`).all();
|
|
res.json({ totalItems, activeItems, totalPurchases, totalCoinsInCirculation, topItems });
|
|
}
|
|
|
|
module.exports = {
|
|
getItems, purchaseItem, getPurchases, getCoins, getMyActive, activateItem,
|
|
adminGetItems, adminCreateItem, adminUpdateItem, adminDeleteItem, adminAwardCoins, adminShopStats
|
|
};
|