From 43df41287f91504b88f72c9d153c6a76ef8d5545 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 23 Jun 2026 23:17:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(errors):=20=D1=81=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=81=D0=BA=D0=B8=D1=85=20?= =?UTF-8?q?(=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=BD=D1=8B=D1=85)?= =?UTF-8?q?=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20=D0=B2=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD-=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83?= =?UTF-8?q?=20=C2=AB=D0=9E=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Глобальный репортер в api.js (грузится на всех страницах) ловит необработанные JS-ошибки (window 'error') и rejected-промисы ('unhandledrejection') в браузере пользователя и шлёт в POST /api/client-errors. Дедуп по сигнатуре + лимит 15/страницу, только для залогиненных, keepalive, не флудит и сам не падает. Бэкенд: routes/clientErrors (auth + rate-limit 20/мин на юзера) → clientErrorController пишет в общий error_log с level='client' (message/stack/route=url/method=kind/user_id), поля обрезаются. Появляются в существующей админ-вкладке «Ошибки» с бейджем «БРАУЗЕР» (фиолетовый акцент vs розовый у серверных). Тест client-errors.test.js 5/5. lint:routes 0; node --check всех файлов. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/controllers/clientErrorController.js | 29 ++++++++ backend/src/routes/clientErrors.js | 11 +++ backend/src/server.js | 1 + backend/tests/client-errors.test.js | 67 +++++++++++++++++++ frontend/js/admin/admin.js | 10 ++- js/api.js | 52 ++++++++++++++ 6 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 backend/src/controllers/clientErrorController.js create mode 100644 backend/src/routes/clientErrors.js create mode 100644 backend/tests/client-errors.test.js diff --git a/backend/src/controllers/clientErrorController.js b/backend/src/controllers/clientErrorController.js new file mode 100644 index 0000000..b664bc6 --- /dev/null +++ b/backend/src/controllers/clientErrorController.js @@ -0,0 +1,29 @@ +'use strict'; +/* clientErrorController — приём ошибок из браузера пользователя. + Пишем в общий error_log с level='client', чтобы они появились в админ-вкладке «Ошибки». + Запись не должна ронять запрос — любые сбои глушим. */ +const db = require('../db/db'); + +const MAX_MSG = 1000, MAX_STACK = 4000, MAX_ROUTE = 400; +const clamp = (v, n) => (v == null ? null : String(v).slice(0, n)); + +function report(req, res) { + const b = req.body || {}; + const message = (clamp(b.message, MAX_MSG) || '').trim(); + if (!message) return res.status(400).json({ error: 'message required' }); + + const kind = b.kind === 'unhandledrejection' ? 'rejection' : 'error'; + const route = clamp(b.url || b.route, MAX_ROUTE); + let stack = clamp(b.stack, MAX_STACK); + // если стека нет — собираем источник:строка:колонка + if (!stack && (b.source || b.line)) stack = `${b.source || ''}:${b.line || ''}:${b.col || ''}`; + + try { + db.prepare( + 'INSERT INTO error_log (level, message, stack, route, method, user_id) VALUES (?, ?, ?, ?, ?, ?)' + ).run('client', message, stack, route, kind, req.user.id); + } catch { /* лог не должен ломать ответ */ } + res.json({ ok: true }); +} + +module.exports = { report }; diff --git a/backend/src/routes/clientErrors.js b/backend/src/routes/clientErrors.js new file mode 100644 index 0000000..09b92ea --- /dev/null +++ b/backend/src/routes/clientErrors.js @@ -0,0 +1,11 @@ +'use strict'; +const router = require('express').Router(); +const { authMiddleware } = require('../middleware/auth'); +const rateLimit = require('../middleware/rateLimit'); +const ctrl = require('../controllers/clientErrorController'); + +router.use(authMiddleware); +// Не больше 20 отчётов в минуту с пользователя — защита от флуда циклящихся ошибок. +router.post('/', rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много отчётов об ошибках' }), ctrl.report); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 8e25f03..ebd643f 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -199,6 +199,7 @@ app.use('/api/materials', require('./routes/materials')); app.use('/api/custom-sims', require('./routes/customSims')); app.use('/api/game', require('./routes/game')); app.use('/api/wishes', require('./routes/wishes')); +app.use('/api/client-errors', require('./routes/clientErrors')); app.use('/api/prep', require('./routes/prep')); app.use('/api/dashboard', require('./routes/dashboard')); diff --git a/backend/tests/client-errors.test.js b/backend/tests/client-errors.test.js new file mode 100644 index 0000000..7ee0772 --- /dev/null +++ b/backend/tests/client-errors.test.js @@ -0,0 +1,67 @@ +'use strict'; +/** + * Integration: /api/client-errors — приём браузерных ошибок в error_log (level='client'). + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, inject, getToken, db, cleanup } = require('./setup'); + +app.use('/api/client-errors', require('../src/routes/clientErrors')); +after(() => cleanup()); + +describe('/api/client-errors', () => { + let student; + before(async () => { student = await getToken('student'); }); + + it('требует авторизацию (401)', async () => { + const res = await inject('POST', '/api/client-errors', { message: 'x' }, null); + assert.equal(res.status, 401); + }); + + it('пустое сообщение → 400', async () => { + const res = await inject('POST', '/api/client-errors', { message: ' ' }, student.token); + assert.equal(res.status, 400); + }); + + it('пишет ошибку в error_log с level=client', async () => { + const res = await inject('POST', '/api/client-errors', { + kind: 'error', message: 'TypeError: x is null', + stack: 'at foo (app.js:10:5)', source: '/js/app.js', line: 10, col: 5, + url: '/lab?sim=demo#x', + }, student.token); + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + + const row = db.prepare( + "SELECT * FROM error_log WHERE level='client' AND user_id=? ORDER BY id DESC LIMIT 1" + ).get(student.userId); + assert.ok(row, 'строка должна появиться'); + assert.equal(row.message, 'TypeError: x is null'); + assert.equal(row.route, '/lab?sim=demo#x'); + assert.equal(row.method, 'error'); + assert.match(row.stack, /app\.js:10:5/); + }); + + it('unhandledrejection → method=rejection, stack из source при отсутствии stack', async () => { + const res = await inject('POST', '/api/client-errors', { + kind: 'unhandledrejection', message: 'boom', source: '/js/x.js', line: 3, col: 1, url: '/dashboard', + }, student.token); + assert.equal(res.status, 200); + const row = db.prepare( + "SELECT * FROM error_log WHERE level='client' AND message='boom' ORDER BY id DESC LIMIT 1" + ).get(); + assert.equal(row.method, 'rejection'); + assert.match(row.stack, /x\.js:3:1/); + }); + + it('длинные поля обрезаются (не падает)', async () => { + const res = await inject('POST', '/api/client-errors', { + message: 'M'.repeat(5000), stack: 'S'.repeat(20000), url: 'U'.repeat(2000), + }, student.token); + assert.equal(res.status, 200); + const row = db.prepare("SELECT * FROM error_log WHERE level='client' ORDER BY id DESC LIMIT 1").get(); + assert.ok(row.message.length <= 1000); + assert.ok(row.stack.length <= 4000); + assert.ok(row.route.length <= 400); + }); +}); diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index c6a0bae..4e7b701 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -284,9 +284,15 @@ el.innerHTML = rows.map(r => { const dt = new Date(r.created_at); const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}); - return `
+ const isClient = r.level === 'client'; + const accent = isClient ? 'var(--violet)' : 'var(--pink)'; + const badge = isClient + ? `БРАУЗЕР` + : ''; + return `
- ${r.method || ''} ${esc(r.route || '')} + ${badge} + ${esc(r.method || '')} ${esc(r.route || '')} ${ds} ${r.user_id ? `user:${r.user_id}` : ''}
diff --git a/js/api.js b/js/api.js index b180de2..6031eba 100644 --- a/js/api.js +++ b/js/api.js @@ -1959,3 +1959,55 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi }); }); })(); + +/* ── Глобальный репортер клиентских ошибок ─────────────────────────────── + Ловит необработанные JS-ошибки и rejected-промисы в браузере пользователя + и шлёт в /api/client-errors → они появляются в админ-вкладке «Ошибки». + Дедуп + лимит на загрузку страницы (не флудим), только для залогиненных. */ +(function initClientErrorReporter() { + const seen = new Set(); + let sent = 0; const MAX_PER_PAGE = 15; + let inFlight = false; + + function send(payload) { + try { + if (!isLoggedIn()) return; // отчёты только от залогиненных + if (sent >= MAX_PER_PAGE) return; // не флудим повторами + const sig = (payload.message || '') + '|' + (payload.source || '') + ':' + (payload.line || ''); + if (seen.has(sig)) return; + seen.add(sig); sent++; + if (inFlight) return; + inFlight = true; + const token = getToken(); + fetch(API + '/client-errors', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, token ? { Authorization: 'Bearer ' + token } : {}), + body: JSON.stringify(payload), + keepalive: true, // долетит даже при закрытии вкладки + }).catch(function () {}).finally(function () { inFlight = false; }); + } catch (e) { inFlight = false; /* репортер не должен сам падать */ } + } + + window.addEventListener('error', function (e) { + // Пропускаем ошибки загрузки ресурсов (img/script) — у них нет message/error. + if (!e || (!e.message && !e.error)) return; + send({ + kind: 'error', + message: e.message || (e.error && (e.error.message || String(e.error))) || 'Script error', + stack: e.error && e.error.stack ? String(e.error.stack) : null, + source: e.filename || null, line: e.lineno || null, col: e.colno || null, + url: location.pathname + location.search + location.hash, + }); + }); + + window.addEventListener('unhandledrejection', function (e) { + const r = e && e.reason; + let msg = 'Unhandled promise rejection'; + let stack = null; + if (r) { + if (typeof r === 'string') msg = r; + else { msg = r.message || (r.toString && r.toString()) || msg; stack = r.stack ? String(r.stack) : null; } + } + send({ kind: 'unhandledrejection', message: msg, stack: stack, url: location.pathname + location.search + location.hash }); + }); +})();