3a4623a60a
Безопасность: - 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>
346 lines
16 KiB
JavaScript
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);
|
|
};
|