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(); // --- PRODUCTION MODE --- 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 ''; // Support wildcard allowlist entries such as *.example.com 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); } // Mask sensitive data 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); }); // --- API KEY AUTHENTICATION MIDDLEWARE --- 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(); }); // --- RATE LIMITING --- const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, message: { error: "Too many requests" }, standardHeaders: 'draft-7', legacyHeaders: false }); app.use(limiter); // --- REQUEST LOGGING (SAFE) --- 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(); }); // --- DAILY USAGE CAPS --- 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(); }); // --- BOT DETECTION --- 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(); }); // --- INSTANCES CONFIGURATION --- 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); } // --- CACHE WITH AUTO-CLEANUP --- 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); // --- FETCH WITH TIMEOUT --- 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); } } // --- RESOLVE CHATFLOW ID --- 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 }; } // --- STREAMING HANDLER --- 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 }); // --- CHAT TOKEN ISSUE ROUTE --- 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 /.' }); } 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.' }); } }); // --- PREDICTION ROUTE --- 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' }); } }); // --- CONFIG ROUTE --- 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' }); } }); // --- STREAMING CHECK ROUTE --- 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 }); } }); // --- TEST ENDPOINTS (DISABLED IN PRODUCTION) --- 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); }); } // --- HEALTH CHECK --- app.get('/', (req, res) => res.send('Federated Proxy Active')); app.get('/health', (req, res) => { res.json({ status: 'healthy', uptime: process.uptime() }); }); // --- 404 HANDLER --- app.use((req, res) => { res.status(404).json({ error: 'Route not found' }); }); // --- GLOBAL ERROR HANDLER --- app.use((err, req, res, next) => { log('[Error] Unhandled error'); res.status(500).json({ error: 'Internal server error' }); }); // --- SERVER START --- 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); }); });