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

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

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

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

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