initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY server.js ./
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3001
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "uno-bff",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user