Files
Learn_System/backend/src/controllers/redBookController.js
T
Maxim Dolgolyov 3a4623a60a fix: полное ревью системы — 15 исправлений безопасности и надёжности
Безопасность:
- tests/🆔 скрыть is_correct и explanation для студентов (P0)
- SQL injection: limit/offset через placeholder вместо template literal
- Stored XSS: stripTags для lesson comments, flashcards, redBook sightings
- profile.html: escape e.message в showMsg (XSS через server error)
- attachment_url: валидация только /uploads/* путей
- requestId: генерировать UUID сервером, не доверять клиенту
- register: скрыть token_version из ответа

Надёжность:
- register: обработка UNIQUE constraint race condition
- pet buyBg: re-check баланса внутри транзакции
- DB errors: скрыть e.message в testController/questionController/courseController
- preferences: лимит 50KB на размер JSON

UX:
- board.html: debounce 250ms на search input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:59:19 +03:00

346 lines
16 KiB
JavaScript

const db = require('../db/db');
const { awardXP, checkRedBookAchievements } = require('./gamificationController');
const { stripTags } = require('../utils/sanitize');
/* ── helpers ─────────────────────────────────────────────────────────── */
const stmts = {
groups: db.prepare('SELECT * FROM rb_groups ORDER BY name_ru'),
habitats: db.prepare('SELECT * FROM rb_habitats ORDER BY name'),
speciesById: db.prepare(`
SELECT s.*, g.name_ru as group_name, g.icon as group_icon, g.color as group_color,
h.name as habitat_name, h.type as habitat_type
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
WHERE s.id = ?
`),
regionsBySpecies: db.prepare('SELECT region_code FROM rb_species_regions WHERE species_id = ?'),
popdata: db.prepare('SELECT year, count_estimate, source FROM rb_population_data WHERE species_id = ? ORDER BY year'),
foodWebPrey: db.prepare(`
SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category,
g.icon as group_icon, g.color as group_color
FROM rb_food_web fw
JOIN rb_species s ON s.id = fw.prey_id
JOIN rb_groups g ON g.id = s.group_id
WHERE fw.predator_id = ?
`),
foodWebPredators: db.prepare(`
SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category,
g.icon as group_icon, g.color as group_color
FROM rb_food_web fw
JOIN rb_species s ON s.id = fw.predator_id
JOIN rb_groups g ON g.id = s.group_id
WHERE fw.prey_id = ?
`),
collection: db.prepare('SELECT species_id, unlock_method, notes, unlocked_at FROM rb_user_collection WHERE user_id = ?'),
hasCollect: db.prepare('SELECT 1 FROM rb_user_collection WHERE user_id = ? AND species_id = ?'),
addCollect: db.prepare('INSERT OR IGNORE INTO rb_user_collection (user_id, species_id, unlock_method) VALUES (?,?,?)'),
quests: db.prepare('SELECT * FROM rb_quests ORDER BY id'),
userQuests: db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ?'),
startQuest: db.prepare('INSERT OR IGNORE INTO rb_user_quests (user_id, quest_id) VALUES (?,?)'),
questById: db.prepare('SELECT * FROM rb_quests WHERE id = ?'),
userQuestRow:db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ? AND quest_id = ?'),
completeQ: db.prepare("UPDATE rb_user_quests SET status='completed', completed_at=datetime('now') WHERE user_id=? AND quest_id=?"),
sightings: db.prepare(`
SELECT rs.*, u.name as user_name, s.name_ru as species_name
FROM rb_sightings rs
JOIN users u ON u.id = rs.user_id
JOIN rb_species s ON s.id = rs.species_id
ORDER BY rs.created_at DESC LIMIT 50
`),
addSighting: db.prepare('INSERT INTO rb_sightings (user_id, species_id, region_code, description) VALUES (?,?,?,?)'),
mapData: db.prepare(`
SELECT r.region_code, COUNT(r.species_id) as total,
SUM(CASE WHEN s.category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN s.category='EN' THEN 1 ELSE 0 END) as en,
SUM(CASE WHEN s.category='VU' THEN 1 ELSE 0 END) as vu
FROM rb_species_regions r
JOIN rb_species s ON s.id = r.species_id
GROUP BY r.region_code
`),
};
function buildSpeciesList(filter = {}) {
let sql = `
SELECT s.id, s.name_ru, s.name_be, s.name_lat, s.category, s.by_category,
s.photo_url, s.model_type, s.biomass_kg, s.interesting_fact,
g.name_ru as group_name, g.icon as group_icon, g.color as group_color,
h.name as habitat_name, h.type as habitat_type
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
`;
const cond = [], params = [];
if (filter.group_id) { cond.push('s.group_id = ?'); params.push(filter.group_id); }
if (filter.category) { cond.push('s.category = ?'); params.push(filter.category); }
if (filter.habitat_id) { cond.push('s.habitat_id = ?'); params.push(filter.habitat_id); }
if (filter.region) {
sql += ' JOIN rb_species_regions rr ON rr.species_id = s.id';
cond.push('rr.region_code = ?'); params.push(filter.region);
}
if (filter.q) {
cond.push('(s.name_ru LIKE ? OR s.name_lat LIKE ? OR s.name_be LIKE ?)');
const like = `%${filter.q}%`;
params.push(like, like, like);
}
if (cond.length) sql += ' WHERE ' + cond.join(' AND ');
sql += ' ORDER BY s.category, s.name_ru';
const limit = Math.min(parseInt(filter.limit) || 50, 100);
const offset = parseInt(filter.offset) || 0;
sql += ' LIMIT ? OFFSET ?';
params.push(limit, offset);
return db.prepare(sql).all(...params);
}
/* ── GET /api/red-book/groups ───────────────────────────────────────── */
exports.getGroups = (req, res) => {
const groups = stmts.groups.all();
// attach count
const counts = db.prepare(`
SELECT group_id, COUNT(*) as n,
SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en
FROM rb_species GROUP BY group_id
`).all();
const countMap = {};
counts.forEach(c => countMap[c.group_id] = { n: c.n, cr: c.cr, en: c.en });
groups.forEach(g => Object.assign(g, countMap[g.id] || { n: 0, cr: 0, en: 0 }));
res.json(groups);
};
/* ── GET /api/red-book/habitats ─────────────────────────────────────── */
exports.getHabitats = (req, res) => {
res.json(stmts.habitats.all());
};
/* ── GET /api/red-book/species ──────────────────────────────────────── */
exports.getSpecies = (req, res) => {
const list = buildSpeciesList(req.query);
// total for pagination
const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n;
// user collection ids
let collected = new Set();
if (req.user) {
stmts.collection.all(req.user.id).forEach(r => collected.add(r.species_id));
}
list.forEach(s => {
try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; }
s.collected = collected.has(s.id);
});
res.json({ total, species: list });
};
/* ── GET /api/red-book/species/:id ─────────────────────────────────── */
exports.getSpeciesById = (req, res) => {
const s = stmts.speciesById.get(req.params.id);
if (!s) return res.status(404).json({ error: 'Вид не найден' });
s.regions = stmts.regionsBySpecies.all(s.id).map(r => r.region_code);
s.population_data = stmts.popdata.all(s.id);
s.prey = stmts.foodWebPrey.all(s.id);
s.predators = stmts.foodWebPredators.all(s.id);
try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; }
try { s.population_trend = JSON.parse(s.population_trend || '[]'); } catch { s.population_trend = []; }
if (req.user) {
s.collected = !!stmts.hasCollect.get(req.user.id, s.id);
}
res.json(s);
};
/* ── GET /api/red-book/map-data ─────────────────────────────────────── */
exports.getMapData = (req, res) => {
res.json(stmts.mapData.all());
};
/* ── GET /api/red-book/food-web ─────────────────────────────────────── */
exports.getFoodWeb = (req, res) => {
const ids = (req.query.species_ids || '').split(',').map(Number).filter(Boolean);
if (!ids.length) {
// return full web
const nodes = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description,
g.icon, g.color, g.id as group_id,
h.id as habitat_id, h.type as habitat_type, h.name as habitat_name
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
`).all();
const links = db.prepare('SELECT predator_id as source, prey_id as target, strength FROM rb_food_web').all();
return res.json({ nodes, links });
}
const ph = ids.map(() => '?').join(',');
const nodes = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.id IN (${ph})
`).all(...ids);
const links = db.prepare(`
SELECT predator_id as source, prey_id as target, strength FROM rb_food_web
WHERE predator_id IN (${ph}) OR prey_id IN (${ph})
`).all(...ids, ...ids);
res.json({ nodes, links });
};
/* ── GET /api/red-book/biome/:habitatId ─────────────────────────────── */
exports.getBiomeSpecies = (req, res) => {
const list = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url, s.model_type,
s.biomass_kg, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.habitat_id = ?
ORDER BY s.category, s.name_ru
`).all(req.params.habitatId);
res.json(list);
};
/* ── POST /api/red-book/species/:id/collect ─────────────────────────── */
exports.collectSpecies = (req, res) => {
const userId = req.user.id;
const speciesId = parseInt(req.params.id);
const sp = stmts.speciesById.get(speciesId);
if (!sp) return res.status(404).json({ error: 'Вид не найден' });
const already = stmts.hasCollect.get(userId, speciesId);
if (already) return res.json({ already: true, message: 'Вид уже в коллекции' });
stmts.addCollect.run(userId, speciesId, req.body?.method || 'explore');
// Award XP (via gamification service — also updates level in DB)
const xpMap = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
const xp = xpMap[sp.category] || 20;
try { awardXP(userId, xp, `Открыт вид: ${sp.name_ru}`); } catch {}
// Auto-complete any active quests that required this species
const completedQuests = [];
try {
const activeQuests = db.prepare(`
SELECT q.*, uq.quest_id FROM rb_user_quests uq
JOIN rb_quests q ON q.id = uq.quest_id
WHERE uq.user_id = ? AND uq.status = 'active'
`).all(userId);
// Get all collected species ids for this user
const collectedIds = new Set(
db.prepare('SELECT species_id FROM rb_user_collection WHERE user_id = ?').all(userId).map(r => r.species_id)
);
for (const quest of activeQuests) {
const requiredIds = JSON.parse(quest.species_ids || '[]');
if (requiredIds.every(id => collectedIds.has(id))) {
stmts.completeQ.run(userId, quest.id);
// Award quest XP via gamification service
try { awardXP(userId, quest.xp_reward, `Квест выполнен: ${quest.title}`); } catch {}
completedQuests.push({ id: quest.id, title: quest.title, xp_reward: quest.xp_reward });
}
}
} catch {}
// Check Red Book achievements (non-blocking)
try { checkRedBookAchievements(userId); } catch {}
res.json({ collected: true, xp_earned: xp, species: { id: sp.id, name_ru: sp.name_ru, category: sp.category }, completed_quests: completedQuests });
};
/* ── GET /api/red-book/collection ───────────────────────────────────── */
exports.getCollection = (req, res) => {
const rows = stmts.collection.all(req.user.id);
const ids = rows.map(r => r.species_id);
if (!ids.length) return res.json({ total: 0, species: [] });
const ph = ids.map(() => '?').join(',');
const species = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url,
g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.id IN (${ph})
`).all(...ids);
const meta = {};
rows.forEach(r => meta[r.species_id] = { method: r.unlock_method, notes: r.notes, at: r.unlocked_at });
species.forEach(s => Object.assign(s, meta[s.id] || {}));
res.json({ total: species.length, species });
};
/* ── GET /api/red-book/quests ───────────────────────────────────────── */
exports.getQuests = (req, res) => {
const quests = stmts.quests.all();
quests.forEach(q => q.species_ids = JSON.parse(q.species_ids || '[]'));
if (!req.user) return res.json(quests);
const userQ = {};
stmts.userQuests.all(req.user.id).forEach(uq => userQ[uq.quest_id] = uq);
quests.forEach(q => {
const uq = userQ[q.id];
q.user_status = uq?.status || 'locked';
q.user_progress = uq ? JSON.parse(uq.progress || '{}') : {};
});
res.json(quests);
};
/* ── POST /api/red-book/quests/:id/start ────────────────────────────── */
exports.startQuest = (req, res) => {
const q = stmts.questById.get(req.params.id);
if (!q) return res.status(404).json({ error: 'Квест не найден' });
stmts.startQuest.run(req.user.id, q.id);
res.json({ started: true });
};
/* ── GET /api/red-book/sightings ────────────────────────────────────── */
exports.getSightings = (req, res) => {
const speciesId = req.query.species_id ? parseInt(req.query.species_id) : null;
let sql = `
SELECT rs.id, rs.species_id, rs.region_code, rs.description,
rs.confirmed_by_teacher, rs.created_at,
u.name as user_name,
s.name_ru as species_name,
g.icon as species_icon
FROM rb_sightings rs
JOIN users u ON u.id = rs.user_id
JOIN rb_species s ON s.id = rs.species_id
JOIN rb_groups g ON g.id = s.group_id
`;
const params = [];
if (speciesId) { sql += ' WHERE rs.species_id = ?'; params.push(speciesId); }
sql += ' ORDER BY rs.created_at DESC LIMIT 50';
res.json(db.prepare(sql).all(...params));
};
/* ── POST /api/red-book/sightings ───────────────────────────────────── */
exports.addSighting = (req, res) => {
const { species_id, region_code, description } = req.body || {};
if (!species_id) return res.status(400).json({ error: 'species_id обязателен' });
const safeDesc = stripTags((description || '').slice(0, 2000));
const id = stmts.addSighting.run(req.user.id, species_id, region_code || '', safeDesc).lastInsertRowid;
try { checkRedBookAchievements(req.user.id); } catch {}
res.json({ id });
};
/* ── GET /api/red-book/stats ────────────────────────────────────────── */
exports.getStats = (req, res) => {
const totals = db.prepare(`
SELECT COUNT(*) as total,
SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en,
SUM(CASE WHEN category='VU' THEN 1 ELSE 0 END) as vu,
SUM(CASE WHEN category='NT' THEN 1 ELSE 0 END) as nt,
SUM(CASE WHEN category='LC' THEN 1 ELSE 0 END) as lc
FROM rb_species
`).get();
let collected = 0;
if (req.user) {
collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(req.user.id).n;
}
res.json({ ...totals, collected });
};
/* ── GET /api/red-book/daily ────────────────────────────────────────── */
exports.getDaily = (req, res) => {
// deterministic by day of year
const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);
const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n;
const offset = dayOfYear % total;
const daily = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.interesting_fact,
s.description, s.photo_url, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
ORDER BY s.id LIMIT 1 OFFSET ?
`).get(offset);
res.json(daily);
};