diff --git a/AGENTS.md b/AGENTS.md index 879be87..4668f88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel) - **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + cache/pub-sub abstraction (`redis.js`) + static frontend in `public/` - **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data -- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API +- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Второй способ для внешних систем — API-ключи в `X-Api-Key` (см. 3f), они не дают доступа к UI и живут только в `/api/v1/*`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API --- @@ -138,6 +138,23 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **`bindTz` добавляет параметр только если в SQL есть `$TZ$`**: иначе Postgres отвечает `bind message supplies 1 parameters, but prepared statement requires 0`, а без global error handler запрос **висит вечно** (страница остаётся «Загрузка...»). Поэтому запрос с фильтрами дат работает, а без них — падает: проверяй оба варианта. Регрессия закрыта в `api.smoketest.js` - **`TZ` в compose** (`docker-compose.yml`, 5 мест) — только фолбэк для `DEFAULT_TIMEZONE`; фактическая зона берётся из настройки +### 3f. Внешний API и API-ключи (`server.js`, `public/apikeys.html`, `public/js/apikeys.js`) +- **Два независимых способа аутентификации**: сессии (`X-Auth-Token` → `requireAuth`) и API-ключи (`X-Api-Key` или `Authorization: Bearer` → `requireApiKey`). Это **разные** middleware: не смешивайте их, иначе поедет контракт из `api.smoketest.js`. `requireApiKey` обслуживает **только** `/api/v1/*` +- **Таблица**: `api_keys` (`user_id`, `name`, `prefix`, `key_hash`, `scopes TEXT[]`, `branch_ids INT[]`, `rate_limit_per_min`, `last_used_at/ip`, `expires_at`, `revoked_at`). DDL — в `db/init.sql`, `db/migration.sql` **и** `ensureApiKeysTable()` (`server.js`), вызывается из `ensureUsersAndFirstAdmin()` +- **Ключ не хранится**: в БД лежит только `sha256(ключ)` в `key_hash` + первые 12 символов в `prefix` для отображения. Секрет возвращается **один раз** при `POST /api/api-keys` и `POST /api/api-keys/:id/rotate` — восстановить его нельзя, только выпустить новый. Формат `wsk_<64 hex>` +- **Поиск ключа** — по хешу (`key_hash` UNIQUE), не по префиксу; сравнение строк не TimingSafe, поэтому и не делается: вход идёт через индекс по хешу +- **Права (`scopes`)**: `read` и `write`. Весь `/api/v1/*` требует `read` (в `apiV1.use`), мутации дополнительно проходят `apiWrite('write')` — без `write` ключ читает, но получает 403 на записи +- **Филиалы ключа сужают, но не расширяют права**: `apiKeyUser()` пересекает `branch_ids` ключа с филиалами владельца и **понижает роль до `tutor`**, даже если владелец — админ. Ключ не может стать шире возможностей того, кто его выдал +- **Кэш**: `apikey:` в Redis, TTL `SESSION_CACHE_TTL_MS` (30 с). Любая мутация ключа, а также смена роли/активности/филиалов пользователя (`PUT`/`DELETE /api/users/:id`) обязана звать `invalidateApiKeys()` — иначе отозванный ключ продолжит работать до истечения кэша +- **`last_used_at` троттлится** маркером `apikey:touch:` (5 мин), а не пишется на каждый запрос +- **Rate limit**: отдельный `apiKeyLimiter` на `cache.rateLimitStore('apikey', 60s)`; лимит — функция от `rate_limit_per_min` ключа (по умолчанию `API_KEY_DEFAULT_RPM` = 120). Ключ счёта — `k` по `keyGenerator`, для неавторизованных — `ip` через `ipKeyGenerator(ipOf(req))` (обязателен, иначе IPv6-клиенты обходят лимит) +- **Подбор ключа** считается через `recordFailure(req, 'apikey-bruteforce', 30, BAN_TTL_MS)`; метка причины есть в `BAN_REASON_LABELS` +- **CRUD ключей** (`/api/api-keys`, `/meta`, `/:id`, `/:id/rotate`) — под `requireAuth, requireAdmin`. Не-admin видит и правит только свои ключи. Валидация: `reqStr` для имени, `normalizeApiScopes`, `apiKeyRateValue` (1..10000), `apiKeyExpiry`, `apiKeyAllowedBranches` (возвращает `null` при чужом/несуществующем филиале) +- **Формат ответов `/api/v1`**: списки возвращают единый конверт `{ items, total, limit, offset }` (`apiList`) — в отличие от внутреннего API, где формы ответа разные (`{entries,total}`, `{modules,total}`, голый массив). Не смешивайте с внутренними хелперами +- **Мутации через API** аудитятся через `apiAudit()` — он добавляет `via_api_key: ` в `audit_log.target`, поэтому в аудите видно, каким ключом сделано изменение. После мутаций обязательны `invalidateEntries()` / `invalidateLessonReports()` / `invalidateStudents()` / `invalidateStats()` + `broadcastEntryChanged()`, иначе фронтенд не обновится +- **`DELETE /api/v1/entries/:id` — мягкое удаление** (`deleted_at`), как и во внутреннем API; физическое удаление живёт только в корзине +- **`api_keys` НЕ входит в бэкап** (как `sessions`), а `POST /api/restore` делает `DELETE FROM api_keys` — после восстановления все внешние ключи мертвы, их надо выпустить заново + ### 4. API Patterns - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется @@ -265,6 +282,10 @@ docker compose start redis # app reconnects on its # Audit diff checks node diff.selftest.js # unit, no stack needed + +# External API checks +node api-keys.selftest.js # e2e, needs running stack +curl -H "X-Api-Key: wsk_..." http://localhost:3003/api/v1/me ``` `diff.js` builds the audit payload for text changes: word-level segments @@ -301,6 +322,7 @@ guards against. - [ ] Rate limiter on new public routes - [ ] Admin routes behind `requireAdmin` - [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change +- [ ] API-ключи: в БД только хеш+префикс, секрет возвращается один раз; любая мутация ключа зовёт `invalidateApiKeys()` - [ ] No secrets in code — only via env vars - [ ] Helmet headers present (already global) - [ ] CORS disabled (no `cors` middleware) @@ -318,6 +340,7 @@ guards against. | `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests | | `backup-restore.js` / `backup.selftest.js` | Backup format version, `normalizeRestoreData` validation of restore payloads, backup table lists; self-tests | | `api.smoketest.js` | End-to-end API smoke test against a running stack | +| `api-keys.selftest.js` | Self-tests for external API: `X-Api-Key` auth, scopes, branch scoping, rate limit, rotate/revoke | | `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance | | `db/init.sql` | Initial schema (runs on fresh DB) | | `db/migration.sql` | Idempotent migrations for existing DBs | diff --git a/README.md b/README.md index 4336d60..7db96af 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,7 @@ docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL ' ```bash node redis.selftest.js # юнит-тесты redis.js node api.smoketest.js # сквозная проверка API (нужен запущенный стек) +node api-keys.selftest.js # внешний API и API-ключи (нужен запущенный стек) ``` ## Уведомления @@ -550,7 +551,7 @@ node api.smoketest.js # сквозная проверка API (нужен ## Безопасность -- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям. +- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. В API сам по себе он не авторизует: доступ дают сессия (`X-Auth-Token`) или API-ключ (`X-Api-Key`, только для `/api/v1/*`). - **CORS отключён** — кросс-доменные запросы к API запрещены. - **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин. - **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline. @@ -607,6 +608,52 @@ node api.smoketest.js # сквозная проверка API (нужен Сессия хранится в таблице `sessions` (срок 30 дней) и кэшируется в Redis на 30 секунд. +### Внешний API и API-ключи + +Для интеграций с внешними системами есть отдельный префикс `/api/v1` и собственная авторизация — **API-ключи**. Ключи создаются в админке: **API-ключи** в боковом меню (`public/apikeys.html`), либо через `GET/POST/PUT/DELETE /api/api-keys` (администратор). + +Ключ передаётся в заголовке `X-Api-Key` или `Authorization: Bearer <ключ>`: + +```bash +curl -H "X-Api-Key: wsk_ВАШ_КЛЮЧ" https://ВАШ_ДОМЕН/api/v1/groups +curl -H "Authorization: Bearer wsk_ВАШ_КЛЮЧ" https://ВАШ_ДОМЕН/api/v1/stats +``` + +Секрет показывается **один раз** — при создании и при перевыпуске (`⟳` в таблице). В базе хранится только SHA-256 хеш, поэтому восстановить ключ нельзя: если он потерян или утёк, выпустите новый, а старый удалите. + +| Метод | Путь | Право | +| --- | --- | --- | +| `GET` | `/api/v1/me` | чтение | +| `GET` | `/api/v1/branches` | чтение | +| `GET` | `/api/v1/groups`, `/groups/:id` | чтение | +| `GET` | `/api/v1/students`, `/students/:id` | чтение | +| `POST`, `PUT` | `/api/v1/students[/:id]` | запись | +| `GET` | `/api/v1/modules` | чтение | +| `GET` | `/api/v1/entries`, `/entries/:id`, `/entries/:id/files` | чтение | +| `POST`, `PUT`, `DELETE` | `/api/v1/entries[/:id]` | запись | +| `GET` | `/api/v1/lesson-reports`, `/lesson-reports/:id` | чтение | +| `POST`, `PUT`, `DELETE` | `/api/v1/lesson-reports[/:id]` | запись | +| `GET` | `/api/v1/stats` | чтение | + +Списки возвращают единый формат `{ items, total, limit, offset }`; поддерживаются `limit`/`offset` (до 500) и фильтры (`group_id`, `module_id`, `student_name`, `search`, `date_from`, `date_to`). + +Меры безопасности: + +- **Права**: у ключа есть скоупы `read` и `write`; без `write` все изменения возвращают `403`. +- **Филиалы**: ключ можно ограничить конкретными филиалами — он увидит **не больше**, чем доступно выдавшему его пользователю (админский ключ с ограничением теряет доступ ко всем остальным филиалам). +- **Срок и лимиты**: у ключа задаются дата окончания и лимит запросов в минуту (по умолчанию 120); при превышении — `429`. +- **Отзыв**: удаление ключа действует немедленно; старый ключ перестаёт работать и после ротации. +- **Подбор ключей** считается, при частых неудачах IP получает бан. +- **Аудит**: все изменения, сделанные через API, попадают в аудит с пометкой `via_api_key`. +- **Изоляция**: ключ работает только в `/api/v1/*` и не открывает доступ к админ-панели. +- **Бэкап**: ключи не входят в архив — после восстановления их нужно выпустить заново. + +Проверка: + +```bash +node api-keys.selftest.js +``` + ## Структура проекта ``` @@ -621,6 +668,7 @@ node api.smoketest.js # сквозная проверка API (нужен ├── diff.js # пословный diff текста и сборка изменений записи для аудита ├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка) ├── api.smoketest.js # сквозная проверка API по поднятому стеку +├── api-keys.selftest.js # тесты внешнего API: ключи, права, филиалы, rate limit ├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото ├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится) ├── db/ diff --git a/api-keys.selftest.js b/api-keys.selftest.js new file mode 100644 index 0000000..9d54a87 --- /dev/null +++ b/api-keys.selftest.js @@ -0,0 +1,178 @@ +const fs = require('fs'); +const path = require('path'); + +function loadEnv() { + const file = path.join(__dirname, '.env'); + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); + if (m && !(m[1] in process.env)) process.env[m[1]] = m[2]; + } +} + +const BASE = process.env.BASE || 'http://localhost:3003'; + +async function api(pathname, { token, apiKey, method = 'GET', body, headers: extra } = {}) { + const headers = {}; + if (token) headers['X-Auth-Token'] = token; + if (apiKey) headers['X-Api-Key'] = apiKey; + if (body) headers['Content-Type'] = 'application/json'; + Object.assign(headers, extra || {}); + const res = await fetch(BASE + pathname, { method, headers, body: body ? JSON.stringify(body) : undefined }); + const text = await res.text(); + let data = text; + try { data = JSON.parse(text); } catch (e) {} + return { status: res.status, data, headers: res.headers }; +} + +function ok(label, cond, extra) { + console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined && extra !== null ? ' -> ' + JSON.stringify(extra) : ''}`); + if (!cond) process.exitCode = 1; + return cond; +} + +const V1 = (key, p, opts) => api('/api/v1' + p, { ...opts, apiKey: key }); + +const unbanSelf = async (token) => { + const bans = await api('/api/bans', { token }); + if (!Array.isArray(bans.data)) return; + for (const b of bans.data) { + if (b.reason === 'apikey-bruteforce') await api('/api/bans/' + encodeURIComponent(b.ip), { token, method: 'DELETE' }); + } +}; + +async function main() { + loadEnv(); + const login = await api('/api/auth/login', { method: 'POST', body: { username: process.env.ADMIN_USERNAME || 'admin', password: process.env.ADMIN_PASSWORD } }); + if (login.status === 403) { + console.log('IP заблокирован защитой от подбора ключей (тест делает несколько заведомо неверных ключей за прогон).'); + console.log('Снимите бан в админке «Блокировки» и повторите запуск.'); + return; + } + if (!ok('login', login.status === 200 && login.data.token, login.status)) return; + const token = login.data.token; + await unbanSelf(token); + + const created = await api('/api/api-keys', { token, method: 'POST', body: { name: 'SelfTest read ' + Date.now(), scopes: ['read'] } }); + ok('api-keys: создание ключа -> 201 с секретом', + created.status === 201 && typeof created.data.key === 'string' && created.data.key.startsWith('wsk_'), + { status: created.status, prefix: created.data && created.data.prefix }); + const rawKey = created.data.key; + const keyId = created.data.id; + + const meta = await api('/api/api-keys/meta', { token }); + ok('api-keys: meta отдаёт scopes', meta.status === 200 && meta.data.scopes && meta.data.scopes.read && meta.data.scopes.write, meta.data); + ok('api-keys: секрет не возвращается в листинге', + await api('/api/api-keys', { token }).then(r => Array.isArray(r.data) && r.data.every(k => k.key === undefined)), null); + + const me = await V1(rawKey, '/me'); + ok('api v1: /me по X-Api-Key -> 200', me.status === 200 && me.data.user && me.data.user.role === 'admin', me.data && me.data.user); + ok('api v1: Authorization Bearer тоже работает', (await api('/api/v1/me', { headers: { Authorization: 'Bearer ' + rawKey } })).status === 200, null); + ok('api v1: секрет ключа не утекает в /me', me.data.key && me.data.key.key === undefined, me.data.key); + ok('api v1: без ключа -> 401', (await api('/api/v1/me')).status === 401, null); + ok('api v1: неверный ключ -> 401', (await api('/api/v1/me', { headers: { 'X-Api-Key': 'wsk_' + '0'.repeat(64) } })).status === 401, null); + ok('api v1: токен сессии не работает как API-ключ', (await api('/api/v1/me', { headers: { 'X-Api-Key': token } })).status === 401, null); + + for (const p of ['/branches', '/groups', '/students', '/modules', '/entries', '/lesson-reports', '/stats']) { + const r = await V1(rawKey, p); + ok('api v1: чтение ' + p, r.status === 200, { status: r.status, error: r.data && r.data.error }); + } + const gList = await V1(rawKey, '/groups?limit=2'); + ok('api v1: список возвращает {items,total,limit,offset}', + gList.status === 200 && Array.isArray(gList.data.items) && typeof gList.data.total === 'number' && gList.data.limit === 2, + { status: gList.status, keys: Object.keys(gList.data || {}) }); + + const g = (gList.data.items && gList.data.items[0]) || null; + const studentName = 'SelftestApi ' + Date.now(); + if (g) { + const denied = await V1(rawKey, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'read-only' } }); + ok('api v1: ключ без scope write -> 403', denied.status === 403, { status: denied.status, error: denied.data && denied.data.error }); + } else { + ok('api v1: есть группа для проверки записи', false, 'no groups'); + } + + const writeKey = await api('/api/api-keys', { token, method: 'POST', body: { name: 'SelfTest write ' + Date.now(), scopes: ['read', 'write'] } }); + ok('api-keys: создание ключа со scope write', writeKey.status === 201, writeKey.status); + + if (g) { + const ce = await V1(writeKey.data.key, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'Создано через API' } }); + ok('api v1: POST /entries со scope write -> 201', ce.status === 201 && ce.data.id > 0, { status: ce.status, error: ce.data && ce.data.error }); + if (ce.status === 201) { + const upd = await V1(writeKey.data.key, '/entries/' + ce.data.id, { method: 'PUT', body: { description: 'Обновлено через API' } }); + ok('api v1: PUT /entries/:id', upd.status === 200 && upd.data.description === 'Обновлено через API', { status: upd.status, error: upd.data && upd.data.error }); + const one = await V1(writeKey.data.key, '/entries/' + ce.data.id); + ok('api v1: GET /entries/:id отдаёт изменённое', one.status === 200 && one.data.description === 'Обновлено через API', one.status); + ok('api v1: GET /entries/:id/files', (await V1(writeKey.data.key, '/entries/' + ce.data.id + '/files')).status === 200, null); + ok('api v1: DELETE /entries/:id (soft delete)', (await V1(writeKey.data.key, '/entries/' + ce.data.id, { method: 'DELETE' })).status === 200, null); + const after = await V1(writeKey.data.key, '/entries?search=' + encodeURIComponent(studentName)); + ok('api v1: удалённая запись не выдаётся в списке', + after.status === 200 && !after.data.items.some(i => i.id === ce.data.id), + { status: after.status, ids: (after.data.items || []).map(i => i.id) }); + } + const lessonDate = new Date().toISOString().slice(0, 10); + const lr = await V1(writeKey.data.key, '/lesson-reports', { method: 'POST', body: { group_id: g.id, lesson_date: lessonDate, lesson_time: '10:00', text: 'Отчёт через API' } }); + ok('api v1: POST /lesson-reports', lr.status === 201 && lr.data.id > 0, { status: lr.status, error: lr.data && lr.data.error }); + if (lr.status === 201) { + const lrList = await V1(writeKey.data.key, '/lesson-reports?group_id=' + g.id + '&date_from=' + lessonDate); + ok('api v1: отчёт виден в списке по дате', lrList.status === 200 && lrList.data.items.some(r => r.id === lr.data.id), { status: lrList.status, total: lrList.data && lrList.data.total }); + ok('api v1: DELETE /lesson-reports/:id', (await V1(writeKey.data.key, '/lesson-reports/' + lr.data.id, { method: 'DELETE' })).status === 200, null); + } + } + + const rot = await api('/api/api-keys/' + writeKey.data.id + '/rotate', { token, method: 'POST' }); + ok('api-keys: ротация выдаёт новый секрет', rot.status === 200 && typeof rot.data.key === 'string' && rot.data.key !== writeKey.data.key, rot.status); + ok('api v1: старый ключ мёртв после ротации', (await api('/api/v1/me', { apiKey: writeKey.data.key })).status === 401, null); + ok('api v1: новый ключ работает после ротации', (await api('/api/v1/me', { apiKey: rot.data.key })).status === 200, null); + await api('/api/api-keys/' + writeKey.data.id, { token, method: 'DELETE' }); + + const updKey = await api('/api/api-keys/' + keyId, { token, method: 'PUT', body: { name: 'Renamed ' + Date.now(), scopes: ['read'] } }); + ok('api-keys: PUT обновляет ключ', updKey.status === 200 && updKey.data.name.startsWith('Renamed'), updKey.status); + ok('api-keys: некорректный лимит -> 400', (await api('/api/api-keys/' + keyId, { token, method: 'PUT', body: { rate_limit_per_min: 999999 } })).status === 400, null); + ok('api-keys: DELETE ключа', (await api('/api/api-keys/' + keyId, { token, method: 'DELETE' })).status === 200, null); + ok('api v1: удалённый ключ -> 401', (await api('/api/v1/me', { apiKey: rawKey })).status === 401, null); + ok('api-keys: список без сессии -> 401', (await api('/api/api-keys')).status === 401, null); + ok('api-keys: X-Admin-Token не авторизует -> 401', (await api('/api/api-keys', { headers: { 'X-Admin-Token': token } })).status === 401, null); + + const limited = await api('/api/api-keys', { token, method: 'POST', body: { name: 'RL test', scopes: ['read'], rate_limit_per_min: 3 } }); + const rlKey = limited.data.key; + const codes = []; + let rlHeaders = null; + for (let i = 0; i < 5; i++) { + const r = await api('/api/v1/stats', { apiKey: rlKey }); + codes.push(r.status); + rlHeaders = r.headers; + } + ok('api-keys: индивидуальный лимит rpm соблюдается', + codes.slice(0, 3).every(c => c === 200) && codes.slice(3).some(c => c === 429), + { codes, limit: rlHeaders && rlHeaders.get('ratelimit-limit') }); + ok('api-keys: заголовки ratelimit присутствуют', Boolean(rlHeaders && rlHeaders.get('ratelimit-limit')), null); + await api('/api/api-keys/' + limited.data.id, { token, method: 'DELETE' }); + + const expired = await api('/api/api-keys', { token, method: 'POST', body: { name: 'Expired', scopes: ['read'], expires_at: '2000-01-01T00:00:00Z' } }); + ok('api v1: просроченный ключ -> 401', (await api('/api/v1/me', { apiKey: expired.data.key })).status === 401, null); + await api('/api/api-keys/' + expired.data.id, { token, method: 'DELETE' }); + + const branches = await api('/api/branches', { token }); + if (Array.isArray(branches.data) && branches.data.length >= 1) { + const only = await api('/api/api-keys', { token, method: 'POST', body: { name: 'Branch 1 ' + Date.now(), scopes: ['read'], branch_ids: [branches.data[0].id] } }); + ok('api-keys: ключ с ограничением по филиалу создан', only.status === 201, only.status); + const scoped = await api('/api/v1/branches', { apiKey: only.data.key }); + ok('api v1: ключ видит только свой филиал', + scoped.status === 200 && scoped.data.items.length === 1 && scoped.data.items[0].id === branches.data[0].id, + { status: scoped.status, ids: (scoped.data.items || []).map(b => b.id) }); + const sc = await api('/api/v1/me', { apiKey: only.data.key }); + ok('api v1: филиал ограничивает права доступа', + sc.data.user.role === 'tutor' && sc.data.user.branch_ids.length === 1 && sc.data.user.branch_ids[0] === branches.data[0].id, + sc.data.user); + const bad = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { branch_ids: [999999] } }); + ok('api-keys: несуществующий филиал -> 400', bad.status === 400, bad.status); + await api('/api/api-keys/' + only.data.id, { token, method: 'DELETE' }); + } else { + ok('api v1: есть филиал для проверки скоупа', false, 'no branches'); + } + + await unbanSelf(token); + + console.log('\nAPI KEYS SELFTEST DONE'); +} + +main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); }); \ No newline at end of file diff --git a/db/init.sql b/db/init.sql index 25226c9..5fca227 100644 --- a/db/init.sql +++ b/db/init.sql @@ -225,6 +225,26 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(150) NOT NULL, + prefix VARCHAR(16) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + scopes TEXT[] NOT NULL DEFAULT ARRAY['read']::TEXT[], + branch_ids INT[] NOT NULL DEFAULT ARRAY[]::INT[], + rate_limit_per_min INT, + created_at TIMESTAMPTZ DEFAULT now(), + last_used_at TIMESTAMPTZ, + last_used_ip VARCHAR(45), + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash); +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at); + CREATE TABLE IF NOT EXISTS lesson_reports ( id SERIAL PRIMARY KEY, group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, diff --git a/db/migration.sql b/db/migration.sql index 37c623c..3c31ffd 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -176,6 +176,26 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(150) NOT NULL, + prefix VARCHAR(16) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + scopes TEXT[] NOT NULL DEFAULT ARRAY['read']::TEXT[], + branch_ids INT[] NOT NULL DEFAULT ARRAY[]::INT[], + rate_limit_per_min INT, + created_at TIMESTAMPTZ DEFAULT now(), + last_used_at TIMESTAMPTZ, + last_used_ip VARCHAR(45), + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash); +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at); + ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL; CREATE TABLE IF NOT EXISTS group_photos ( diff --git a/public/admin.js b/public/admin.js index d7d7037..86b63aa 100644 --- a/public/admin.js +++ b/public/admin.js @@ -63,6 +63,7 @@ function buildSidebar(active) { { page: 'modules', label: 'Темы модулей', icon: 'layers' }, { page: 'branches', label: 'Филиалы', icon: 'building-2' }, { page: 'users', label: 'Пользователи', icon: 'user-cog' }, + { page: 'apikeys', label: 'API-ключи', icon: 'key-round' }, { page: 'worker', label: 'Воркер ИИ', icon: 'bot' }, { page: 'audit', label: 'Аудит', icon: 'scroll-text' }, { page: 'bans', label: 'Блокировки', icon: 'shield-off' }, @@ -399,10 +400,11 @@ function renderIcons() { } } -function showToast(msg) { +function showToast(msg, isError) { const t = document.getElementById('toast'); if (!t) return; t.textContent = msg; + t.classList.toggle('error', !!isError); t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 3000); } diff --git a/public/apikeys.html b/public/apikeys.html new file mode 100644 index 0000000..8f65b29 --- /dev/null +++ b/public/apikeys.html @@ -0,0 +1,140 @@ + + + + + +API-ключи — Админ-панель + + + +
+ +
+
+

API-ключи

+

Доступ к данным из внешних систем по HTTP API

+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + +
IDНазваниеКлючПраваФилиалыСозданИспользованСтатус
+ +
+ +
+
+ +

Как использовать

+
+

Базовый адрес: /api/v1. Ключ передаётся в заголовке X-Api-Key или Authorization: Bearer <ключ>.

+
curl -H "X-Api-Key: wsk_ВАШ_КЛЮЧ" https://ВАШ_ДОМЕН/api/v1/groups
+
+curl -H "X-Api-Key: wsk_ВАШ_КЛЮЧ" \
+     -H "Content-Type: application/json" \
+     -d '{"student_name":"Иван","group_id":1,"description":"Сделал проект"}' \
+     https://ВАШ_ДОМЕН/api/v1/entries
+

Секрет показывается один раз при создании и ротации — в базе хранится только SHA-256 хеш. Эндпоинты: /me, /branches, /groups, /students, /modules, /entries, /lesson-reports, /stats.

+
+
+
+ + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/public/js/apikeys.js b/public/js/apikeys.js new file mode 100644 index 0000000..b3b4c3d --- /dev/null +++ b/public/js/apikeys.js @@ -0,0 +1,146 @@ +let apiKeys = []; +let branches = []; + +async function loadKeys() { + const [kRes, bRes] = await Promise.all([ + fetch(`${API}/api/api-keys`, { headers: hdr() }), + fetch(`${API}/api/branches`, { headers: hdr() }), + ]); + if (!kRes.ok) { showToast('Ошибка загрузки ключей'); return; } + if (bRes.ok) branches = await bRes.json(); + apiKeys = await kRes.json(); + renderKeys(); +} + +function selectedBranches() { + return [...document.getElementById('kBranches').selectedOptions].map(o => Number(o.value)); +} + +function keyStatus(k) { + if (k.revoked_at) return 'Отозван'; + if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now()) return 'Истёк'; + return 'Активен'; +} + +function renderKeys() { + const tbody = document.querySelector('#keysTable tbody'); + const empty = document.getElementById('keysEmpty'); + tbody.innerHTML = ''; + empty.style.display = apiKeys.length ? 'none' : 'block'; + const branchById = {}; + branches.forEach(b => { branchById[b.id] = b.name; }); + apiKeys.forEach(k => { + const tr = document.createElement('tr'); + const scopeNames = { read: 'чтение', write: 'запись' }; + const scopes = (k.scopes || []).map(s => scopeNames[s] || s).join(', '); + const branchNames = (k.branch_ids || []).map(id => branchById[id] || `#${id}`).join(', ') || 'все'; + const expires = k.expires_at ? fmtDateOnlyIso(k.expires_at) : 'бессрочный'; + tr.innerHTML = ` + ${k.id} + ${esc(k.name)} + ${esc(k.prefix || '')}… + ${esc(scopes)} + ${esc(branchNames)} + ${fmtDateTimeY(k.created_at)} + ${k.last_used_at ? fmtDateTimeY(k.last_used_at) : '—'} + ${keyStatus(k)} + + + + `; + tbody.appendChild(tr); + }); +} + +function openModal() { + const sel = document.getElementById('kBranches'); + sel.innerHTML = branches.map(b => ``).join(''); + document.getElementById('kName').value = ''; + document.getElementById('kRead').checked = true; + document.getElementById('kWrite').checked = false; + document.getElementById('kRate').value = ''; + document.getElementById('kExpires').value = ''; + document.getElementById('keyModal').classList.add('open'); + setTimeout(() => document.getElementById('kName').focus(), 100); +} + +function closeModal() { + document.getElementById('keyModal').classList.remove('open'); +} + +function showSecret(secret) { + document.getElementById('secretValue').value = secret; + document.getElementById('secretModal').classList.add('open'); +} + +async function saveKey() { + const name = document.getElementById('kName').value.trim(); + if (!name) { showToast('Введите название', true); return; } + const scopes = []; + if (document.getElementById('kRead').checked) scopes.push('read'); + if (document.getElementById('kWrite').checked) scopes.push('write'); + if (!scopes.length) { showToast('Выберите хотя бы одно право', true); return; } + const expires = document.getElementById('kExpires').value; + const res = await fetch(`${API}/api/api-keys`, { + method: 'POST', + headers: hdrJson(), + body: JSON.stringify({ + name, + scopes, + branch_ids: selectedBranches(), + rate_limit_per_min: document.getElementById('kRate').value || null, + expires_at: expires || null, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { showToast(data.error || 'Ошибка создания ключа', true); return; } + closeModal(); + showSecret(data.key); + await loadKeys(); +} + +async function rotateKey(id) { + if (!window.confirm('Перевыпустить ключ? Старый ключ перестанет работать.')) return; + const res = await fetch(`${API}/api/api-keys/${id}/rotate`, { method: 'POST', headers: hdr() }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { showToast(data.error || 'Ошибка ротации', true); return; } + showSecret(data.key); + await loadKeys(); +} + +async function deleteKey(id) { + if (!window.confirm('Удалить API-ключ? Он перестанет работать немедленно.')) return; + const res = await fetch(`${API}/api/api-keys/${id}`, { method: 'DELETE', headers: hdr() }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { showToast(data.error || 'Ошибка удаления', true); return; } + showToast('Ключ удалён'); + await loadKeys(); +} + +async function init() { + if (!(await requireAdminPage())) return; + buildSidebar('apikeys'); + document.getElementById('addKeyBtn').addEventListener('click', openModal); + document.getElementById('kCancel').addEventListener('click', closeModal); + document.getElementById('kSave').addEventListener('click', saveKey); + document.getElementById('secretClose').addEventListener('click', () => document.getElementById('secretModal').classList.remove('open')); + document.getElementById('secretCopy').addEventListener('click', async () => { + const field = document.getElementById('secretValue'); + try { + await navigator.clipboard.writeText(field.value); + showToast('Ключ скопирован'); + } catch { + field.select(); + showToast('Скопируйте ключ вручную', true); + } + }); + document.querySelector('#keysTable tbody').addEventListener('click', (e) => { + const rot = e.target.closest('[data-rot]'); + if (rot) return rotateKey(rot.dataset.rot); + const del = e.target.closest('[data-del]'); + if (del) return deleteKey(del.dataset.del); + }); + await loadKeys(); +} + +init(); \ No newline at end of file diff --git a/server.js b/server.js index b56577f..4652576 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ const express = require('express'); const { Pool, types } = require('pg'); const multer = require('multer'); const rateLimit = require('express-rate-limit'); +const { ipKeyGenerator } = require('express-rate-limit'); const helmet = require('helmet'); const bcrypt = require('bcrypt'); const heicConvert = require('heic-convert'); @@ -119,6 +120,7 @@ function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cach function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); } function invalidateLessonReports() { cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:'); } function invalidateSessions() { cacheDrop('session:'); } +function invalidateApiKeys() { cacheDrop('apikey:'); cacheDrop('rl:apikey'); } const EVENTS_CHANNEL = 'whatido:events'; const AI_WAKE_CHANNEL = 'whatido:wake:ai'; @@ -460,6 +462,7 @@ const BAN_REASON_LABELS = { honeypot: 'Антиспам-поле', 'login-bruteforce': 'Подбор пароля входа', 'share-password-bruteforce': 'Подбор пароля ссылки', + 'apikey-bruteforce': 'Подбор API-ключа', manual: 'Вручную', }; @@ -966,6 +969,150 @@ async function optionalAuth(req, res, next) { next(); } +// --- API keys (external access) --- +const API_KEY_PREFIX = 'wsk'; +const API_KEY_SCOPES = { read: 'Чтение данных', write: 'Изменение данных' }; +const API_KEY_TOUCH_MS = 5 * 60 * 1000; +const API_KEY_DEFAULT_RPM = 120; +const API_KEY_MAX_RPM = 10000; +const API_KEY_MAX_NAME = 150; + +const apiKeyLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: (req) => { + const rpm = req.apiKey && req.apiKey.rpm; + return Number.isInteger(rpm) && rpm > 0 ? Math.min(rpm, API_KEY_MAX_RPM) : API_KEY_DEFAULT_RPM; + }, + standardHeaders: true, + legacyHeaders: false, + store: cache.rateLimitStore('apikey', 60 * 1000), + keyGenerator: (req) => (req.apiKey ? 'k' + req.apiKey.id : 'ip' + ipKeyGenerator(ipOf(req))), + message: { error: 'Превышен лимит запросов для API-ключа' }, +}); + +function hashApiKey(raw) { + return crypto.createHash('sha256').update(String(raw), 'utf8').digest('hex'); +} + +function apiKeyFromRequest(req) { + const header = req.headers['x-api-key']; + if (typeof header === 'string' && header.trim()) return header.trim(); + const auth = req.headers.authorization; + if (typeof auth === 'string') { + const m = auth.match(/^Bearer\s+(\S+)$/i); + if (m) return m[1]; + } + return ''; +} + +function normalizeApiScopes(v) { + const list = Array.isArray(v) ? v : (v === undefined || v === null ? [] : [v]); + const out = [...new Set(list.map(x => String(x).trim().toLowerCase()).filter(x => Object.prototype.hasOwnProperty.call(API_KEY_SCOPES, x)))]; + return out.length ? out : ['read']; +} + +function apiKeyPublic(row) { + return { + id: row.id, + name: row.name, + prefix: row.prefix, + scopes: row.scopes || ['read'], + branch_ids: row.branch_ids || [], + rate_limit_per_min: row.rate_limit_per_min, + created_at: row.created_at, + last_used_at: row.last_used_at, + last_used_ip: row.last_used_ip, + expires_at: row.expires_at, + revoked_at: row.revoked_at, + user_id: row.user_id, + username: row.username || undefined, + user_name: row.user_name || undefined, + }; +} + +function apiKeyUser(row) { + const owner = { + id: row.user_id, + username: row.username, + name: row.user_name, + role: row.role, + is_active: true, + branch_ids: row.owner_branch_ids || [], + }; + const limit = (row.branch_ids || []).map(Number).filter(Boolean); + if (!limit.length) return owner; + const ownerScope = branchScope(owner); + const allowed = ownerScope.admin ? limit : limit.filter(id => ownerScope.ids.includes(id)); + return { ...owner, role: 'tutor', branch_ids: allowed }; +} + +async function loadApiKey(raw) { + if (!raw || raw.length < 32 || raw.length > 200) return null; + const key = 'apikey:' + hashApiKey(raw); + const cached = await cache.get(key); + if (cached !== undefined) return cached; + const { rows } = await pool.query( + `SELECT k.id, k.user_id, k.name, k.prefix, k.scopes, k.branch_ids, k.rate_limit_per_min, + k.expires_at, k.revoked_at, + u.username, u.name AS user_name, u.role, u.is_active, + COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS owner_branch_ids + FROM api_keys k + JOIN users u ON u.id = k.user_id + LEFT JOIN user_branches ub ON ub.user_id = u.id + WHERE k.key_hash = $1 + GROUP BY k.id, u.id`, + [hashApiKey(raw)] + ); + if (!rows.length) return null; + const row = rows[0]; + if (row.revoked_at || !row.is_active) return null; + if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) return null; + const value = { id: row.id, name: row.name, scopes: row.scopes || ['read'], user: apiKeyUser(row), rpm: row.rate_limit_per_min }; + await cache.set(key, value, SESSION_CACHE_TTL_MS); + return value; +} + +async function touchApiKey(id, ip) { + try { + const marker = 'apikey:touch:' + id; + const last = await cache.get(marker); + if (last !== undefined && Date.now() - Number(last) < API_KEY_TOUCH_MS) return; + await cache.set(marker, Date.now(), API_KEY_TOUCH_MS); + await pool.query('UPDATE api_keys SET last_used_at = now(), last_used_ip = $1 WHERE id = $2', [ip, id]); + } catch (e) { + console.error('api key touch:', e.message); + } +} + +function requireApiKey(scope) { + return async (req, res, next) => { + let apiKey = null; + try { + const raw = apiKeyFromRequest(req); + apiKey = await loadApiKey(raw); + if (!apiKey) { + if (raw) await recordFailure(req, 'apikey-bruteforce', 30, BAN_TTL_MS); + return res.status(401).json({ error: 'Invalid or expired API key' }); + } + if (scope && !apiKey.scopes.includes(scope)) { + return res.status(403).json({ error: `API key lacks scope: ${scope}` }); + } + req.apiKey = apiKey; + req.user = apiKey.user; + touchApiKey(apiKey.id, ipOf(req)); + } catch (e) { + console.error('requireApiKey:', e); + return res.status(500).json({ error: 'Internal server error' }); + } + next(); + }; +} + +function apiAudit(req, action, target) { + const merged = { ...(target || {}), via_api_key: req.apiKey ? req.apiKey.id : null }; + return logAudit(req, action, merged); +} + function branchWhere(user, alias) { const s = branchScope(user); if (s.admin) return { where: '', params: [] }; @@ -1435,6 +1582,27 @@ async function ensureUserTables() { await pool.query('ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL'); } +async function ensureApiKeysTable() { + await pool.query(`CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(150) NOT NULL, + prefix VARCHAR(16) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + scopes TEXT[] NOT NULL DEFAULT ARRAY['read']::TEXT[], + branch_ids INT[] NOT NULL DEFAULT ARRAY[]::INT[], + rate_limit_per_min INT, + created_at TIMESTAMPTZ DEFAULT now(), + last_used_at TIMESTAMPTZ, + last_used_ip VARCHAR(45), + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ + )`); + await pool.query('CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash)'); + await pool.query('CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id)'); + await pool.query('CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at)'); +} + async function ensureFirstAdmin() { if (!ADMIN_PASSWORD) return; const { rows } = await pool.query('SELECT id FROM users WHERE role = \'admin\' LIMIT 1'); @@ -1451,6 +1619,7 @@ async function ensureFirstAdmin() { async function ensureUsersAndFirstAdmin() { await ensureUserTables(); + await ensureApiKeysTable(); await ensureFirstAdmin(); } @@ -1775,6 +1944,7 @@ app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => { } await client.query('COMMIT'); invalidateSessions(); + invalidateApiKeys(); await logAudit(req, 'user.update', { id, role: newRole, is_active: isActive }); const fresh = await pool.query( `SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at, @@ -1798,10 +1968,144 @@ app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => { } await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]); invalidateSessions(); + invalidateApiKeys(); await logAudit(req, 'user.delete', { id: req.params.id }); res.json({ ok: true }); }); +// --- API keys (admin) --- +function apiKeyOwnerScope(req) { + if (req.user.role === 'admin') return { where: '', params: [] }; + return { where: ' AND k.user_id = $1', params: [req.user.id] }; +} + +async function apiKeyAllowedBranches(user, value) { + const ids = [...new Set((Array.isArray(value) ? value : []).map(Number).filter(Boolean))]; + if (!ids.length) return []; + const s = branchScope(user); + const allowed = s.admin ? ids : ids.filter(id => s.ids.includes(id)); + if (allowed.length !== ids.length) return null; + const { rows } = await pool.query('SELECT id FROM branches WHERE id = ANY($1::int[])', [allowed]); + return rows.length === allowed.length ? allowed : null; +} + +function apiKeyExpiry(value) { + if (value === null || value === undefined || value === '') return null; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return undefined; + return d; +} + +function apiKeyRateValue(value) { + if (value === null || value === undefined || value === '') return { ok: true, value: null }; + const n = Number(value); + if (!Number.isInteger(n) || n < 1 || n > API_KEY_MAX_RPM) return { ok: false, value: null }; + return { ok: true, value: n }; +} + +app.get('/api/api-keys/meta', requireAdmin, async (_, res) => { + res.json({ scopes: API_KEY_SCOPES, default_rpm: API_KEY_DEFAULT_RPM }); +}); + +app.get('/api/api-keys', requireAuth, requireAdmin, async (req, res) => { + const scope = apiKeyOwnerScope(req); + const { rows } = await pool.query( + `SELECT k.*, u.username, u.name AS user_name FROM api_keys k + JOIN users u ON u.id = k.user_id + WHERE 1=1${scope.where} + ORDER BY k.id DESC`, + scope.params + ); + res.json(rows.map(apiKeyPublic)); +}); + +app.post('/api/api-keys', requireAuth, requireAdmin, async (req, res) => { + const name = reqStr(req.body?.name, API_KEY_MAX_NAME); + const scopes = normalizeApiScopes(req.body?.scopes); + const expiresAt = apiKeyExpiry(req.body?.expires_at); + if (expiresAt === undefined) return res.status(400).json({ error: 'Некорректная дата окончания' }); + const branchIds = await apiKeyAllowedBranches(req.user, req.body?.branch_ids); + if (!branchIds) return res.status(400).json({ error: 'Недопустимый список филиалов' }); + const rate = apiKeyRateValue(req.body?.rate_limit_per_min); + if (!rate.ok) return res.status(400).json({ error: 'Некорректный лимит запросов' }); + const secret = crypto.randomBytes(32).toString('hex'); + const raw = `${API_KEY_PREFIX}_${secret}`; + const { rows } = await pool.query( + `INSERT INTO api_keys (user_id, name, prefix, key_hash, scopes, branch_ids, rate_limit_per_min, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [req.user.id, name, raw.slice(0, 12), hashApiKey(raw), scopes, branchIds, rate.value, expiresAt ? expiresAt.toISOString() : null] + ); + invalidateApiKeys(); + await logAudit(req, 'api_key.create', { id: rows[0].id, name, scopes, branch_ids: branchIds, expires_at: rows[0].expires_at }); + res.status(201).json({ ...apiKeyPublic(rows[0]), key: raw }); +}); + +app.put('/api/api-keys/:id', requireAuth, requireAdmin, async (req, res) => { + const current = await pool.query('SELECT * FROM api_keys WHERE id = $1', [req.params.id]); + if (!current.rows.length) return res.status(404).json({ error: 'Ключ не найден' }); + if (req.user.role !== 'admin' && current.rows[0].user_id !== req.user.id) { + return res.status(403).json({ error: 'Forbidden' }); + } + const target = current.rows[0]; + const name = req.body?.name !== undefined ? reqStr(req.body.name, API_KEY_MAX_NAME) : target.name; + const scopes = req.body?.scopes !== undefined ? normalizeApiScopes(req.body.scopes) : target.scopes; + let branchIds = target.branch_ids || []; + if (req.body?.branch_ids !== undefined) { + const allowed = await apiKeyAllowedBranches(req.user, req.body.branch_ids); + if (!allowed) return res.status(400).json({ error: 'Недопустимый список филиалов' }); + branchIds = allowed; + } + let expiresAt = target.expires_at; + if (req.body?.expires_at !== undefined) { + const parsed = apiKeyExpiry(req.body.expires_at); + if (parsed === undefined) return res.status(400).json({ error: 'Некорректная дата окончания' }); + expiresAt = parsed ? parsed.toISOString() : null; + } + let rateValue = target.rate_limit_per_min; + if (req.body?.rate_limit_per_min !== undefined) { + const rate = apiKeyRateValue(req.body.rate_limit_per_min); + if (!rate.ok) return res.status(400).json({ error: 'Некорректный лимит запросов' }); + rateValue = rate.value; + } + const { rows } = await pool.query( + `UPDATE api_keys SET name = $1, scopes = $2, branch_ids = $3, rate_limit_per_min = $4, expires_at = $5 + WHERE id = $6 RETURNING *`, + [name, scopes, branchIds, rateValue, expiresAt, req.params.id] + ); + invalidateApiKeys(); + await logAudit(req, 'api_key.update', { id: rows[0].id, name, scopes, branch_ids: branchIds, expires_at: expiresAt }); + res.json(apiKeyPublic(rows[0])); +}); + +app.delete('/api/api-keys/:id', requireAuth, requireAdmin, async (req, res) => { + const current = await pool.query('SELECT * FROM api_keys WHERE id = $1', [req.params.id]); + if (!current.rows.length) return res.status(404).json({ error: 'Ключ не найден' }); + if (req.user.role !== 'admin' && current.rows[0].user_id !== req.user.id) { + return res.status(403).json({ error: 'Forbidden' }); + } + await pool.query('DELETE FROM api_keys WHERE id = $1', [req.params.id]); + invalidateApiKeys(); + await logAudit(req, 'api_key.delete', { id: req.params.id, name: current.rows[0].name }); + res.json({ ok: true }); +}); + +app.post('/api/api-keys/:id/rotate', requireAuth, requireAdmin, async (req, res) => { + const current = await pool.query('SELECT * FROM api_keys WHERE id = $1', [req.params.id]); + if (!current.rows.length) return res.status(404).json({ error: 'Ключ не найден' }); + if (req.user.role !== 'admin' && current.rows[0].user_id !== req.user.id) { + return res.status(403).json({ error: 'Forbidden' }); + } + const secret = crypto.randomBytes(32).toString('hex'); + const raw = `${API_KEY_PREFIX}_${secret}`; + const { rows } = await pool.query( + 'UPDATE api_keys SET prefix = $1, key_hash = $2, revoked_at = NULL, last_used_at = NULL WHERE id = $3 RETURNING *', + [raw.slice(0, 12), hashApiKey(raw), req.params.id] + ); + invalidateApiKeys(); + await logAudit(req, 'api_key.rotate', { id: rows[0].id, name: rows[0].name }); + res.json({ ...apiKeyPublic(rows[0]), key: raw }); +}); + // --- Settings --- app.get('/api/settings', requireAdmin, async (_, res) => { const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key'); @@ -2422,6 +2726,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req await client.query('DELETE FROM groups'); await client.query('DELETE FROM user_branches'); await client.query('DELETE FROM sessions'); + await client.query('DELETE FROM api_keys'); await client.query('DELETE FROM audit_log'); await client.query('DELETE FROM users'); await client.query('DELETE FROM branches'); @@ -6685,6 +6990,507 @@ app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => { res.json({ ok: true, entries: e.rowCount, groups: g.rowCount, days }); }); +// --- External API v1 --- +const apiV1 = express.Router(); +apiV1.use(requireApiKey('read')); +apiV1.use(apiKeyLimiter); + +function apiPage(req) { + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 500); + const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); + return { limit, offset }; +} + +function apiList(rows, total, limit, offset) { + return { items: rows, total, limit, offset }; +} + +app.use('/api/v1', apiV1); + +apiV1.get('/me', async (req, res) => { + res.json({ + key: { id: req.apiKey.id, name: req.apiKey.name, scopes: req.apiKey.scopes }, + user: safeUser(req.user), + server_time: new Date().toISOString(), + }); +}); + +apiV1.get('/branches', async (req, res) => { + const s = branchScope(req.user); + const params = []; + let where = ''; + if (!s.admin) { + if (!s.ids.length) return res.json(apiList([], 0, 50, 0)); + where = ` WHERE b.id = ANY($${params.push(s.ids)}::int[])`; + } + const { rows } = await pool.query( + `SELECT b.id, b.name, b.address, b.phone, b.created_at, + count(g.id)::int AS groups_count + FROM branches b + LEFT JOIN groups g ON g.branch_id = b.id + ${where} + GROUP BY b.id + ORDER BY b.id`, + params + ); + res.json(apiList(rows, rows.length, rows.length, 0)); +}); + +apiV1.get('/groups', async (req, res) => { + const { limit, offset } = apiPage(req); + const bw = branchWhere(req.user, 'g'); + const params = bw.params.slice(); + const active = req.query.deleted === '1' ? 'AND g.deleted_at IS NOT NULL' : 'AND g.deleted_at IS NULL'; + const { rows: crows } = await pool.query( + `SELECT count(*)::int AS n FROM groups g WHERE 1=1 ${active}${bw.where}`, + params + ); + const total = crows[0].n; + const q = `SELECT g.id, g.name, g.branch_id, b.name AS branch_name, g.day_of_week, + g.time_start, g.time_end, g.cover_path, g.tutor_id, g.created_at, g.deleted_at + FROM groups g + LEFT JOIN branches b ON b.id = g.branch_id + WHERE 1=1 ${active}${bw.where} + ORDER BY g.id + LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`; + const { rows } = await pool.query(q, params); + res.json(apiList(rows, total, limit, offset)); +}); + +apiV1.get('/groups/:id', async (req, res) => { + if (!(await groupBelongsToBranches(req.user, req.params.id))) { + return res.status(403).json({ error: 'Нет доступа к этой группе' }); + } + const { rows } = await pool.query( + `SELECT g.id, g.name, g.branch_id, b.name AS branch_name, g.day_of_week, + g.time_start, g.time_end, g.cover_path, g.tutor_id, g.created_at, g.deleted_at + FROM groups g LEFT JOIN branches b ON b.id = g.branch_id + WHERE g.id = $1`, + [req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(rows[0]); +}); + +apiV1.get('/students', async (req, res) => { + const { limit, offset } = apiPage(req); + const bw = branchWhere(req.user, 'g'); + const params = bw.params.slice(); + const search = String(req.query.search || '').trim(); + if (search) params.push(`%${search}%`); + const extra = search ? ` AND s.name ILIKE $${params.length}` : ''; + const { rows: crows } = await pool.query( + `SELECT count(*)::int AS n FROM students s + LEFT JOIN groups g ON g.id = s.group_id + WHERE 1=1${extra}${bw.where}`, + params + ); + const total = crows[0].n; + const q = `SELECT s.id, s.name, s.group_id, g.name AS group_name, s.photo_path, s.created_at + FROM students s + LEFT JOIN groups g ON g.id = s.group_id + WHERE 1=1${extra}${bw.where} + ORDER BY s.name + LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`; + const { rows } = await pool.query(q, params); + res.json(apiList(rows, total, limit, offset)); +}); + +apiV1.get('/students/:id', async (req, res) => { + const { rows } = await pool.query( + `SELECT s.id, s.name, s.group_id, g.name AS group_name, s.photo_path, s.profile, s.created_at + FROM students s + LEFT JOIN groups g ON g.id = s.group_id + WHERE s.id = $1`, + [req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + if (req.user.role !== 'admin' && rows[0].group_id) { + if (!(await groupBelongsToBranches(req.user, rows[0].group_id))) { + return res.status(403).json({ error: 'Нет доступа к этому ученику' }); + } + } + res.json(rows[0]); +}); + +apiV1.get('/modules', async (req, res) => { + const { limit, offset } = apiPage(req); + const params = []; + const conditions = []; + if (req.query.search?.trim()) { params.push(`%${req.query.search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); } + if (req.query.active === '1' || req.query.active === 'true') conditions.push('m.is_active = true'); + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; + const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params); + const total = crows[0].n; + const q = `SELECT m.id, m.name, m.lessons_count, m.is_active, m.created_at, count(e.id)::int AS entries_count + FROM modules m + LEFT JOIN entries e ON e.module_id = m.id${where} + GROUP BY m.id + ORDER BY m.is_active DESC, m.id + LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`; + const { rows } = await pool.query(q, params); + res.json(apiList(rows, total, limit, offset)); +}); + +apiV1.get('/stats', async (req, res) => { + const s = branchScope(req.user); + const params = []; + let gf = ''; + if (!s.admin) { + if (!s.ids.length) return res.json({ entries: 0, groups: 0, students: 0, today: 0 }); + gf = ` AND g.branch_id IN (${s.ids.map(id => `$${params.push(id)}`).join(',')})`; + } + const todayParams = params.slice(); + const todaySql = `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id + WHERE e.deleted_at IS NULL AND e.created_at >= ${tzWall()}::date${gf}` + .replace(/\$TZ\$/g, `$${todayParams.push(await appTimezone())}`); + const [entries, groups, students, today] = await Promise.all([ + pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${gf}`, params), + pool.query(`SELECT count(*)::int AS n FROM groups g WHERE g.deleted_at IS NULL${gf}`, params), + pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${gf}`, params), + pool.query(todaySql, todayParams), + ]); + res.json({ + entries: entries.rows[0].n, + groups: groups.rows[0].n, + students: students.rows[0].n, + today: today.rows[0].n, + }); +}); + +apiV1.get('/entries', async (req, res) => { + const { limit, offset } = apiPage(req); + const conditions = ['e.deleted_at IS NULL']; + const params = []; + if (req.query.group_id) { params.push(req.query.group_id); conditions.push(`e.group_id = $${params.length}`); } + if (req.query.module_id) { params.push(req.query.module_id); conditions.push(`e.module_id = $${params.length}`); } + if (req.query.student_name) { params.push(req.query.student_name); conditions.push(`e.student_name = $${params.length}`); } + if (req.query.search) { params.push(`%${req.query.search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); } + if (req.query.date_from) { params.push(req.query.date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); } + if (req.query.date_to) { params.push(req.query.date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); } + const s = branchScope(req.user); + if (!s.admin) { + if (!s.ids.length) conditions.push('1 = 0'); + else conditions.push(`g.branch_id IN (${s.ids.map(id => `$${params.push(id)}`).join(',')})`); + } + const bound = bindTz(' WHERE ' + conditions.join(' AND '), params, await appTimezone()); + const { rows: crows } = await pool.query( + `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${bound.sql}`, + bound.params + ); + const total = crows[0].n; + const q = `SELECT e.id, e.student_name, e.group_id, g.name AS group_name, e.module_id, m.name AS module_name, + e.description, e.photo_path, e.created_at + FROM entries e + JOIN groups g ON g.id = e.group_id + LEFT JOIN modules m ON m.id = e.module_id${bound.sql} + ORDER BY e.created_at DESC + LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`; + const { rows } = await pool.query(q, bound.params); + res.json(apiList(rows, total, limit, offset)); +}); + +apiV1.get('/entries/:id', async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + const { rows } = await pool.query( + `SELECT e.id, e.student_name, e.group_id, g.name AS group_name, e.module_id, m.name AS module_name, + e.description, e.description_original, e.photo_path, e.ai_status, e.created_at, e.deleted_at + FROM entries e + JOIN groups g ON g.id = e.group_id + LEFT JOIN modules m ON m.id = e.module_id + WHERE e.id = $1`, + [req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + const entry = rows[0]; + const fRes = await pool.query('SELECT id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id', [entry.id]); + entry.files = fRes.rows; + res.json(entry); +}); + +apiV1.get('/entries/:id/files', async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + const { rows } = await pool.query( + 'SELECT id, token, name, created_at FROM project_files WHERE entry_id = $1 ORDER BY id', + [req.params.id] + ); + res.json(apiList(rows, rows.length, rows.length, 0)); +}); + +apiV1.get('/lesson-reports', async (req, res) => { + const { limit, offset } = apiPage(req); + const conditions = []; + const params = []; + if (req.query.group_id) { params.push(req.query.group_id); conditions.push(`lr.group_id = $${params.length}`); } + if (req.query.date_from) { params.push(req.query.date_from); conditions.push(`lr.lesson_date >= $${params.length}::date`); } + if (req.query.date_to) { params.push(req.query.date_to); conditions.push(`lr.lesson_date <= $${params.length}::date`); } + if (req.query.search?.trim()) { params.push(`%${req.query.search.trim()}%`); conditions.push(`lr.text ILIKE $${params.length}`); } + const s = branchScope(req.user); + if (!s.admin) { + if (!s.ids.length) conditions.push('1 = 0'); + else conditions.push(`g.branch_id IN (${s.ids.map(id => `$${params.push(id)}`).join(',')})`); + } + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; + const from = `FROM lesson_reports lr JOIN groups g ON g.id = lr.group_id${where}`; + const { rows: crows } = await pool.query(`SELECT count(*)::int AS n ${from}`, params); + const total = crows[0].n; + const { rows } = await pool.query( + `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.topic, lr.text, + lr.ai_status, lr.author_id, lr.created_at, lr.updated_at, g.name AS group_name + ${from} + ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST, lr.id DESC + LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`, + params + ); + res.json(apiList(rows, total, limit, offset)); +}); + +apiV1.get('/lesson-reports/:id', async (req, res) => { + const { report, error, code } = await lessonReportById(req.user, req.params.id); + if (!report) return res.status(code || 404).json({ error }); + res.json(report); +}); + +function apiWrite(scope) { + return (req, res, next) => { + if (!req.apiKey || !req.apiKey.scopes.includes(scope)) { + return res.status(403).json({ error: `API key lacks scope: ${scope}` }); + } + next(); + }; +} + +apiV1.post('/entries', apiWrite('write'), async (req, res) => { + const studentName = reqStr(req.body?.student_name, 150); + const description = reqStr(req.body?.description, 20000); + const grp = await lessonReportGroup(req.user, req.body?.group_id); + if (grp.error) return res.status(grp.code || 400).json({ error: grp.error }); + let mid = null; + if (req.body?.module_id !== undefined && req.body?.module_id !== null && req.body?.module_id !== '') { + const parsed = optInt(req.body.module_id, 1); + if (!parsed) return res.status(400).json({ error: 'Модуль не найден' }); + const mod = await pool.query('SELECT id FROM modules WHERE id = $1', [parsed]); + if (!mod.rows.length) return res.status(400).json({ error: 'Модуль не найден' }); + mid = parsed; + } + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING', [studentName]); + const { rows } = await client.query( + `INSERT INTO entries (student_name, group_id, module_id, description, description_original) + VALUES ($1, $2, $3, $4, $4) RETURNING *`, + [studentName, grp.group.id, mid, description] + ); + await client.query('COMMIT'); + await apiAudit(req, 'api.entry.create', { id: rows[0].id, group_id: grp.group.id, student_name: studentName }); + invalidateEntries(); + invalidateStats(); + broadcastEntryChanged(); + res.status(201).json(rows[0]); + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + throw e; + } finally { + client.release(); + } +}); + +apiV1.put('/entries/:id', apiWrite('write'), async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + const before = await pool.query( + 'SELECT id, student_name, group_id, module_id, description FROM entries WHERE id = $1', + [req.params.id] + ); + if (!before.rows.length) return res.status(404).json({ error: 'Not found' }); + const studentName = req.body?.student_name !== undefined ? reqStr(req.body.student_name, 150) : null; + const description = req.body?.description !== undefined ? reqStr(req.body.description, 20000) : null; + let groupId = null; + if (req.body?.group_id !== undefined) { + const grp = await lessonReportGroup(req.user, req.body.group_id); + if (grp.error) return res.status(grp.code || 400).json({ error: grp.error }); + groupId = grp.group.id; + } + let hasModule = false; + let mid = null; + if (req.body?.module_id !== undefined) { + hasModule = true; + if (req.body.module_id !== null && req.body.module_id !== '') { + const parsed = optInt(req.body.module_id, 1); + if (!parsed) return res.status(400).json({ error: 'Модуль не найден' }); + const mod = await pool.query('SELECT id FROM modules WHERE id = $1', [parsed]); + if (!mod.rows.length) return res.status(400).json({ error: 'Модуль не найден' }); + mid = parsed; + } + } + const { rows } = await pool.query( + `UPDATE entries SET + student_name = COALESCE($1, student_name), + group_id = COALESCE($2, group_id), + description = COALESCE($3, description), + description_original = COALESCE($3, description_original), + module_id = CASE WHEN $4::boolean THEN $5::int ELSE module_id END + WHERE id = $6 RETURNING *`, + [studentName, groupId, description, hasModule, mid, req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + await apiAudit(req, 'api.entry.update', { id: rows[0].id, before: before.rows[0] }); + invalidateEntries(); + invalidateStats(); + broadcastEntryChanged(); + res.json(rows[0]); +}); + +apiV1.delete('/entries/:id', apiWrite('write'), async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + await pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL', [req.params.id]); + await apiAudit(req, 'api.entry.delete', { id: req.params.id }); + invalidateEntries(); + invalidateStats(); + broadcastEntryChanged(); + res.json({ ok: true }); +}); + +apiV1.post('/lesson-reports', apiWrite('write'), async (req, res) => { + const grp = await lessonReportGroup(req.user, req.body?.group_id); + if (grp.error) return res.status(grp.code || 400).json({ error: grp.error }); + const date = parseLessonReportDate(req.body?.lesson_date); + if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' }); + const time = parseLessonReportTime(req.body?.lesson_time); + if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' }); + const topic = typeof req.body?.topic === 'string' ? req.body.topic.trim() : ''; + if (topic.length > LESSON_REPORT_TOPIC_MAX) return res.status(400).json({ error: `Тема занятия длиннее ${LESSON_REPORT_TOPIC_MAX} символов` }); + const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''; + if (!text) return res.status(400).json({ error: 'Введите текст отчёта' }); + if (text.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` }); + const existing = await pool.query('SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2', [grp.group.id, date]); + if (existing.rows.length) { + return res.status(409).json({ error: 'За эту группу и дату отчёт уже есть — откройте его для редактирования', id: existing.rows[0].id }); + } + const aiWanted = req.body?.ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false'; + const { rows } = await pool.query( + `INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id) + VALUES ($1, $2::date, $3, $4, $5, $6, NULL, $7::text, CASE WHEN $7::text = 'none' THEN NULL ELSE now() END, NULL, $8, $9) RETURNING *`, + [grp.group.id, date, time, topic || null, text, aiWanted ? text : null, aiWanted ? 'pending' : 'none', req.user.id || null, grp.group.branch_id || null] + ); + const created = rows[0]; + await saveLessonReportVersion(created.id, text, 'manual', req.user.id); + await apiAudit(req, 'api.lesson_report.create', { id: created.id, group_id: grp.group.id, lesson_date: date }); + invalidateLessonReports(); + if (aiWanted) wakeLessonAiWorker(); + res.status(201).json(created); +}); + +apiV1.put('/lesson-reports/:id', apiWrite('write'), async (req, res) => { + const { report, error, code } = await lessonReportById(req.user, req.params.id); + if (!report) return res.status(code || 404).json({ error }); + const curDate = String(report.lesson_date).slice(0, 10); + let date = curDate; + if (req.body?.lesson_date !== undefined && req.body?.lesson_date !== null && req.body?.lesson_date !== '') { + date = parseLessonReportDate(req.body.lesson_date); + if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' }); + } + let time; + if (req.body?.lesson_time !== undefined) { + time = parseLessonReportTime(req.body.lesson_time); + if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' }); + } + const body = req.body?.text === undefined ? null : (typeof req.body.text === 'string' ? req.body.text.trim() : ''); + if (req.body?.text !== undefined && !body) return res.status(400).json({ error: 'Введите текст отчёта' }); + if (body && body.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` }); + let topicText; + if (req.body?.topic !== undefined) { + topicText = typeof req.body.topic === 'string' ? req.body.topic.trim() : ''; + if (topicText.length > LESSON_REPORT_TOPIC_MAX) return res.status(400).json({ error: `Тема занятия длиннее ${LESSON_REPORT_TOPIC_MAX} символов` }); + } + if (date !== curDate) { + const clash = await pool.query('SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2 AND id <> $3', [report.group_id, date, report.id]); + if (clash.rows.length) return res.status(409).json({ error: 'За эту группу и дату уже есть другой отчёт' }); + } + const nextTime = time === undefined ? report.lesson_time : time; + const aiWanted = !!body && req.body?.ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false'; + const { rows } = await pool.query( + `UPDATE lesson_reports SET + lesson_date = $1::date, + lesson_time = $2, + topic = CASE WHEN $6::boolean THEN $3::text ELSE topic END, + text = COALESCE($4, text), + text_original = CASE WHEN $5::boolean THEN $4::text ELSE text_original END, + text_ai = CASE WHEN $5::boolean THEN NULL ELSE text_ai END, + ai_status = CASE WHEN $5::boolean THEN 'pending'::varchar ELSE ai_status END, + ai_checked_at = CASE WHEN $5::boolean THEN now() ELSE ai_checked_at END, + ai_error = CASE WHEN $5::boolean THEN NULL ELSE ai_error END, + updated_at = now() + WHERE id = $7 RETURNING *`, + [date, nextTime, topicText === undefined ? null : (topicText || null), body, aiWanted, req.body?.topic !== undefined, report.id] + ); + if (body) await saveLessonReportVersion(report.id, body, 'manual', req.user.id); + await apiAudit(req, 'api.lesson_report.update', { id: report.id, lesson_date: date }); + invalidateLessonReports(); + if (aiWanted) wakeLessonAiWorker(); + res.json(rows[0]); +}); + +apiV1.delete('/lesson-reports/:id', apiWrite('write'), async (req, res) => { + const { report, error, code } = await lessonReportById(req.user, req.params.id); + if (!report) return res.status(code || 404).json({ error }); + await pool.query('DELETE FROM lesson_reports WHERE id = $1', [report.id]); + await apiAudit(req, 'api.lesson_report.delete', { id: report.id }); + invalidateLessonReports(); + res.json({ ok: true }); +}); + +apiV1.post('/students', apiWrite('write'), async (req, res) => { + const name = reqStr(req.body?.name, 150); + let gid = null; + if (req.body?.group_id !== undefined && req.body?.group_id !== null && req.body?.group_id !== '') { + const grp = await lessonReportGroup(req.user, req.body.group_id); + if (grp.error) return res.status(grp.code || 400).json({ error: grp.error }); + gid = grp.group.id; + } + const { rows } = await pool.query('INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *', [name, gid]); + await apiAudit(req, 'api.student.create', { id: rows[0].id, name }); + invalidateStudents(); + invalidateStats(); + res.status(201).json(rows[0]); +}); + +apiV1.put('/students/:id', apiWrite('write'), async (req, res) => { + const name = reqStr(req.body?.name, 150); + const cur = await pool.query('SELECT id, group_id FROM students WHERE id = $1', [req.params.id]); + if (!cur.rows.length) return res.status(404).json({ error: 'Not found' }); + if (req.user.role !== 'admin' && cur.rows[0].group_id && !(await groupBelongsToBranches(req.user, cur.rows[0].group_id))) { + return res.status(403).json({ error: 'Нет доступа к этому ученику' }); + } + let gid = null; + if (req.body?.group_id !== undefined && req.body?.group_id !== null && req.body?.group_id !== '') { + const grp = await lessonReportGroup(req.user, req.body.group_id); + if (grp.error) return res.status(grp.code || 400).json({ error: grp.error }); + gid = grp.group.id; + } + const { rows } = await pool.query('UPDATE students SET name = $1, group_id = $2 WHERE id = $3 RETURNING *', [name, gid, req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + await apiAudit(req, 'api.student.update', { id: rows[0].id, name }); + invalidateStudents(); + res.json(rows[0]); +}); + // --- Error handlers --- const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');