Files
2026-05-13 14:20:41 +00:00

157 lines
4.2 KiB
JavaScript

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}`);
});