| | const express = require('express'); |
| | const fetch = require('node-fetch'); |
| | const cors = require('cors'); |
| | const rateLimit = require('express-rate-limit'); |
| | const helmet = require('helmet'); |
| | const crypto = require('crypto'); |
| | const jwt = require('jsonwebtoken'); |
| | require('dotenv').config(); |
| |
|
| | const app = express(); |
| |
|
| | |
| | const PRODUCTION_MODE = process.env.PRODUCTION_MODE === 'true'; |
| | const ALLOW_UNAUTHENTICATED_ACCESS = process.env.ALLOW_UNAUTHENTICATED_ACCESS === 'true'; |
| | const AUTH_MODE = (process.env.AUTH_MODE || 'api_key_only').trim().toLowerCase(); |
| | const CHAT_TOKEN_AUTH_ENABLED = process.env.CHAT_TOKEN_AUTH_ENABLED === 'true'; |
| |
|
| | function parseCommaSeparatedList(value) { |
| | if (!value || typeof value !== 'string') return []; |
| | return value |
| | .split(',') |
| | .map((item) => item.trim()) |
| | .filter(Boolean); |
| | } |
| |
|
| | function parseTrustProxyValue(value) { |
| | if (!value || typeof value !== 'string') return false; |
| | const normalized = value.trim().toLowerCase(); |
| | if (normalized === 'true') return true; |
| | if (normalized === 'false') return false; |
| | if (/^\d+$/.test(normalized)) return parseInt(normalized, 10); |
| | return value.trim(); |
| | } |
| |
|
| | function parsePositiveInt(value, fallback) { |
| | const parsed = Number.parseInt(String(value || ''), 10); |
| | return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; |
| | } |
| |
|
| | const API_KEYS = parseCommaSeparatedList(process.env.API_KEYS); |
| | const allowedOrigins = parseCommaSeparatedList(process.env.ALLOWED_ORIGINS); |
| | const TRUST_PROXY = parseTrustProxyValue(process.env.TRUST_PROXY); |
| | const VALID_AUTH_MODES = new Set(['api_key_only', 'origin_or_api_key', 'origin_only']); |
| | const TURNSTILE_SECRET_KEY = (process.env.TURNSTILE_SECRET_KEY || '').trim(); |
| | const TURNSTILE_VERIFY_URL = (process.env.TURNSTILE_VERIFY_URL || 'https://challenges.cloudflare.com/turnstile/v0/siteverify').trim(); |
| | const TURNSTILE_ALLOWED_HOSTNAMES = parseCommaSeparatedList(process.env.TURNSTILE_ALLOWED_HOSTNAMES) |
| | .map(normalizeHostnameEntry) |
| | .filter(Boolean); |
| | const CHAT_TOKEN_SECRET = (process.env.CHAT_TOKEN_SECRET || '').trim(); |
| | const CHAT_TOKEN_ISSUER = (process.env.CHAT_TOKEN_ISSUER || 'federated-proxy').trim(); |
| | const CHAT_TOKEN_TTL_SECONDS = parsePositiveInt(process.env.CHAT_TOKEN_TTL_SECONDS, 900); |
| | const CHAT_TOKEN_CLOCK_SKEW_SECONDS = parsePositiveInt(process.env.CHAT_TOKEN_CLOCK_SKEW_SECONDS, 30); |
| | const CHAT_TOKEN_ALLOWED_SITE_IDS = parseCommaSeparatedList(process.env.CHAT_TOKEN_ALLOWED_SITE_IDS); |
| | const CHAT_TOKEN_BIND_IP = process.env.CHAT_TOKEN_BIND_IP === 'true'; |
| | const CHAT_TOKEN_BIND_USER_AGENT = process.env.CHAT_TOKEN_BIND_USER_AGENT === 'true'; |
| |
|
| | function normalizeHostnameEntry(value) { |
| | if (typeof value !== 'string') return ''; |
| |
|
| | const trimmed = value.trim().toLowerCase(); |
| | if (!trimmed) return ''; |
| |
|
| | |
| | if (/^\*\.[a-z0-9.-]+$/.test(trimmed)) { |
| | return trimmed; |
| | } |
| |
|
| | try { |
| | if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { |
| | return new URL(trimmed).hostname.toLowerCase(); |
| | } |
| | return new URL(`https://${trimmed}`).hostname.toLowerCase(); |
| | } catch { |
| | return ''; |
| | } |
| | } |
| |
|
| | function extractHostnameFromOrigin(origin) { |
| | return normalizeHostnameEntry(origin); |
| | } |
| |
|
| | function hostnameMatchesAllowlist(hostname, allowlist) { |
| | if (!hostname) return false; |
| |
|
| | return allowlist.some((entry) => { |
| | if (entry === hostname) return true; |
| | if (!entry.startsWith('*.')) return false; |
| | const suffix = entry.slice(2); |
| | return hostname === suffix || hostname.endsWith(`.${suffix}`); |
| | }); |
| | } |
| |
|
| | const ORIGIN_HOSTNAMES = allowedOrigins |
| | .map(extractHostnameFromOrigin) |
| | .filter(Boolean); |
| |
|
| | const EFFECTIVE_TURNSTILE_HOSTNAMES = Array.from( |
| | new Set([...TURNSTILE_ALLOWED_HOSTNAMES, ...ORIGIN_HOSTNAMES]) |
| | ); |
| |
|
| | function log(message, level = 'info') { |
| | if (PRODUCTION_MODE && level === 'debug') return; |
| | console.log(message); |
| | } |
| |
|
| | function logSensitive(message) { |
| | if (!PRODUCTION_MODE) console.log(message); |
| | } |
| |
|
| | if (!ALLOW_UNAUTHENTICATED_ACCESS && API_KEYS.length === 0) { |
| | if (AUTH_MODE === 'api_key_only') { |
| | log('CRITICAL ERROR: API_KEYS must include at least one key in api_key_only mode.'); |
| | process.exit(1); |
| | } |
| | } |
| |
|
| | if (PRODUCTION_MODE && ALLOW_UNAUTHENTICATED_ACCESS) { |
| | log('CRITICAL ERROR: ALLOW_UNAUTHENTICATED_ACCESS=true is not allowed in production mode.'); |
| | process.exit(1); |
| | } |
| |
|
| | if (!VALID_AUTH_MODES.has(AUTH_MODE)) { |
| | log(`CRITICAL ERROR: AUTH_MODE '${AUTH_MODE}' is invalid. Valid modes: api_key_only, origin_or_api_key, origin_only.`); |
| | process.exit(1); |
| | } |
| |
|
| | if (!ALLOW_UNAUTHENTICATED_ACCESS && (AUTH_MODE === 'origin_or_api_key' || AUTH_MODE === 'origin_only') && allowedOrigins.length === 0) { |
| | log('CRITICAL ERROR: ALLOWED_ORIGINS must include at least one origin when using origin-based auth modes.'); |
| | process.exit(1); |
| | } |
| |
|
| | if (CHAT_TOKEN_AUTH_ENABLED && !TURNSTILE_SECRET_KEY) { |
| | log('CRITICAL ERROR: TURNSTILE_SECRET_KEY is required when CHAT_TOKEN_AUTH_ENABLED=true.'); |
| | process.exit(1); |
| | } |
| |
|
| | if (CHAT_TOKEN_AUTH_ENABLED && !CHAT_TOKEN_SECRET) { |
| | log('CRITICAL ERROR: CHAT_TOKEN_SECRET is required when CHAT_TOKEN_AUTH_ENABLED=true.'); |
| | process.exit(1); |
| | } |
| |
|
| | if (CHAT_TOKEN_AUTH_ENABLED && allowedOrigins.length === 0) { |
| | log('CRITICAL ERROR: ALLOWED_ORIGINS must include at least one origin when CHAT_TOKEN_AUTH_ENABLED=true.'); |
| | process.exit(1); |
| | } |
| |
|
| | |
| | function maskIP(ip) { |
| | const normalized = normalizeIp(ip); |
| | if (PRODUCTION_MODE) { |
| | if (normalized.includes('.')) { |
| | const parts = normalized.split('.'); |
| | return parts.length === 4 ? `${parts[0]}.${parts[1]}.***.**` : 'masked'; |
| | } |
| |
|
| | if (normalized.includes(':')) { |
| | const parts = normalized.split(':').filter(Boolean); |
| | return parts.length >= 2 ? `${parts[0]}:${parts[1]}::****` : 'masked'; |
| | } |
| |
|
| | return 'masked'; |
| | } |
| | return normalized; |
| | } |
| |
|
| | function maskOrigin(origin) { |
| | if (PRODUCTION_MODE && origin && origin !== 'no-origin') { |
| | try { |
| | const url = new URL(origin);
|
| | return `${url.protocol}//${url.hostname.substring(0, 3)}***`;
|
| | } catch {
|
| | return 'masked';
|
| | }
|
| | } |
| | return origin; |
| | } |
| |
|
| | function normalizeIp(ip) { |
| | if (!ip || typeof ip !== 'string') return 'unknown'; |
| | return ip.startsWith('::ffff:') ? ip.slice(7) : ip; |
| | } |
| |
|
| | function getClientIp(req) { |
| | return normalizeIp(req.ip || req.socket?.remoteAddress || 'unknown'); |
| | } |
| |
|
| | function extractApiKey(req) { |
| | const rawApiKey = req.headers['x-api-key']; |
| | if (typeof rawApiKey === 'string' && rawApiKey.trim()) { |
| | return rawApiKey.trim(); |
| | } |
| |
|
| | const authHeader = req.headers.authorization; |
| | if (typeof authHeader === 'string') { |
| | const match = authHeader.match(/^Bearer\s+(.+)$/i); |
| | if (match && match[1] && match[1].trim()) { |
| | return match[1].trim(); |
| | } |
| | } |
| |
|
| | return null; |
| | } |
| |
|
| | function timingSafeEquals(left, right) { |
| | const leftBuffer = Buffer.from(left); |
| | const rightBuffer = Buffer.from(right); |
| | if (leftBuffer.length !== rightBuffer.length) return false; |
| | return crypto.timingSafeEqual(leftBuffer, rightBuffer); |
| | } |
| |
|
| | function isAllowedOrigin(origin) { |
| | return typeof origin === 'string' && origin.length > 0 && allowedOrigins.includes(origin); |
| | } |
| |
|
| | function authenticateRequest(req) { |
| | if (ALLOW_UNAUTHENTICATED_ACCESS) { |
| | return { valid: true, source: 'insecure-open-mode' }; |
| | } |
| |
|
| | const apiKey = extractApiKey(req); |
| | if (apiKey) { |
| | const keyValid = API_KEYS.some((configuredKey) => timingSafeEquals(configuredKey, apiKey)); |
| | if (keyValid) { |
| | return { valid: true, source: 'api-key' }; |
| | } |
| | } |
| |
|
| | const origin = req.headers.origin; |
| | const hasAllowedOrigin = isAllowedOrigin(origin); |
| | if (AUTH_MODE === 'origin_only' && hasAllowedOrigin) { |
| | return { valid: true, source: 'allowed-origin' }; |
| | } |
| |
|
| | if (AUTH_MODE === 'origin_or_api_key' && hasAllowedOrigin) { |
| | return { valid: true, source: 'allowed-origin' }; |
| | } |
| |
|
| | if (apiKey) { |
| | return { valid: false, source: 'invalid-api-key' }; |
| | } |
| |
|
| | return { valid: false, source: 'missing-credentials' }; |
| | } |
| |
|
| | function extractBearerToken(req) { |
| | const authorization = req.headers.authorization; |
| | if (typeof authorization !== 'string') return null; |
| | const match = authorization.match(/^Bearer\s+(.+)$/i); |
| | return match && match[1] ? match[1].trim() : null; |
| | } |
| |
|
| | function hasValidApiKey(req) { |
| | const apiKey = extractApiKey(req); |
| | return typeof apiKey === 'string' && API_KEYS.some((configuredKey) => timingSafeEquals(configuredKey, apiKey)); |
| | } |
| |
|
| | function normalizeBotName(rawBotName) { |
| | if (typeof rawBotName !== 'string') return ''; |
| | return rawBotName.toLowerCase().replace(/\s+/g, '-').substring(0, 100); |
| | } |
| |
|
| | function parseChatflowTarget(chatflowId) { |
| | if (typeof chatflowId !== 'string') return null; |
| | const normalized = chatflowId.trim().replace(/^\/+|\/+$/g, ''); |
| | if (!normalized) return null; |
| |
|
| | const parts = normalized.split('/'); |
| | if (parts.length !== 2) return null; |
| |
|
| | const instanceNum = Number.parseInt(parts[0], 10); |
| | const botName = normalizeBotName(parts[1]); |
| | if (!Number.isInteger(instanceNum) || instanceNum < 1 || !botName) return null; |
| |
|
| | return { instanceNum, botName }; |
| | } |
| |
|
| | function hashUserAgent(value) { |
| | const userAgent = typeof value === 'string' ? value : ''; |
| | return crypto.createHash('sha256').update(userAgent).digest('hex'); |
| | } |
| |
|
| | function verifyChatAccessToken(token) { |
| | if (typeof token !== 'string' || token.length < 20) { |
| | return { valid: false, reason: 'missing-token' }; |
| | } |
| |
|
| | let payload; |
| |
|
| | try { |
| | payload = jwt.verify(token, CHAT_TOKEN_SECRET, { |
| | algorithms: ['HS256'], |
| | issuer: CHAT_TOKEN_ISSUER, |
| | clockTolerance: CHAT_TOKEN_CLOCK_SKEW_SECONDS |
| | }); |
| | } catch (error) { |
| | const message = String(error && error.message ? error.message : '').toLowerCase(); |
| | if (message.includes('jwt expired')) return { valid: false, reason: 'expired' }; |
| | if (message.includes('invalid issuer')) return { valid: false, reason: 'invalid-issuer' }; |
| | if (message.includes('invalid signature')) return { valid: false, reason: 'invalid-signature' }; |
| | if (message.includes('jwt malformed') || message.includes('invalid token')) return { valid: false, reason: 'invalid-format' }; |
| | return { valid: false, reason: 'invalid-token' }; |
| | } |
| |
|
| | if (!payload || typeof payload !== 'object') { |
| | return { valid: false, reason: 'invalid-token' }; |
| | } |
| |
|
| | if (!Number.isInteger(payload.instanceNum) || payload.instanceNum < 1) { |
| | return { valid: false, reason: 'invalid-instance' }; |
| | } |
| |
|
| | if (typeof payload.botName !== 'string' || payload.botName.length === 0) { |
| | return { valid: false, reason: 'invalid-bot' }; |
| | } |
| |
|
| | return { valid: true, claims: payload }; |
| | } |
| |
|
| | async function verifyTurnstileChallenge(token, req) { |
| | const body = new URLSearchParams(); |
| | body.set('secret', TURNSTILE_SECRET_KEY); |
| | body.set('response', token); |
| |
|
| | const clientIp = getClientIp(req); |
| | if (clientIp && clientIp !== 'unknown') { |
| | body.set('remoteip', clientIp); |
| | } |
| |
|
| | const response = await fetchWithTimeout( |
| | TURNSTILE_VERIFY_URL, |
| | { |
| | method: 'POST', |
| | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| | body: body.toString() |
| | }, |
| | 10000 |
| | ); |
| |
|
| | if (!response.ok) { |
| | throw new Error(`Turnstile verification failed with status ${response.status}`); |
| | } |
| |
|
| | const result = await response.json(); |
| | if (!result || result.success !== true) { |
| | return { valid: false, reason: 'captcha-rejected', result }; |
| | } |
| |
|
| | const hostname = normalizeHostnameEntry(result.hostname); |
| | if (EFFECTIVE_TURNSTILE_HOSTNAMES.length > 0 && !hostnameMatchesAllowlist(hostname, EFFECTIVE_TURNSTILE_HOSTNAMES)) { |
| | return { valid: false, reason: 'invalid-hostname', result }; |
| | } |
| |
|
| | return { valid: true, result }; |
| | } |
| |
|
| | function requirePredictionAccess(req, res, next) { |
| | if (!CHAT_TOKEN_AUTH_ENABLED) { |
| | return next(); |
| | } |
| |
|
| | if (hasValidApiKey(req)) { |
| | return next(); |
| | } |
| |
|
| | const token = extractBearerToken(req); |
| | if (!token) { |
| | return res.status(401).json({ |
| | error: 'Unauthorized', |
| | message: 'A valid chat token or API key is required.' |
| | }); |
| | } |
| |
|
| | const verification = verifyChatAccessToken(token); |
| | if (!verification.valid) { |
| | log(`[Security] Invalid chat token: ${verification.reason}`); |
| | return res.status(401).json({ |
| | error: 'Unauthorized', |
| | message: 'Invalid or expired chat token.' |
| | }); |
| | } |
| |
|
| | const claims = verification.claims; |
| | const instanceNum = Number.parseInt(req.params.instanceNum, 10); |
| | const botName = normalizeBotName(req.params.botName); |
| |
|
| | if (claims.instanceNum !== instanceNum || claims.botName !== botName) { |
| | return res.status(403).json({ |
| | error: 'Access denied', |
| | message: 'Token is not valid for this chatbot.' |
| | }); |
| | } |
| |
|
| | if (CHAT_TOKEN_ALLOWED_SITE_IDS.length > 0 && !CHAT_TOKEN_ALLOWED_SITE_IDS.includes(claims.siteId || '')) { |
| | return res.status(403).json({ |
| | error: 'Access denied', |
| | message: 'Token site binding is not authorized.' |
| | }); |
| | } |
| |
|
| | if (CHAT_TOKEN_BIND_IP && claims.ip !== getClientIp(req)) { |
| | return res.status(403).json({ |
| | error: 'Access denied', |
| | message: 'Token binding validation failed.' |
| | }); |
| | } |
| |
|
| | if (CHAT_TOKEN_BIND_USER_AGENT && claims.uaHash !== hashUserAgent(req.headers['user-agent'] || '')) { |
| | return res.status(403).json({ |
| | error: 'Access denied', |
| | message: 'Token binding validation failed.' |
| | }); |
| | } |
| |
|
| | req.chatTokenClaims = claims; |
| | next(); |
| | } |
| |
|
| | app.disable('x-powered-by'); |
| | app.use(helmet({ |
| | contentSecurityPolicy: false, |
| | crossOriginEmbedderPolicy: false |
| | })); |
| |
|
| | app.set('trust proxy', TRUST_PROXY); |
| | app.use(express.json({ limit: '1mb' })); |
| |
|
| | app.use(cors({ |
| | origin: function (origin, callback) { |
| | if (!origin) { |
| | return callback(null, true); |
| | } |
| | if (allowedOrigins.length === 0) { |
| | log(`[Security] Blocked cross-origin request because ALLOWED_ORIGINS is empty: ${maskOrigin(origin)}`); |
| | return callback(new Error('Not allowed by CORS')); |
| | } |
| | if (allowedOrigins.includes(origin)) { |
| | callback(null, true); |
| | } else { |
| | log(`[Security] Blocked origin: ${maskOrigin(origin)}`); |
| | callback(new Error('Not allowed by CORS')); |
| | } |
| | }, |
| | credentials: false, |
| | methods: ['GET', 'POST', 'OPTIONS'], |
| | allowedHeaders: ['Content-Type', 'X-API-Key', 'Authorization'] |
| | })); |
| |
|
| | app.use((err, req, res, next) => {
|
| | if (err.message === 'Not allowed by CORS') {
|
| | return res.status(403).json({ error: 'Access denied' });
|
| | }
|
| | next(err);
|
| | });
|
| |
|
| | |
| | app.use((req, res, next) => { |
| | const isPublicPath = req.path === '/' || req.path === '/health' || (!PRODUCTION_MODE && req.path.startsWith('/test-')); |
| | const isTokenIssuePath = CHAT_TOKEN_AUTH_ENABLED && req.path === '/auth/chat-token' && req.method === 'POST'; |
| | const isTokenProtectedPrediction = CHAT_TOKEN_AUTH_ENABLED && req.method === 'POST' && req.path.startsWith('/api/v1/prediction/'); |
| |
|
| | if (isPublicPath || isTokenIssuePath || isTokenProtectedPrediction) { |
| | return next(); |
| | } |
| | |
| | const auth = authenticateRequest(req); |
| | |
| | if (!auth.valid) { |
| | log(`[Security] Blocked unauthorized request from ${maskIP(getClientIp(req))}`); |
| | return res.status(401).json({ |
| | error: 'Unauthorized', |
| | message: AUTH_MODE === 'api_key_only' |
| | ? 'Valid API key required' |
| | : 'Valid API key or allowed browser origin required' |
| | }); |
| | } |
| | |
| | log(`[Auth] Request authorized from: ${auth.source}`); |
| | next(); |
| | });
|
| |
|
| |
|
| | const limiter = rateLimit({ |
| | windowMs: 15 * 60 * 1000, |
| | max: 100, |
| | message: { error: "Too many requests" }, |
| | standardHeaders: 'draft-7', |
| | legacyHeaders: false |
| | }); |
| | app.use(limiter); |
| |
|
| | |
| | app.use((req, res, next) => { |
| | const ip = maskIP(getClientIp(req)); |
| | const origin = maskOrigin(req.headers.origin || 'no-origin'); |
| | const apiKey = extractApiKey(req) ? '***' : 'none'; |
| | const path = PRODUCTION_MODE ? req.path.split('/').slice(0, 4).join('/') + '/***' : req.path; |
| | |
| | log(`[${new Date().toISOString()}] ${ip} -> ${req.method} ${path} | Origin: ${origin} | Key: ${apiKey}`); |
| | next(); |
| | });
|
| |
|
| |
|
| | const dailyUsage = new Map();
|
| | let lastResetDate = new Date().toDateString();
|
| |
|
| | function checkDailyReset() {
|
| | const today = new Date().toDateString();
|
| | if (today !== lastResetDate) {
|
| | dailyUsage.clear();
|
| | lastResetDate = today;
|
| | log('[System] Daily usage counters reset');
|
| | }
|
| | }
|
| |
|
| | setInterval(checkDailyReset, 60 * 60 * 1000);
|
| |
|
| | app.use((req, res, next) => { |
| | if (req.method === 'POST' && req.path.includes('/prediction/')) { |
| | checkDailyReset(); |
| | |
| | const ip = getClientIp(req); |
| | const count = dailyUsage.get(ip) || 0; |
| | |
| | if (count >= 200) { |
| | return res.status(429).json({ |
| | error: 'Daily limit reached', |
| | message: 'You have reached your daily usage limit. Try again tomorrow.'
|
| | });
|
| | }
|
| |
|
| | dailyUsage.set(ip, count + 1);
|
| |
|
| | if (dailyUsage.size > 10000) {
|
| | log('[System] Daily usage map too large, clearing oldest entries', 'debug');
|
| | const entries = Array.from(dailyUsage.entries()).slice(0, 1000);
|
| | entries.forEach(([key]) => dailyUsage.delete(key));
|
| | }
|
| | }
|
| | next();
|
| | });
|
| |
|
| |
|
| | app.use((req, res, next) => {
|
| | if (req.method !== 'POST') {
|
| | return next();
|
| | }
|
| |
|
| | const userAgent = (req.headers['user-agent'] || '').toLowerCase(); |
| | const suspiciousBots = ['python-requests', 'curl/', 'wget/', 'scrapy', 'crawler']; |
| | |
| | const hasPrivilegedApiKey = hasValidApiKey(req); |
| | if (hasPrivilegedApiKey) { |
| | return next(); |
| | } |
| |
|
| | const isBot = suspiciousBots.some(bot => userAgent.includes(bot));
|
| | |
| | if (isBot) { |
| | log(`[Security] Blocked bot from ${maskIP(getClientIp(req))}`); |
| | return res.status(403).json({ |
| | error: 'Automated access detected', |
| | message: 'This service is for web browsers only.' |
| | }); |
| | }
|
| | next();
|
| | });
|
| |
|
| |
|
| | let INSTANCES = []; |
| | try { |
| | INSTANCES = JSON.parse(process.env.FLOWISE_INSTANCES || '[]'); |
| | log(`[System] Loaded ${INSTANCES.length} instances`); |
| | if (!Array.isArray(INSTANCES) || INSTANCES.length === 0) { |
| | log('CRITICAL ERROR: FLOWISE_INSTANCES must be a non-empty array'); |
| | process.exit(1); |
| | } |
| | } catch (e) { |
| | log("CRITICAL ERROR: Could not parse FLOWISE_INSTANCES JSON"); |
| | process.exit(1); |
| | } |
| |
|
| |
|
| | const flowCache = new Map();
|
| |
|
| | setInterval(() => {
|
| | const now = Date.now();
|
| | for (const [key, value] of flowCache.entries()) {
|
| | if (value.timestamp && now - value.timestamp > 10 * 60 * 1000) {
|
| | flowCache.delete(key);
|
| | }
|
| | }
|
| | }, 10 * 60 * 1000);
|
| |
|
| |
|
| | async function fetchWithTimeout(url, options, timeout = 10000) { |
| | const controller = new AbortController(); |
| | const timer = setTimeout(() => controller.abort(), timeout); |
| |
|
| | try { |
| | return await fetch(url, { ...options, signal: controller.signal }); |
| | } catch (error) { |
| | if (error && error.name === 'AbortError') { |
| | throw new Error('Request timeout'); |
| | } |
| | throw error; |
| | } finally { |
| | clearTimeout(timer); |
| | } |
| | } |
| |
|
| |
|
| | async function resolveChatflowId(instanceNum, botName) {
|
| | const cacheKey = `${instanceNum}-${botName}`;
|
| |
|
| | const cached = flowCache.get(cacheKey);
|
| | if (cached && cached.timestamp && Date.now() - cached.timestamp < 5 * 60 * 1000) {
|
| | return { id: cached.id, instance: cached.instance };
|
| | }
|
| |
|
| | if (isNaN(instanceNum) || instanceNum < 1 || instanceNum > INSTANCES.length) {
|
| | throw new Error(`Instance ${instanceNum} does not exist. Valid: 1-${INSTANCES.length}`);
|
| | }
|
| |
|
| | const instance = INSTANCES[instanceNum - 1];
|
| | logSensitive(`[System] Looking up '${botName}' in instance ${instanceNum}...`);
|
| |
|
| | const headers = {};
|
| | if (instance.key && instance.key.length > 0) {
|
| | headers['Authorization'] = `Bearer ${instance.key}`;
|
| | }
|
| |
|
| | const response = await fetchWithTimeout(`${instance.url}/api/v1/chatflows`, { headers }, 10000);
|
| |
|
| | if (!response.ok) {
|
| | throw new Error(`Instance ${instanceNum} returned status ${response.status}`);
|
| | }
|
| |
|
| | const flows = await response.json();
|
| |
|
| | if (!Array.isArray(flows)) {
|
| | throw new Error(`Instance ${instanceNum} returned invalid response`);
|
| | }
|
| |
|
| | const match = flows.find(f => f.name && f.name.toLowerCase().replace(/\s+/g, '-') === botName);
|
| |
|
| | if (!match || !match.id) {
|
| | throw new Error(`Bot '${botName}' not found in instance ${instanceNum}`);
|
| | }
|
| |
|
| | flowCache.set(cacheKey, {
|
| | id: match.id,
|
| | instance: instance,
|
| | timestamp: Date.now()
|
| | });
|
| |
|
| | logSensitive(`[System] Found '${botName}' -> ${match.id}`);
|
| |
|
| | return { id: match.id, instance };
|
| | }
|
| |
|
| |
|
| | async function handleStreamingResponse(flowiseResponse, clientRes) { |
| | clientRes.setHeader('Content-Type', 'text/event-stream');
|
| | clientRes.setHeader('Cache-Control', 'no-cache');
|
| | clientRes.setHeader('Connection', 'keep-alive');
|
| | clientRes.setHeader('X-Accel-Buffering', 'no');
|
| |
|
| | log('[Streaming] Forwarding SSE stream...');
|
| |
|
| | let streamStarted = false;
|
| | let dataReceived = false;
|
| | let lastDataTime = Date.now();
|
| | let totalBytes = 0;
|
| |
|
| | const timeoutCheck = setInterval(() => {
|
| | const timeSinceData = Date.now() - lastDataTime;
|
| |
|
| | if (timeSinceData > 45000) {
|
| | log(`[Streaming] Timeout - no data for ${(timeSinceData/1000).toFixed(1)}s`);
|
| | clearInterval(timeoutCheck);
|
| |
|
| | if (!dataReceived) {
|
| | log('[Streaming] Stream completed with NO data received!');
|
| | if (!streamStarted) {
|
| | clientRes.status(504).json({
|
| | error: 'Gateway timeout',
|
| | message: 'No response from chatbot within 45 seconds'
|
| | });
|
| | } else {
|
| | clientRes.write('\n\nevent: error\ndata: {"error": "Response timeout - no data received"}\n\n');
|
| | }
|
| | }
|
| | clientRes.end();
|
| | }
|
| | }, 5000);
|
| |
|
| | flowiseResponse.body.on('data', (chunk) => { |
| | clearInterval(timeoutCheck); |
| | streamStarted = true; |
| | dataReceived = true; |
| | lastDataTime = Date.now(); |
| | totalBytes += chunk.length; |
| |
|
| | logSensitive(`[Streaming] Received chunk: ${chunk.length} bytes (total: ${totalBytes})`);
|
| | clientRes.write(chunk);
|
| | });
|
| |
|
| | flowiseResponse.body.on('end', () => {
|
| | clearInterval(timeoutCheck);
|
| |
|
| | if (dataReceived) {
|
| | log(`[Streaming] Stream completed - ${totalBytes} bytes`);
|
| | } else {
|
| | log('[Streaming] Stream completed but NO data received!');
|
| | }
|
| |
|
| | clientRes.end();
|
| | });
|
| |
|
| | flowiseResponse.body.on('error', (err) => {
|
| | clearInterval(timeoutCheck);
|
| | log('[Streaming Error]');
|
| |
|
| | if (streamStarted && dataReceived) {
|
| | clientRes.write(`\n\nevent: error\ndata: {"error": "Stream interrupted"}\n\n`);
|
| | } else if (!streamStarted) {
|
| | clientRes.status(500).json({ error: 'Stream failed to start' });
|
| | }
|
| | clientRes.end();
|
| | }); |
| | } |
| |
|
| | const tokenIssueLimiter = rateLimit({ |
| | windowMs: 10 * 60 * 1000, |
| | max: 30, |
| | message: { error: 'Too many token requests' }, |
| | standardHeaders: 'draft-7', |
| | legacyHeaders: false |
| | }); |
| |
|
| | |
| | app.post('/auth/chat-token', tokenIssueLimiter, async (req, res) => { |
| | if (!CHAT_TOKEN_AUTH_ENABLED) { |
| | return res.status(404).json({ error: 'Route not found' }); |
| | } |
| |
|
| | try { |
| | const origin = req.headers.origin; |
| | if (!isAllowedOrigin(origin)) { |
| | log(`[Security] Blocked token issuance for untrusted origin: ${maskOrigin(origin || 'no-origin')}`); |
| | return res.status(403).json({ |
| | error: 'Access denied', |
| | message: 'Origin is not allowed.' |
| | }); |
| | } |
| |
|
| | const provider = typeof req.body.provider === 'string' ? req.body.provider.trim().toLowerCase() : 'turnstile'; |
| | if (provider !== 'turnstile') { |
| | return res.status(400).json({ |
| | error: 'Invalid request', |
| | message: 'Unsupported captcha provider.' |
| | }); |
| | } |
| |
|
| | const captchaToken = typeof req.body.captchaToken === 'string' ? req.body.captchaToken.trim() : ''; |
| | if (!captchaToken || captchaToken.length > 5000) { |
| | return res.status(400).json({ |
| | error: 'Invalid request', |
| | message: 'captchaToken is required.' |
| | }); |
| | } |
| |
|
| | const rawChatflowId = typeof req.body.chatflowid === 'string' |
| | ? req.body.chatflowid |
| | : (typeof req.body.chatflowId === 'string' ? req.body.chatflowId : ''); |
| | const target = parseChatflowTarget(rawChatflowId); |
| | if (!target || target.instanceNum > INSTANCES.length) { |
| | return res.status(400).json({ |
| | error: 'Invalid request', |
| | message: 'chatflowid must match the format <instance>/<bot>.' |
| | }); |
| | } |
| |
|
| | const siteId = typeof req.body.siteId === 'string' ? req.body.siteId.trim() : ''; |
| | if (CHAT_TOKEN_ALLOWED_SITE_IDS.length > 0 && !CHAT_TOKEN_ALLOWED_SITE_IDS.includes(siteId)) { |
| | return res.status(403).json({ |
| | error: 'Access denied', |
| | message: 'siteId is not authorized.' |
| | }); |
| | } |
| |
|
| | const captchaVerification = await verifyTurnstileChallenge(captchaToken, req); |
| | if (!captchaVerification.valid) { |
| | const receivedHostname = captchaVerification.result && captchaVerification.result.hostname |
| | ? normalizeHostnameEntry(captchaVerification.result.hostname) |
| | : ''; |
| | log(`[Security] Captcha rejected: ${captchaVerification.reason}${receivedHostname ? ` (${receivedHostname})` : ''}`); |
| | return res.status(403).json({ |
| | error: 'Access denied', |
| | reason: captchaVerification.reason, |
| | message: captchaVerification.reason === 'invalid-hostname' |
| | ? 'Captcha hostname is not allowed.' |
| | : 'Captcha verification failed.' |
| | }); |
| | } |
| |
|
| | const claims = { |
| | instanceNum: target.instanceNum, |
| | botName: target.botName |
| | }; |
| |
|
| | if (siteId) { |
| | claims.siteId = siteId; |
| | } |
| |
|
| | if (CHAT_TOKEN_BIND_IP) { |
| | claims.ip = getClientIp(req); |
| | } |
| |
|
| | if (CHAT_TOKEN_BIND_USER_AGENT) { |
| | claims.uaHash = hashUserAgent(req.headers['user-agent'] || ''); |
| | } |
| |
|
| | const token = jwt.sign(claims, CHAT_TOKEN_SECRET, { |
| | algorithm: 'HS256', |
| | issuer: CHAT_TOKEN_ISSUER, |
| | expiresIn: CHAT_TOKEN_TTL_SECONDS, |
| | jwtid: crypto.randomBytes(12).toString('hex') |
| | }); |
| | res.status(200).json({ |
| | token, |
| | tokenType: 'Bearer', |
| | expiresIn: CHAT_TOKEN_TTL_SECONDS |
| | }); |
| | } catch (error) { |
| | log(`[Error] Chat token issuance failed: ${error.message}`); |
| | res.status(500).json({ |
| | error: 'Token issuance failed', |
| | message: 'Unable to issue chat token.' |
| | }); |
| | } |
| | }); |
| |
|
| | |
| | app.post('/api/v1/prediction/:instanceNum/:botName', requirePredictionAccess, async (req, res) => { |
| | try { |
| | const instanceNum = parseInt(req.params.instanceNum); |
| | const botName = normalizeBotName(req.params.botName); |
| |
|
| | if (!req.body.question || typeof req.body.question !== 'string') {
|
| | return res.status(400).json({
|
| | error: 'Invalid request',
|
| | message: 'Question must be a non-empty string.'
|
| | });
|
| | }
|
| |
|
| | if (req.body.question.length > 2000) {
|
| | return res.status(400).json({
|
| | error: 'Message too long',
|
| | message: 'Please keep messages under 2000 characters.'
|
| | });
|
| | }
|
| |
|
| | const { id, instance } = await resolveChatflowId(instanceNum, botName);
|
| |
|
| | const headers = { 'Content-Type': 'application/json' };
|
| | if (instance.key && instance.key.length > 0) {
|
| | headers['Authorization'] = `Bearer ${instance.key}`;
|
| | }
|
| |
|
| | const startTime = Date.now();
|
| | logSensitive(`[Timing] Calling Flowise at ${new Date().toISOString()}`);
|
| |
|
| | const response = await fetchWithTimeout(
|
| | `${instance.url}/api/v1/prediction/${id}`,
|
| | {
|
| | method: 'POST',
|
| | headers,
|
| | body: JSON.stringify(req.body)
|
| | },
|
| | 60000
|
| | );
|
| |
|
| | const duration = Date.now() - startTime;
|
| | log(`[Timing] Response received in ${(duration/1000).toFixed(1)}s`);
|
| |
|
| | if (!response.ok) {
|
| | const errorText = await response.text();
|
| | logSensitive(`[Error] Instance returned ${response.status}: ${errorText.substring(0, 100)}`);
|
| | return res.status(response.status).json({
|
| | error: 'Flowise instance error',
|
| | message: 'The chatbot instance returned an error.'
|
| | });
|
| | }
|
| |
|
| | const contentType = response.headers.get('content-type') || '';
|
| |
|
| | if (contentType.includes('text/event-stream')) {
|
| | log('[Streaming] Detected SSE response');
|
| | return handleStreamingResponse(response, res);
|
| | }
|
| |
|
| | log('[Non-streaming] Parsing JSON response');
|
| | const text = await response.text();
|
| |
|
| | try {
|
| | const data = JSON.parse(text);
|
| | res.status(200).json(data);
|
| | } catch (e) {
|
| | log('[Error] Invalid JSON response');
|
| | res.status(500).json({ error: 'Invalid response from Flowise' });
|
| | }
|
| |
|
| | } catch (error) { |
| | log(`[Error] Prediction request failed: ${error.message}`); |
| | res.status(500).json({ |
| | error: 'Request failed', |
| | message: 'Unable to process the request' |
| | }); |
| | } |
| | }); |
| |
|
| |
|
| | app.get('/api/v1/public-chatbotConfig/:instanceNum/:botName', async (req, res) => { |
| | try { |
| | const instanceNum = parseInt(req.params.instanceNum); |
| | const botName = normalizeBotName(req.params.botName); |
| |
|
| | const { id, instance } = await resolveChatflowId(instanceNum, botName);
|
| |
|
| | const headers = {};
|
| | if (instance.key && instance.key.length > 0) {
|
| | headers['Authorization'] = `Bearer ${instance.key}`;
|
| | }
|
| |
|
| | const response = await fetchWithTimeout(
|
| | `${instance.url}/api/v1/public-chatbotConfig/${id}`,
|
| | { headers },
|
| | 10000
|
| | );
|
| |
|
| | if (!response.ok) {
|
| | return res.status(response.status).json({ error: 'Config not available' });
|
| | }
|
| |
|
| | const data = await response.json();
|
| | res.status(200).json(data);
|
| |
|
| | } catch (error) { |
| | log(`[Error] Config request failed: ${error.message}`); |
| | res.status(404).json({ error: 'Config not available' }); |
| | } |
| | }); |
| |
|
| |
|
| | app.get('/api/v1/chatflows-streaming/:instanceNum/:botName', async (req, res) => { |
| | try { |
| | const instanceNum = parseInt(req.params.instanceNum); |
| | const botName = normalizeBotName(req.params.botName); |
| |
|
| | const { id, instance } = await resolveChatflowId(instanceNum, botName);
|
| |
|
| | const headers = {};
|
| | if (instance.key && instance.key.length > 0) {
|
| | headers['Authorization'] = `Bearer ${instance.key}`;
|
| | }
|
| |
|
| | const response = await fetchWithTimeout(
|
| | `${instance.url}/api/v1/chatflows-streaming/${id}`,
|
| | { headers },
|
| | 10000
|
| | );
|
| |
|
| | if (!response.ok) {
|
| | return res.status(200).json({ isStreaming: false });
|
| | }
|
| |
|
| | const data = await response.json();
|
| | res.status(200).json(data);
|
| |
|
| | } catch (error) {
|
| | log('[Error] Streaming check failed', 'debug');
|
| | res.status(200).json({ isStreaming: false });
|
| | }
|
| | });
|
| |
|
| |
|
| | if (!PRODUCTION_MODE) {
|
| | app.get('/test-stream', (req, res) => {
|
| | res.setHeader('Content-Type', 'text/event-stream');
|
| | res.setHeader('Cache-Control', 'no-cache');
|
| | res.setHeader('Connection', 'keep-alive');
|
| |
|
| | let count = 0;
|
| | const interval = setInterval(() => {
|
| | count++;
|
| | res.write(`data: {"message": "Test ${count}"}\n\n`);
|
| |
|
| | if (count >= 5) {
|
| | clearInterval(interval);
|
| | res.end();
|
| | }
|
| | }, 500);
|
| | });
|
| | }
|
| |
|
| |
|
| | app.get('/', (req, res) => res.send('Federated Proxy Active'));
|
| |
|
| | app.get('/health', (req, res) => { |
| | res.json({ |
| | status: 'healthy', |
| | uptime: process.uptime() |
| | }); |
| | }); |
| |
|
| |
|
| | app.use((req, res) => {
|
| | res.status(404).json({ error: 'Route not found' });
|
| | });
|
| |
|
| |
|
| | app.use((err, req, res, next) => {
|
| | log('[Error] Unhandled error');
|
| | res.status(500).json({ error: 'Internal server error' });
|
| | });
|
| |
|
| |
|
| | const server = app.listen(7860, '0.0.0.0', () => { |
| | log('===== Federated Proxy Started ====='); |
| | log(`Port: 7860`); |
| | log(`Mode: ${PRODUCTION_MODE ? 'PRODUCTION' : 'DEVELOPMENT'}`); |
| | log(`Auth Mode: ${AUTH_MODE}`); |
| | log(`Instances: ${INSTANCES.length}`); |
| | log(`Allowed Origins: ${allowedOrigins.length || 'None (cross-origin blocked)'}`); |
| | log(`API Keys: ${API_KEYS.length || (ALLOW_UNAUTHENTICATED_ACCESS ? 'None (INSECURE OPEN MODE)' : 'None')}`); |
| | log(`Trust Proxy: ${TRUST_PROXY}`); |
| | log(`Chat Token Auth: ${CHAT_TOKEN_AUTH_ENABLED ? `Enabled (${CHAT_TOKEN_TTL_SECONDS}s ttl)` : 'Disabled'}`); |
| | if (CHAT_TOKEN_AUTH_ENABLED) { |
| | log(`Turnstile Hostnames: ${EFFECTIVE_TURNSTILE_HOSTNAMES.length ? EFFECTIVE_TURNSTILE_HOSTNAMES.join(', ') : 'None (disabled)'}`); |
| | } |
| | if (AUTH_MODE !== 'api_key_only') { |
| | log('[Security Warning] Origin-based auth helps for browser gating but is weaker than API keys.'); |
| | } |
| | log('===================================='); |
| | }); |
| |
|
| | process.on('SIGTERM', () => {
|
| | log('[System] Shutting down gracefully...');
|
| | server.close(() => {
|
| | log('[System] Server closed');
|
| | process.exit(0);
|
| | });
|
| | });
|
| |
|
| | process.on('SIGINT', () => {
|
| | log('[System] Shutting down gracefully...');
|
| | server.close(() => {
|
| | log('[System] Server closed');
|
| | process.exit(0);
|
| | });
|
| | });
|
| |
|