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:
@@ -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)
|
- **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/`
|
- **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
|
- **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`
|
- **`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`; фактическая зона берётся из настройки
|
- **`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
|
### 4. API Patterns
|
||||||
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
- **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` и фильтр не добавляется
|
- **Филиалы**: `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
|
# Audit diff checks
|
||||||
node diff.selftest.js # unit, no stack needed
|
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
|
`diff.js` builds the audit payload for text changes: word-level segments
|
||||||
@@ -301,6 +322,7 @@ guards against.
|
|||||||
- [ ] Rate limiter on new public routes
|
- [ ] Rate limiter on new public routes
|
||||||
- [ ] Admin routes behind `requireAdmin`
|
- [ ] Admin routes behind `requireAdmin`
|
||||||
- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change
|
- [ ] 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
|
- [ ] No secrets in code — only via env vars
|
||||||
- [ ] Helmet headers present (already global)
|
- [ ] Helmet headers present (already global)
|
||||||
- [ ] CORS disabled (no `cors` middleware)
|
- [ ] 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 |
|
| `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 |
|
| `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.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 |
|
| `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/init.sql` | Initial schema (runs on fresh DB) |
|
||||||
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL '
|
|||||||
```bash
|
```bash
|
||||||
node redis.selftest.js # юнит-тесты redis.js
|
node redis.selftest.js # юнит-тесты redis.js
|
||||||
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
|
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 запрещены.
|
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
||||||
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
- **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.
|
- **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `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 секунд.
|
Сессия хранится в таблице `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.js # пословный diff текста и сборка изменений записи для аудита
|
||||||
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
|
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
|
||||||
├── api.smoketest.js # сквозная проверка API по поднятому стеку
|
├── api.smoketest.js # сквозная проверка API по поднятому стеку
|
||||||
|
├── api-keys.selftest.js # тесты внешнего API: ключи, права, филиалы, rate limit
|
||||||
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
|
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
|
||||||
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
||||||
├── db/
|
├── db/
|
||||||
|
|||||||
@@ -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
@@ -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_token ON sessions(token);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
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 (
|
CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -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_token ON sessions(token);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
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;
|
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 (
|
CREATE TABLE IF NOT EXISTS group_photos (
|
||||||
|
|||||||
+3
-1
@@ -63,6 +63,7 @@ function buildSidebar(active) {
|
|||||||
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
|
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
|
||||||
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
||||||
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
||||||
|
{ page: 'apikeys', label: 'API-ключи', icon: 'key-round' },
|
||||||
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
||||||
{ page: 'audit', label: 'Аудит', icon: 'scroll-text' },
|
{ page: 'audit', label: 'Аудит', icon: 'scroll-text' },
|
||||||
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
||||||
@@ -399,10 +400,11 @@ function renderIcons() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(msg) {
|
function showToast(msg, isError) {
|
||||||
const t = document.getElementById('toast');
|
const t = document.getElementById('toast');
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
|
t.classList.toggle('error', !!isError);
|
||||||
t.classList.add('show');
|
t.classList.add('show');
|
||||||
setTimeout(() => t.classList.remove('show'), 3000);
|
setTimeout(() => t.classList.remove('show'), 3000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <ключ></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>
|
||||||
@@ -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="Удалить">×</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();
|
||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const { Pool, types } = require('pg');
|
const { Pool, types } = require('pg');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { ipKeyGenerator } = require('express-rate-limit');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const heicConvert = require('heic-convert');
|
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 invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||||||
function invalidateLessonReports() { cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:'); }
|
function invalidateLessonReports() { cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:'); }
|
||||||
function invalidateSessions() { cacheDrop('session:'); }
|
function invalidateSessions() { cacheDrop('session:'); }
|
||||||
|
function invalidateApiKeys() { cacheDrop('apikey:'); cacheDrop('rl:apikey'); }
|
||||||
|
|
||||||
const EVENTS_CHANNEL = 'whatido:events';
|
const EVENTS_CHANNEL = 'whatido:events';
|
||||||
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
|
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
|
||||||
@@ -460,6 +462,7 @@ const BAN_REASON_LABELS = {
|
|||||||
honeypot: 'Антиспам-поле',
|
honeypot: 'Антиспам-поле',
|
||||||
'login-bruteforce': 'Подбор пароля входа',
|
'login-bruteforce': 'Подбор пароля входа',
|
||||||
'share-password-bruteforce': 'Подбор пароля ссылки',
|
'share-password-bruteforce': 'Подбор пароля ссылки',
|
||||||
|
'apikey-bruteforce': 'Подбор API-ключа',
|
||||||
manual: 'Вручную',
|
manual: 'Вручную',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -966,6 +969,150 @@ async function optionalAuth(req, res, next) {
|
|||||||
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) {
|
function branchWhere(user, alias) {
|
||||||
const s = branchScope(user);
|
const s = branchScope(user);
|
||||||
if (s.admin) return { where: '', params: [] };
|
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');
|
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() {
|
async function ensureFirstAdmin() {
|
||||||
if (!ADMIN_PASSWORD) return;
|
if (!ADMIN_PASSWORD) return;
|
||||||
const { rows } = await pool.query('SELECT id FROM users WHERE role = \'admin\' LIMIT 1');
|
const { rows } = await pool.query('SELECT id FROM users WHERE role = \'admin\' LIMIT 1');
|
||||||
@@ -1451,6 +1619,7 @@ async function ensureFirstAdmin() {
|
|||||||
|
|
||||||
async function ensureUsersAndFirstAdmin() {
|
async function ensureUsersAndFirstAdmin() {
|
||||||
await ensureUserTables();
|
await ensureUserTables();
|
||||||
|
await ensureApiKeysTable();
|
||||||
await ensureFirstAdmin();
|
await ensureFirstAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1775,6 +1944,7 @@ app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
invalidateSessions();
|
invalidateSessions();
|
||||||
|
invalidateApiKeys();
|
||||||
await logAudit(req, 'user.update', { id, role: newRole, is_active: isActive });
|
await logAudit(req, 'user.update', { id, role: newRole, is_active: isActive });
|
||||||
const fresh = await pool.query(
|
const fresh = await pool.query(
|
||||||
`SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at,
|
`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]);
|
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||||
invalidateSessions();
|
invalidateSessions();
|
||||||
|
invalidateApiKeys();
|
||||||
await logAudit(req, 'user.delete', { id: req.params.id });
|
await logAudit(req, 'user.delete', { id: req.params.id });
|
||||||
res.json({ ok: true });
|
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 ---
|
// --- Settings ---
|
||||||
app.get('/api/settings', requireAdmin, async (_, res) => {
|
app.get('/api/settings', requireAdmin, async (_, res) => {
|
||||||
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
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 groups');
|
||||||
await client.query('DELETE FROM user_branches');
|
await client.query('DELETE FROM user_branches');
|
||||||
await client.query('DELETE FROM sessions');
|
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 audit_log');
|
||||||
await client.query('DELETE FROM users');
|
await client.query('DELETE FROM users');
|
||||||
await client.query('DELETE FROM branches');
|
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 });
|
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 ---
|
// --- Error handlers ---
|
||||||
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
|
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user