diff --git a/server.js b/server.js index b6f8ae0..380b1ab 100644 --- a/server.js +++ b/server.js @@ -103,6 +103,10 @@ async function start() { authLimiter, })); + // ── REST API v1 (Bearer JWT, без сессий/CSRF) ───────────────────────────── + const { createApiRouter } = require('./src/api/index'); + app.use('/api/v1', createApiRouter({ auth, q })); + // ── Все маршруты ниже требуют авторизации (auth.middleware) ──────────────── app.use(auth.middleware); diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000..222e732 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,41 @@ +'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); + // Валидация токена — переиспользуем auth.middleware из src/auth.js + router.use(auth.middleware); + + router.use('/entries', createEntriesRouter({ q })); + router.use('/', createAdminRouter({ q })); + + return router; +} + +module.exports = { createApiRouter }; diff --git a/src/api/middleware/bearerAuth.js b/src/api/middleware/bearerAuth.js new file mode 100644 index 0000000..df4205f --- /dev/null +++ b/src/api/middleware/bearerAuth.js @@ -0,0 +1,16 @@ +'use strict'; + +/** + * Middleware для API: требует Bearer-токен в Authorization. + * Если токена нет — сразу 401 JSON (без редиректа на /login). + * Валидацию токена делает следующий в цепочке auth.middleware. + */ +function requireBearer(req, res, next) { + const header = (req.headers.authorization || '').trim(); + if (!/^Bearer\s+\S/i.test(header)) { + return res.status(401).json({ error: 'Bearer token required' }); + } + next(); +} + +module.exports = { requireBearer }; diff --git a/src/api/routes/admin.js b/src/api/routes/admin.js new file mode 100644 index 0000000..c1c8848 --- /dev/null +++ b/src/api/routes/admin.js @@ -0,0 +1,44 @@ +'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 limit = parseInt((req.body || {}).limit, 10); + if (isNaN(limit) || limit < 0) { + return res.status(400).json({ error: 'limit must be non-negative integer' }); + } + try { + await q.setLimit(req.params.id, limit); + res.json({ ok: true, limit }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + return router; +} + +module.exports = { createAdminRouter }; diff --git a/src/api/routes/entries.js b/src/api/routes/entries.js new file mode 100644 index 0000000..9cf9785 --- /dev/null +++ b/src/api/routes/entries.js @@ -0,0 +1,76 @@ +'use strict'; + +const express = require('express'); + +function createEntriesRouter({ q }) { + const router = express.Router(); + const json = express.json({ limit: '32kb' }); + + // ── Хелпер: маппинг сообщений ошибок на HTTP-коды ─────────────────────── + function handleQueryError(e, res) { + const msg = e.message || ''; + if ( + msg.includes('Лимит исчерпан') || + msg.includes('Такой адрес уже существует') || + msg.includes('Пересечение с существующей записью') + ) { + return res.status(409).json({ error: msg }); + } + if (msg.includes('Запись не найдена')) { + return res.status(404).json({ error: msg }); + } + return res.status(500).json({ error: msg }); + } + + // GET /api/v1/entries — список записей текущей компании + router.get('/', async (req, res) => { + try { + const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName); + const entries = await q.listEntries(company.id); + res.json({ entries }); + } 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' }); + try { + const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName); + const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email); + res.status(201).json({ entry, wasNormalized }); + } catch (e) { + 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' }); + try { + const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName); + const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email); + res.json({ entry, wasNormalized }); + } catch (e) { + handleQueryError(e, res); + } + }); + + // DELETE /api/v1/entries/:id — удалить запись + router.delete('/:id', async (req, res) => { + try { + const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName); + await q.deleteEntry(req.params.id, company.id, req.user.email); + res.status(204).send(); + } catch (e) { + handleQueryError(e, res); + } + }); + + return router; +} + +module.exports = { createEntriesRouter };