proxy-v3 / server.js
unknownfriend00007's picture
Upload 3 files
e9ec81c verified
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 <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.'
});
}
});
// --- 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);
});
});