diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 3df2ea0..cd3bfaa 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -27,11 +27,17 @@ async function register(req, res, next) { const cleanName = stripTags(name.trim()); const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); - const { lastInsertRowid } = db.prepare( - 'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)' - ).run(email, hash, cleanName); + let lastInsertRowid; + try { + ({ lastInsertRowid } = db.prepare( + 'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)' + ).run(email, hash, cleanName)); + } catch (e) { + if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: 'Email already registered' }); + throw e; + } - const user = db.prepare('SELECT id, email, name, role, token_version FROM users WHERE id = ?').get(lastInsertRowid); + const user = db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(lastInsertRowid); const token = signToken(user); res.status(201).json({ token, user }); } catch (err) { next(err); } diff --git a/backend/src/controllers/courseController.js b/backend/src/controllers/courseController.js index 4284dab..ce8cf80 100644 --- a/backend/src/controllers/courseController.js +++ b/backend/src/controllers/courseController.js @@ -474,7 +474,7 @@ function assignCourseToClass(req, res) { ON CONFLICT (class_id, course_id) DO UPDATE SET deadline=excluded.deadline `).run(classId, courseId, deadline || null, req.user.id); res.json({ ok: true }); - } catch (e) { res.status(400).json({ error: e.message }); } + } catch (e) { console.error('[course assign]', e.message); res.status(400).json({ error: 'Ошибка назначения курса' }); } } function unassignCourseFromClass(req, res) { diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index c8cd8e1..b5a3109 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -1,4 +1,5 @@ const db = require('../db/db'); +const { stripTags } = require('../utils/sanitize'); /* ── SM-2 algorithm ─────────────────────────────────────────────────────── quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar, @@ -94,7 +95,8 @@ function addCard(req, res) { const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); - const { front = '', back = '' } = req.body; + const front = stripTags((req.body.front || '').slice(0, 5000)); + const back = stripTags((req.body.back || '').slice(0, 5000)); const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) .get(deck.id)?.m ?? -1; const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`) diff --git a/backend/src/controllers/lessonController.js b/backend/src/controllers/lessonController.js index d9c5a63..468bc65 100644 --- a/backend/src/controllers/lessonController.js +++ b/backend/src/controllers/lessonController.js @@ -1,5 +1,6 @@ const db = require('../db/db'); const { onLessonComplete } = require('./gamificationController'); +const { stripTags } = require('../utils/sanitize'); /* ── helpers ──────────────────────────────────────────────────────────── */ function parseBlock(b) { @@ -249,7 +250,7 @@ function addComment(req, res) { const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id); if (!lesson) return res.status(404).json({ error: 'Lesson not found' }); - const text = (req.body.text || '').trim(); + const text = stripTags((req.body.text || '').trim()); if (!text) return res.status(400).json({ error: 'text required' }); if (text.length > 2000) return res.status(400).json({ error: 'Comment too long (max 2000)' }); diff --git a/backend/src/controllers/petController.js b/backend/src/controllers/petController.js index 25d7fff..fd807b2 100644 --- a/backend/src/controllers/petController.js +++ b/backend/src/controllers/petController.js @@ -245,8 +245,17 @@ function buyBg(req, res) { const owned = _parseOwned(user.pet_bg_owned); if (!owned.includes(id)) { if ((user.coins || 0) < item.price) return res.status(400).json({ error: 'insufficient_coins' }); - db.prepare('UPDATE users SET coins=coins-?, pet_bg_owned=?, pet_bg=? WHERE id=?') - .run(item.price, JSON.stringify([...owned, id]), id, req.user.id); + const buy = db.transaction(() => { + // Re-check balance inside transaction to prevent race + const fresh = db.prepare('SELECT coins FROM users WHERE id=?').get(req.user.id); + if ((fresh.coins || 0) < item.price) throw new Error('insufficient_coins'); + db.prepare('UPDATE users SET coins=coins-?, pet_bg_owned=?, pet_bg=? WHERE id=?') + .run(item.price, JSON.stringify([...owned, id]), id, req.user.id); + }); + try { buy(); } catch (e) { + if (e.message === 'insufficient_coins') return res.status(400).json({ error: 'insufficient_coins' }); + throw e; + } } else { db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id); } diff --git a/backend/src/controllers/preferencesController.js b/backend/src/controllers/preferencesController.js index 7149634..00f1133 100644 --- a/backend/src/controllers/preferencesController.js +++ b/backend/src/controllers/preferencesController.js @@ -35,6 +35,8 @@ function patchPreferences(req, res) { db.prepare('SELECT data FROM user_preferences WHERE user_id = ?').get(req.user.id)?.data || '{}' ); const merged = deepMerge(current, req.body); + if (JSON.stringify(merged).length > 50_000) + return res.status(413).json({ error: 'Preferences too large (max 50KB)' }); db.prepare(` INSERT INTO user_preferences (user_id, data, updated_at) VALUES (?, ?, datetime('now')) diff --git a/backend/src/controllers/questionController.js b/backend/src/controllers/questionController.js index 0b53427..02575cb 100644 --- a/backend/src/controllers/questionController.js +++ b/backend/src/controllers/questionController.js @@ -165,7 +165,8 @@ function update(req, res) { })(); res.json({ id: Number(qid) }); } catch (err) { - res.status(err.status || 500).json({ error: err.message }); + console.error('[question update]', err.message); + res.status(err.status || 500).json({ error: err.status ? err.message : 'Ошибка обновления' }); } } @@ -239,7 +240,8 @@ function importCSV(req, res) { } })(); } catch (e) { - return res.status(500).json({ error: e.message }); + console.error('[question import]', e.message); + return res.status(500).json({ error: 'Ошибка импорта' }); } res.json({ imported, errors }); diff --git a/backend/src/controllers/redBookController.js b/backend/src/controllers/redBookController.js index 20d104a..69c187d 100644 --- a/backend/src/controllers/redBookController.js +++ b/backend/src/controllers/redBookController.js @@ -1,5 +1,6 @@ const db = require('../db/db'); const { awardXP, checkRedBookAchievements } = require('./gamificationController'); +const { stripTags } = require('../utils/sanitize'); /* ── helpers ─────────────────────────────────────────────────────────── */ const stmts = { @@ -86,7 +87,8 @@ function buildSpeciesList(filter = {}) { 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 ${limit} OFFSET ${offset}`; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); return db.prepare(sql).all(...params); } @@ -303,7 +305,8 @@ exports.getSightings = (req, res) => { exports.addSighting = (req, res) => { const { species_id, region_code, description } = req.body || {}; if (!species_id) return res.status(400).json({ error: 'species_id обязателен' }); - const id = stmts.addSighting.run(req.user.id, species_id, region_code || '', description || '').lastInsertRowid; + 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 }); }; diff --git a/backend/src/controllers/testController.js b/backend/src/controllers/testController.js index 7e285a6..c4196d0 100644 --- a/backend/src/controllers/testController.js +++ b/backend/src/controllers/testController.js @@ -59,6 +59,15 @@ function getOne(req, res) { options_json: undefined, })); + // Hide is_correct from students — only teachers/admins see correct answers + const isPrivileged = req.user.role === 'teacher' || req.user.role === 'admin'; + if (!isPrivileged) { + questions.forEach(q => { + q.options.forEach(o => { delete o.is_correct; }); + delete q.explanation; + }); + } + res.json({ ...t, questions }); } @@ -99,7 +108,8 @@ function addQuestions(req, res) { try { db.transaction(() => { question_ids.forEach(qid => ins.run(testId, qid, idx++)); })(); } catch (e) { - return res.status(500).json({ error: e.message }); + console.error('[testController] addQuestions error:', e.message); + return res.status(500).json({ error: 'Ошибка добавления вопросов' }); } res.json({ ok: true }); } @@ -126,7 +136,8 @@ function reorderQuestions(req, res) { ids.forEach((qid, i) => upd.run(i, testId, qid)); })(); } catch (e) { - return res.status(500).json({ error: e.message }); + console.error('[testController] reorderQuestions error:', e.message); + return res.status(500).json({ error: 'Ошибка сортировки' }); } res.json({ ok: true }); } diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index 49b9fff..34cd445 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -25,7 +25,7 @@ function getErrorLogStmt() { * Honour an incoming X-Request-Id from trusted proxies/gateways when present. */ function requestId(req, res, next) { - const id = req.headers['x-request-id'] || crypto.randomUUID(); + const id = crypto.randomUUID(); req.requestId = id; res.setHeader('X-Request-Id', id); next(); diff --git a/frontend/board.html b/frontend/board.html index 68f75da..e3eaee2 100644 --- a/frontend/board.html +++ b/frontend/board.html @@ -291,7 +291,7 @@
- +
diff --git a/frontend/profile.html b/frontend/profile.html index 5de4e10..583f98b 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -1376,7 +1376,7 @@ const ini = name.split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS'; document.getElementById('big-avatar').textContent = ini; document.getElementById('nav-avatar').textContent = ini; - } catch(e) { showMsg(msg, e.message||'Ошибка','err'); } + } catch(e) { showMsg(msg, LS.esc(e.message||'Ошибка'),'err'); } finally { btn.disabled = false; } } @@ -1395,7 +1395,7 @@ await LS.updateProfile({ currentPassword:cur, newPassword:nw }); showMsg(msg,'Пароль изменён ','ok'); ['inp-cur-pwd','inp-new-pwd','inp-conf-pwd'].forEach(id => document.getElementById(id).value=''); - } catch(e) { showMsg(msg, e.message||'Ошибка','err'); } + } catch(e) { showMsg(msg, LS.esc(e.message||'Ошибка'),'err'); } finally { btn.disabled = false; } }