const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const crypto = require('crypto'); const app = express(); const PORT = Number(process.env.PORT || 3001); const INTERNAL_TOKEN = process.env.INTERNAL_WEBHOOK_TOKEN || ''; const TEXT_WEBHOOK = process.env.N8N_TEXT_SUBMIT_WEBHOOK || ''; const SCENARIO_WEBHOOK = process.env.N8N_SCENARIO_WEBHOOK || ''; const ALLOWED_SCENARIOS = new Set( (process.env.ALLOWED_SCENARIOS || 'demo-1,demo-2') .split(',') .map(s => s.trim()) .filter(Boolean) ); app.use(helmet({ contentSecurityPolicy: false })); app.use(express.json({ limit: '2mb' })); app.use(rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false })); function ok(res, data = {}, message = 'OK') { return res.json({ ok: true, data, message }); } function fail(res, status, error, message, details = null) { return res.status(status).json({ ok: false, error, message, details }); } async function postToN8N(url, payload) { if (!url) { throw new Error('Webhook URL is empty'); } const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Token': INTERNAL_TOKEN }, body: JSON.stringify(payload) }); const raw = await response.text(); let parsed; try { parsed = raw ? JSON.parse(raw) : {}; } catch { parsed = { raw }; } if (!response.ok) { const err = new Error(parsed.message || raw || `n8n returned ${response.status}`); err.status = response.status; err.payload = parsed; throw err; } return parsed; } app.get('/api/health', (req, res) => { return ok(res, { service: 'uno-bff', nowTs: Date.now() }, 'BFF is healthy'); }); app.post('/api/forms/text-submit', async (req, res) => { const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''; if (!text) { return fail(res, 400, 'VALIDATION_ERROR', 'Поле text обязательно'); } if (text.length > 500) { return fail(res, 400, 'VALIDATION_ERROR', 'Поле text должно быть не длиннее 500 символов'); } const payload = { requestId: req.body?.requestId || crypto.randomUUID(), source: 'uno-click-site', text, ts: Date.now(), clientIp: req.headers['x-forwarded-for'] || req.socket.remoteAddress || '', userAgent: req.headers['user-agent'] || '' }; try { const result = await postToN8N(TEXT_WEBHOOK, payload); return ok(res, result, 'Текст успешно отправлен'); } catch (error) { console.error('text-submit error:', error); return fail( res, 502, 'N8N_ERROR', 'Не удалось обработать запрос в n8n', error.payload || error.message ); } }); app.post('/api/actions/run-scenario', async (req, res) => { const scenarioId = typeof req.body?.scenarioId === 'string' ? req.body.scenarioId.trim() : ''; const payload = (req.body?.payload && typeof req.body.payload === 'object' && !Array.isArray(req.body.payload)) ? req.body.payload : {}; if (!scenarioId) { return fail(res, 400, 'VALIDATION_ERROR', 'Поле scenarioId обязательно'); } if (!ALLOWED_SCENARIOS.has(scenarioId)) { return fail(res, 400, 'VALIDATION_ERROR', `Сценарий ${scenarioId} не разрешен`); } const body = { requestId: req.body?.requestId || crypto.randomUUID(), source: 'uno-click-site', scenarioId, payload, ts: Date.now(), clientIp: req.headers['x-forwarded-for'] || req.socket.remoteAddress || '', userAgent: req.headers['user-agent'] || '' }; try { const result = await postToN8N(SCENARIO_WEBHOOK, body); return ok(res, result, 'Сценарий успешно запущен'); } catch (error) { console.error('run-scenario error:', error); return fail( res, 502, 'N8N_ERROR', 'Не удалось запустить сценарий в n8n', error.payload || error.message ); } }); app.use((req, res) => { return fail(res, 404, 'NOT_FOUND', 'Маршрут не найден'); }); app.listen(PORT, '0.0.0.0', () => { console.log(`uno-bff listening on port ${PORT}`); });