feat(api): внешний API и API-ключи для интеграций

Отдельный префикс /api/v1 со своей авторификацией по API-ключам,
чтобы внешние системы могли забирать и менять данные, не получая
доступа к админке.

Что добавлено:
- таблица api_keys (db/init.sql, db/migration.sql, ensureApiKeysTable)
- CRUD ключей: GET/POST /api/api-keys, PUT/DELETE /:id, POST /:id/rotate
- requireApiKey: X-Api-Key или Authorization: Bearer, только для /api/v1/*
- 21 эндпоинт /api/v1: branches, groups, students, modules, entries,
  lesson-reports, stats, me; списки в формате {items,total,limit,offset}
- страница управления ключами public/apikeys.html + пункт в меню

Безопасность:
- в БД только sha256(ключ) и префикс, секрет отдаётся один раз
- скоупы read/write: без write мутации дают 403
- branch_ids ключа сужают права и понижают роль до tutor
- per-key rate limit на cache.rateLimitStore, подбор ключей -> бан IP
- аудит мутаций с меткой via_api_key
- ключи не входят в бэкап и удаляются при restore

Проверено: api-keys.selftest.js (45 проверок), api.smoketest.js без
регрессий, работа без Redis через in-memory fallback.
This commit is contained in:
dev
2026-10-04 23:12:35 +03:00
parent d77df46092
commit 678cb97bb9
9 changed files with 1386 additions and 3 deletions
+24 -1
View File
@@ -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:<sha256>` в Redis, TTL `SESSION_CACHE_TTL_MS` (30 с). Любая мутация ключа, а также смена роли/активности/филиалов пользователя (`PUT`/`DELETE /api/users/:id`) обязана звать `invalidateApiKeys()` — иначе отозванный ключ продолжит работать до истечения кэша
- **`last_used_at` троттлится** маркером `apikey:touch:<id>` (5 мин), а не пишется на каждый запрос
- **Rate limit**: отдельный `apiKeyLimiter` на `cache.rateLimitStore('apikey', 60s)`; лимит — функция от `rate_limit_per_min` ключа (по умолчанию `API_KEY_DEFAULT_RPM` = 120). Ключ счёта — `k<id>` по `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: <id>` в `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 |
+49 -1
View File
@@ -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/
+178
View File
@@ -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); });
+20
View File
@@ -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,
+20
View File
@@ -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 (
+3 -1
View File
@@ -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);
}
+140
View File
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>API-ключи — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="apikeys">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>API-ключи</h2>
<p class="page-sub">Доступ к данным из внешних систем по HTTP API</p>
</div>
<div class="actions-row" style="margin-bottom:16px">
<button class="btn-primary" id="addKeyBtn" type="button">
<span style="font-size:1.2rem">+</span> Создать ключ
</button>
</div>
<div class="audit-wrap">
<table class="audit-table" id="keysTable">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Ключ</th>
<th>Права</th>
<th>Филиалы</th>
<th>Создан</th>
<th>Использован</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="keysEmpty" style="display:none">API-ключи не созданы</div>
</div>
<div class="settings-card" style="margin-top:16px">
<div class="card-head">
<i data-lucide="terminal"></i>
<h3>Как использовать</h3>
</div>
<p class="hint">Базовый адрес: <code>/api/v1</code>. Ключ передаётся в заголовке <code>X-Api-Key</code> или <code>Authorization: Bearer &lt;ключ&gt;</code>.</p>
<pre class="code-block">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</pre>
<p class="hint">Секрет показывается один раз при создании и ротации — в базе хранится только SHA-256 хеш. Эндпоинты: <code>/me</code>, <code>/branches</code>, <code>/groups</code>, <code>/students</code>, <code>/modules</code>, <code>/entries</code>, <code>/lesson-reports</code>, <code>/stats</code>.</p>
</div>
</div>
</div>
<div class="modal-overlay" id="keyModal">
<div class="edit-modal" style="max-width:520px">
<h3 id="keyModalTitle">Новый API-ключ</h3>
<div class="settings-stack" style="gap:12px;max-width:none">
<div class="settings-field">
<label>Название <span style="color:#ef4444">*</span></label>
<input type="text" id="kName" class="settings-input" placeholder="Интеграция с 1С" autocomplete="off">
</div>
<div class="settings-field">
<label>Права доступа</label>
<div style="display:flex;gap:16px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;font-weight:400">
<input type="checkbox" id="kRead" checked> Чтение
</label>
<label style="display:flex;align-items:center;gap:6px;font-weight:400">
<input type="checkbox" id="kWrite"> Запись
</label>
</div>
<span class="hint" style="font-size:.75rem">Без права «Запись» ключ сможет только читать данные.</span>
</div>
<div class="settings-field">
<label>Филиалы (мультивыбор)</label>
<select id="kBranches" class="settings-input" multiple size="4"></select>
<span class="hint" style="font-size:.75rem">Пусто — доступ ко всем вашим филиалам. Выбор: Ctrl/Cmd+клик.</span>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Лимит запросов в минуту</label>
<input type="number" id="kRate" class="settings-input" min="1" max="10000" placeholder="120">
</div>
<div class="settings-field" style="flex:1">
<label>Действует до</label>
<input type="date" id="kExpires" class="settings-input">
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
<button type="button" class="btn-primary ghost" id="kCancel">Отмена</button>
<button type="button" class="btn-primary" id="kSave">Создать</button>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="secretModal">
<div class="edit-modal" style="max-width:560px">
<h3>Ключ создан</h3>
<p class="hint">Скопируйте ключ сейчас — второй раз он не показывается.</p>
<div class="settings-field">
<label>Ключ</label>
<input type="text" id="secretValue" class="settings-input mono" readonly>
</div>
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
<button type="button" class="btn-primary ghost" id="secretCopy">Скопировать</button>
<button type="button" class="btn-primary" id="secretClose">Готово</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/apikeys.js"></script>
<style>
.status-ok{font-size:.72rem;font-weight:600;color:#16a34a;background:rgba(22,163,74,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
.status-off{font-size:.72rem;font-weight:600;color:#ef4444;background:rgba(239,68,68,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
.status-warn{font-size:.72rem;font-weight:600;color:#d97706;background:rgba(217,119,6,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
.row-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px 8px;border-radius:6px}
.row-btn:hover{background:var(--bg);color:var(--accent)}
.row-btn.del:hover{color:#ef4444;background:rgba(239,68,68,.1)}
.key-prefix{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem;color:var(--muted)}
.key-scopes{font-size:.72rem;color:var(--muted)}
.key-meta{font-size:.74rem;color:var(--muted);white-space:nowrap}
.code-block{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;overflow-x:auto;font-size:.78rem;line-height:1.5;margin:8px 0}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
</style>
</body>
</html>
</div>
</div>
+146
View File
@@ -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 '<span class="status-off">Отозван</span>';
if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now()) return '<span class="status-off">Истёк</span>';
return '<span class="status-ok">Активен</span>';
}
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 = `
<td>${k.id}</td>
<td>${esc(k.name)}</td>
<td class="key-prefix">${esc(k.prefix || '')}…</td>
<td class="key-scopes">${esc(scopes)}</td>
<td class="key-scopes">${esc(branchNames)}</td>
<td class="key-meta">${fmtDateTimeY(k.created_at)}</td>
<td class="key-meta">${k.last_used_at ? fmtDateTimeY(k.last_used_at) : '—'}</td>
<td>${keyStatus(k)}</td>
<td style="white-space:nowrap">
<button class="row-btn" data-rot="${k.id}" title="Перевыпустить ключ">⟳</button>
<button class="row-btn del" data-del="${k.id}" title="Удалить">&times;</button>
</td>`;
tbody.appendChild(tr);
});
}
function openModal() {
const sel = document.getElementById('kBranches');
sel.innerHTML = branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).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();
+806
View File
@@ -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');