v0.5.166: /export→/v2/export редирект, экспорт только админам

This commit is contained in:
2026-06-22 17:46:52 +04:00
parent 24f1d5af77
commit 3d20c2a31d
19 changed files with 11 additions and 29 deletions
-41
View File
@@ -1,41 +0,0 @@
'use strict';
/**
* src/api/index.js — REST API v1.
*
* Монтируется в server.js ДО общего auth.middleware:
* app.use('/api/v1', createApiRouter({ auth, q }))
*
* Аутентификация: только Bearer JWT (без сессий, без CSRF).
* Ответы: JSON везде.
* Переиспользует: src/queries.js, src/auth.js (middleware для валидации токена).
*
* Эндпоинты:
* GET /api/v1/entries — список записей компании
* POST /api/v1/entries — создать запись
* PATCH /api/v1/entries/:id — обновить запись
* DELETE /api/v1/entries/:id — удалить запись
* GET /api/v1/companies — все компании (admin)
* PATCH /api/v1/companies/:id/limit — установить лимит (admin)
*/
const express = require('express');
const { requireBearer } = require('./middleware/bearerAuth');
const { createEntriesRouter } = require('./routes/entries');
const { createAdminRouter } = require('./routes/admin');
function createApiRouter({ auth, q }) {
const router = express.Router();
// Все /api/v1/* требуют Bearer — 401 JSON если нет токена
router.use(requireBearer);
// Валидация токена — Bearer-only (игнорирует сессию, всегда проверяет JWT)
router.use(auth.bearerMiddleware);
router.use('/entries', createEntriesRouter({ q }));
router.use('/', createAdminRouter({ q }));
return router;
}
module.exports = { createApiRouter };
-61
View File
@@ -1,61 +0,0 @@
'use strict';
const express = require('express');
// Отдельный requireAdmin для API — возвращает JSON вместо plain text
function apiRequireAdmin(req, res, next) {
if (!req.user || !req.user.isAdmin) {
return res.status(403).json({ error: 'Admin role required' });
}
next();
}
function createAdminRouter({ q }) {
const router = express.Router();
const json = express.json({ limit: '8kb' });
// GET /api/v1/companies — все компании (admin only)
router.get('/companies', apiRequireAdmin, async (req, res) => {
try {
const companies = await q.getAllCompanies();
res.json({ companies });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// PATCH /api/v1/companies/:id/limit — установить лимит (admin only)
router.patch('/companies/:id/limit', apiRequireAdmin, json, async (req, res) => {
const rawLimit = (req.body || {}).limit;
const isReset = rawLimit === null || rawLimit === undefined;
const limit = isReset ? null : parseInt(rawLimit, 10);
if (!isReset && (isNaN(limit) || limit < 0)) {
return res.status(400).json({ error: 'limit must be non-negative integer or null to reset' });
}
try {
await q.setLimit(req.params.id, limit);
res.json({ ok: true, limit });
} catch (e) {
res.status(e.status || 500).json({ error: e.message });
}
});
// GET /api/v1/audit — журнал действий (admin only)
// ?company=<id> — фильтр по компании (опционально)
router.get('/audit', apiRequireAdmin, async (req, res) => {
try {
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
const [rows, companies] = await Promise.all([
q.getAudit(companyId && Number.isFinite(companyId) ? companyId : null),
q.getAllCompanies(),
]);
res.json({ rows, companies });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
return router;
}
module.exports = { createAdminRouter };
-172
View File
@@ -1,172 +0,0 @@
'use strict';
const express = require('express');
const { aggregateCIDRs } = require('../../validators');
function createEntriesRouter({ q }) {
const router = express.Router();
const json = express.json({ limit: '32kb' });
// Маппинг сообщений ошибок на HTTP-коды
function handleQueryError(e, res) {
const msg = e.message || '';
// 409: бизнес-конфликты (дубликат, пересечение, лимит)
if (
msg.includes('Лимит исчерпан') ||
msg.includes('Такой адрес уже существует') ||
msg.includes('Пересечение с существующей записью')
) {
return res.status(409).json({ error: msg });
}
// 404: запись не найдена
if (msg.includes('Запись не найдена')) {
return res.status(404).json({ error: msg });
}
// 400: ошибки валидации от validate() и blocked-ranges
if (
msg.includes('Некорректн') ||
msg.includes('Маска должна быть') ||
msg.includes('Пустое значение') ||
msg.includes('IPv6 не поддерживается') ||
msg.includes('пересекается с запрещённым')
) {
return res.status(400).json({ error: msg });
}
return res.status(500).json({ error: msg });
}
// Определить компанию из запроса.
// Admin с ?company=<id> → компания по ID.
// User с ?client_id=<id> (мульти-компания) → компания по clientId.
// Иначе → компания пользователя (UPSERT).
async function resolveCompany(req) {
if (req.user.isAdmin && req.query.company) {
const id = parseInt(req.query.company, 10);
if (!Number.isFinite(id) || id <= 0) {
throw Object.assign(new Error('Invalid company id'), { status: 400 });
}
const c = await q.getCompanyById(id);
if (!c) throw Object.assign(new Error('Company not found'), { status: 404 });
return c;
}
// Мульти-компания: client_id разрешён только если есть в профилях (IAM) или allClientIds (JWT fallback)
const requestedId = (req.query.client_id || '').trim();
const allowedIds = req.user.profiles && req.user.profiles.length
? req.user.profiles.map(p => p.client_id)
: (req.user.allClientIds || []);
// Без верифицированного списка — client_id не принимаем
const effectiveClientId = (requestedId && allowedIds.length && allowedIds.includes(requestedId))
? requestedId
: req.user.clientId;
// companyName: для переключённой компании — находим в profiles или используем clientId
let effectiveName = effectiveClientId === req.user.clientId
? req.user.companyName
: effectiveClientId;
if (req.user.profiles) {
const p = req.user.profiles.find(p => p.client_id === effectiveClientId);
if (p) effectiveName = p.company_name;
}
return q.getOrCreateCompany(effectiveClientId, effectiveName, req.user.email);
}
// GET /api/v1/entries — список записей + лимит
// ?includeDeleted=true — показать soft-deleted (только admin)
router.get('/', async (req, res) => {
try {
const company = await resolveCompany(req);
const includeDeleted = req.user.isAdmin && req.query.includeDeleted === 'true';
const [entries, limit] = await Promise.all([
q.listEntries(company.id, includeDeleted),
q.getLimit(company),
]);
res.json({ entries, limit, used: entries.length });
} catch (e) {
res.status(e.status || 500).json({ error: e.message });
}
});
// GET /api/v1/entries/export — агрегированный список CIDR
// По умолчанию JSON. ?format=txt → text/plain.
// Только для админа.
router.get('/export', async (req, res) => {
try {
let companyId = null;
if (!req.user.isAdmin) {
const requestedId = (req.query.client_id || '').trim();
const effectiveClientId = (requestedId && req.user.allClientIds && req.user.allClientIds.includes(requestedId))
? requestedId
: req.user.clientId;
const company = await q.getOrCreateCompany(effectiveClientId, effectiveClientId);
companyId = company.id;
} else if (req.query.company) {
const id = parseInt(req.query.company, 10);
if (Number.isFinite(id) && id > 0) companyId = id;
}
const cidrs = await q.getExportCIDRs(companyId);
const aggregated = aggregateCIDRs(cidrs);
const isText = req.query.format === 'txt';
if (!isText) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
const fname = (req.query.filename || 'white-list.json').replace(/[^\w\-_. ]/g, '_');
const disp = req.query.view === '1' ? 'inline' : 'attachment';
res.setHeader('Content-Disposition', `${disp}; filename="${fname}"`);
return res.json({ cidrs: aggregated, count: aggregated.length });
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_');
const disp = req.query.view === '1' ? 'inline' : 'attachment';
res.setHeader('Content-Disposition', `${disp}; filename="${fname}"`);
res.send(aggregated.join('\n') + (aggregated.length ? '\n' : ''));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// POST /api/v1/entries — создать запись
router.post('/', json, async (req, res) => {
const { value, comment } = req.body || {};
if (!value) return res.status(400).json({ error: 'value required' });
if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' });
try {
const company = await resolveCompany(req);
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email, req.user.originalUserEmail);
res.status(201).json({ entry, wasNormalized });
} catch (e) {
if (e.status) return res.status(e.status).json({ error: e.message });
handleQueryError(e, res);
}
});
// PATCH /api/v1/entries/:id — обновить запись
router.patch('/:id', json, async (req, res) => {
const { value, comment } = req.body || {};
if (!value) return res.status(400).json({ error: 'value required' });
if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' });
try {
const company = await resolveCompany(req);
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email, req.user.originalUserEmail);
res.json({ entry, wasNormalized });
} catch (e) {
if (e.status) return res.status(e.status).json({ error: e.message });
handleQueryError(e, res);
}
});
// DELETE /api/v1/entries/:id — удалить запись
router.delete('/:id', async (req, res) => {
try {
const company = await resolveCompany(req);
await q.deleteEntry(req.params.id, company.id, req.user.email, req.user.originalUserEmail);
res.status(204).send();
} catch (e) {
if (e.status) return res.status(e.status).json({ error: e.message });
handleQueryError(e, res);
}
});
return router;
}
module.exports = { createEntriesRouter };