Spaces:
Sleeping
Sleeping
Download server.js from parsawit/webhook-relay: direct link, hf CLI and curl.
- Browser
- Download file 25.4 kB
-
https://huggingface.co/spaces/parsawit/webhook-relay/resolve/main/server.js
- Command line
-
hf download hf://spaces/parsawit/webhook-relay/server.js
-
curl -L -o server.js https://huggingface.co/spaces/parsawit/webhook-relay/resolve/main/server.js
25.4 kB
| require("dotenv").config(); | |
| const express = require("express"); | |
| const helmet = require("helmet"); | |
| const rateLimit = require("express-rate-limit"); | |
| const morgan = require("morgan"); | |
| const crypto = require("crypto"); | |
| // βββ Config from environment βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const { | |
| // Dual-URL support: set both to avoid restarting the container. | |
| // TARGET_WEBHOOK_URL is accepted as a fallback alias for PROD. | |
| TARGET_WEBHOOK_URL_PROD, | |
| TARGET_WEBHOOK_URL_TEST, | |
| TARGET_WEBHOOK_URL, // legacy fallback β treated as PROD | |
| TARGET_API_KEY, // Bearer token for the private HF Space | |
| RELAY_SECRET, // A secret token callers must include so only YOU can use this relay | |
| // SLACK_SIGNING_SECRET removed | |
| PORT = 7860, // HF Spaces default port | |
| LOG_LEVEL = "combined", | |
| DEFAULT_ENV = "prod", // 'prod', 'test', or 'both' β sets the initial active env | |
| // Security hardening (all optional) | |
| ALLOWED_IPS, // Comma-separated IPs allowed to call /relay & /admin. Empty = allow all. | |
| ALLOWED_ORIGINS, // Comma-separated origins for CORS on /relay & /admin. Empty = block all browser cross-origin. | |
| RATE_LIMIT_RELAY = "20", // Max /relay requests per minute per IP (default: 20) | |
| RATE_LIMIT_ADMIN = "10", // Max /admin requests per minute per IP (default: 10) | |
| } = process.env; | |
| // Resolve effective prod URL (explicit var wins over legacy alias) | |
| const PROD_URL = TARGET_WEBHOOK_URL_PROD || TARGET_WEBHOOK_URL || ""; | |
| const TEST_URL = TARGET_WEBHOOK_URL_TEST || ""; | |
| // Validate required env vars at startup | |
| if (!PROD_URL && !TEST_URL) { | |
| console.error( | |
| "β Missing required env var: set at least one of TARGET_WEBHOOK_URL_PROD or TARGET_WEBHOOK_URL_TEST", | |
| ); | |
| process.exit(1); | |
| } | |
| if (!TARGET_API_KEY) { | |
| console.error("β Missing required env var: TARGET_API_KEY"); | |
| process.exit(1); | |
| } | |
| if (!RELAY_SECRET) { | |
| console.error("β Missing required env var: RELAY_SECRET"); | |
| process.exit(1); | |
| } | |
| // βββ In-memory active environment (no restart needed to switch) βββββββββββββββ | |
| // Valid values: 'prod' | 'test' | |
| // Switch via POST /admin/env or per-request via ?env= / X-Relay-Env header. | |
| let activeEnv = ["prod", "test", "both"].includes(DEFAULT_ENV) | |
| ? DEFAULT_ENV | |
| : "prod"; | |
| // βββ App setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const app = express(); | |
| // Trust proxy hops as configured via environment variable. If you run behind | |
| // one proxy (the common case for containers/loadβbalancers) set this to `1`. A | |
| // value of `0` disables proxy trusting; higher numbers trust more hops. | |
| // | |
| // This helps express-rate-limit and other middleware get the real client IP | |
| // from X-Forwarded-For. See https://expressjs.com/en/guide/behind-proxies.html | |
| // for details. | |
| const TRUST_PROXY = process.env.TRUST_PROXY || "1"; | |
| app.set("trust proxy", TRUST_PROXY); | |
| // Security headers (hides "X-Powered-By" etc.) | |
| app.use(helmet()); | |
| // Rate limiting β prevent abuse on the public relay | |
| const limiter = rateLimit({ | |
| windowMs: 60 * 1000, // 1 minute | |
| max: 60, // 60 requests per minute (global baseline) | |
| standardHeaders: true, | |
| legacyHeaders: false, | |
| message: { error: "Too many requests, slow down." }, | |
| }); | |
| app.use(limiter); | |
| // Stricter per-route limiters for sensitive endpoints | |
| const relayLimiter = rateLimit({ | |
| windowMs: 60 * 1000, | |
| max: parseInt(RATE_LIMIT_RELAY, 10) || 20, | |
| standardHeaders: true, | |
| legacyHeaders: false, | |
| message: { error: "Too many relay requests." }, | |
| }); | |
| const adminLimiter = rateLimit({ | |
| windowMs: 60 * 1000, | |
| max: parseInt(RATE_LIMIT_ADMIN, 10) || 10, | |
| standardHeaders: true, | |
| legacyHeaders: false, | |
| message: { error: "Too many admin requests." }, | |
| }); | |
| // Request logging β strip ?secret= from URLs so the relay secret never appears in logs. | |
| morgan.token("url-safe", (req) => | |
| (req.originalUrl || req.url || "").replace( | |
| /([?&])secret=[^&]*/gi, | |
| "$1secret=***", | |
| ), | |
| ); | |
| app.use( | |
| morgan( | |
| ':remote-addr - :remote-user [:date[clf]] ":method :url-safe HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', | |
| ), | |
| ); | |
| // Parse any content-type body so we can forward it as-is. | |
| app.use(express.json({ limit: "5mb" })); | |
| app.use(express.urlencoded({ extended: true, limit: "5mb" })); | |
| app.use(express.raw({ type: "*/*", limit: "5mb" })); | |
| // βββ Security middleware ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Optional IP allowlist β applied to /relay and /admin routes. | |
| // Set ALLOWED_IPS="1.2.3.4,5.6.7.8" in env. If empty, all IPs are allowed. | |
| const allowedIPs = ALLOWED_IPS | |
| ? ALLOWED_IPS.split(",") | |
| .map((ip) => ip.trim()) | |
| .filter(Boolean) | |
| : []; | |
| function ipAllowlist(req, res, next) { | |
| if (allowedIPs.length === 0) return next(); // not configured β allow all | |
| const clientIP = req.ip; | |
| if (allowedIPs.includes(clientIP)) return next(); | |
| console.warn(`[security] Blocked IP: ${clientIP}`); | |
| return res.status(403).json({ error: "Forbidden" }); | |
| } | |
| // CORS β applied to /relay and /admin routes. | |
| // Set ALLOWED_ORIGINS="https://mysite.com,https://other.com" in env. | |
| // If empty, browser cross-origin requests are blocked (non-browser callers are unaffected). | |
| const allowedOrigins = ALLOWED_ORIGINS | |
| ? ALLOWED_ORIGINS.split(",") | |
| .map((o) => o.trim()) | |
| .filter(Boolean) | |
| : []; | |
| function corsGuard(req, res, next) { | |
| const origin = req.headers.origin; | |
| if (!origin) return next(); // non-browser caller (curl, webhook services) β skip CORS | |
| if (allowedOrigins.length > 0 && allowedOrigins.includes(origin)) { | |
| res.set("Access-Control-Allow-Origin", origin); | |
| res.set("Vary", "Origin"); | |
| } else { | |
| // Origin not in allowlist β block browser cross-origin request | |
| return res.status(403).json({ error: "Origin not allowed" }); | |
| } | |
| res.set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); | |
| res.set( | |
| "Access-Control-Allow-Headers", | |
| "Content-Type,X-Relay-Secret,X-Relay-Env,Authorization", | |
| ); | |
| if (req.method === "OPTIONS") return res.sendStatus(204); | |
| next(); | |
| } | |
| // βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Resolves the effective env string for a request. | |
| * Priority: per-request ?env= / X-Relay-Env header > global activeEnv. | |
| */ | |
| function resolveTargetEnv(req) { | |
| const envParam = ( | |
| req.query.env || | |
| req.headers["x-relay-env"] || | |
| "" | |
| ).toLowerCase(); | |
| return ["prod", "test", "both"].includes(envParam) ? envParam : activeEnv; | |
| } | |
| /** | |
| * Returns an array of { env, url } objects for the resolved target(s). | |
| * When env is 'both', both prod and test URLs are included (if configured). | |
| */ | |
| function resolveTargetUrls(req) { | |
| const env = resolveTargetEnv(req); | |
| if (env === "both") { | |
| const urls = []; | |
| if (PROD_URL) urls.push({ env: "prod", url: PROD_URL }); | |
| if (TEST_URL) urls.push({ env: "test", url: TEST_URL }); | |
| return urls; | |
| } | |
| if (env === "test") { | |
| return [{ env: "test", url: TEST_URL || PROD_URL }]; // fall back to prod if test not set | |
| } | |
| return [{ env: "prod", url: PROD_URL || TEST_URL }]; // fall back to test if prod not set | |
| } | |
| /** | |
| * Redacts a URL for safe logging: shows the scheme and first 5 characters | |
| * of the host, then masks the rest with asterisks. | |
| */ | |
| function censorUrl(url) { | |
| if (!url) return "(not set)"; | |
| try { | |
| const u = new URL(url); | |
| const host = u.host; | |
| const visible = host.slice(0, 5); | |
| const masked = "*".repeat(Math.max(4, host.length - 5)); | |
| return `${u.protocol}//${visible}${masked}/`; | |
| } catch { | |
| // Not a valid URL β show first 5 chars then mask | |
| return url.slice(0, 5) + "****/"; | |
| } | |
| } | |
| /** | |
| * Constant-time comparison to prevent timing attacks on the secret. | |
| * Both sides are hashed to SHA-256 so the comparison is always over equal-length | |
| * buffers, eliminating the length-based timing side-channel. | |
| */ | |
| function verifySecret(provided) { | |
| if (!provided) return false; | |
| const a = crypto.createHash("sha256").update(provided).digest(); | |
| const b = crypto.createHash("sha256").update(RELAY_SECRET).digest(); | |
| return crypto.timingSafeEqual(a, b); | |
| } | |
| /** | |
| /** | |
| * Strips hop-by-hop and sensitive headers before forwarding. | |
| */ | |
| function sanitizeHeaders(incomingHeaders) { | |
| const skip = new Set([ | |
| "host", | |
| "authorization", | |
| "connection", | |
| "keep-alive", | |
| "transfer-encoding", | |
| "te", | |
| "trailer", | |
| "upgrade", | |
| "proxy-authorization", | |
| "proxy-connection", | |
| "x-relay-secret", // never forward our own secret | |
| "x-relay-env", // never forward env-selection header | |
| "content-length", // will be recalculated | |
| "content-type", // set explicitly below to avoid duplicate headers | |
| ]); | |
| const out = {}; | |
| for (const [k, v] of Object.entries(incomingHeaders)) { | |
| if (!skip.has(k.toLowerCase())) out[k] = v; | |
| } | |
| return out; | |
| } | |
| // Permitted inbound Content-Types. Anything else is coerced to application/json | |
| // to prevent Content-Type injection into the upstream service. | |
| const ALLOWED_CONTENT_TYPES = new Set([ | |
| "application/json", | |
| "application/x-www-form-urlencoded", | |
| "text/plain", | |
| "text/xml", | |
| "application/xml", | |
| "application/octet-stream", | |
| "multipart/form-data", | |
| ]); | |
| // βββ Health check (public) βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.get("/", (_req, res) => { | |
| res.json({ status: "ok", service: "webhook-relay" }); | |
| }); | |
| app.get("/health", (_req, res) => { | |
| res.json({ status: "healthy" }); | |
| }); | |
| // βββ Admin: switch active env (protected by relay secret) βββββββββββββββββββββ | |
| /** | |
| * GET /admin/env | |
| * Returns the current active environment and which URLs are configured. | |
| * Protected by X-Relay-Secret header or ?secret= query param. | |
| */ | |
| app.get("/admin/env", adminLimiter, ipAllowlist, corsGuard, (req, res) => { | |
| const secret = req.headers["x-relay-secret"] || req.query.secret; | |
| if (!verifySecret(secret)) | |
| return res.status(403).json({ error: "Forbidden" }); | |
| res.json({ | |
| activeEnv, | |
| configured: { | |
| prod: !!PROD_URL, | |
| test: !!TEST_URL, | |
| }, | |
| }); | |
| }); | |
| /** | |
| * POST /admin/env | |
| * Body: { "env": "test" } or { "env": "prod" } | |
| * Switches the global default target without restarting the container. | |
| * Protected by X-Relay-Secret header or ?secret= query param. | |
| */ | |
| app.post("/admin/env", adminLimiter, ipAllowlist, corsGuard, (req, res) => { | |
| const secret = req.headers["x-relay-secret"] || req.query.secret; | |
| if (!verifySecret(secret)) | |
| return res.status(403).json({ error: "Forbidden" }); | |
| const bodyObj = | |
| typeof req.body === "object" && !Buffer.isBuffer(req.body) ? req.body : {}; | |
| const requested = (bodyObj.env || "").toLowerCase(); | |
| if (requested === "test" && !TEST_URL) { | |
| return res | |
| .status(400) | |
| .json({ error: "TARGET_WEBHOOK_URL_TEST is not configured" }); | |
| } | |
| if (requested === "prod" && !PROD_URL) { | |
| return res | |
| .status(400) | |
| .json({ error: "TARGET_WEBHOOK_URL_PROD is not configured" }); | |
| } | |
| if (requested === "both" && (!PROD_URL || !TEST_URL)) { | |
| return res.status(400).json({ | |
| error: | |
| "Both TARGET_WEBHOOK_URL_PROD and TARGET_WEBHOOK_URL_TEST must be configured for 'both'", | |
| }); | |
| } | |
| if (!["prod", "test", "both"].includes(requested)) { | |
| return res | |
| .status(400) | |
| .json({ error: "env must be 'prod', 'test', or 'both'" }); | |
| } | |
| activeEnv = requested; | |
| console.log(`[admin] Active env switched to: ${activeEnv}`); | |
| res.json({ activeEnv }); | |
| }); | |
| // βββ Relay endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.all("/relay", relayLimiter, ipAllowlist, corsGuard, async (req, res) => { | |
| // ββ 1. Authenticate the caller ββ | |
| // Accept only a valid X-Relay-Secret header/query param | |
| const secret = req.headers["x-relay-secret"] || req.query.secret; | |
| if (!verifySecret(secret)) { | |
| // Generic 403 β intentionally vague so attackers learn nothing | |
| return res.status(403).json({ error: "Forbidden" }); | |
| } | |
| // ββ 2. Handle Slack URL verification challenge ββ | |
| const bodyObj = | |
| typeof req.body === "object" && !Buffer.isBuffer(req.body) | |
| ? req.body | |
| : null; | |
| if (bodyObj && bodyObj.type === "url_verification" && bodyObj.challenge) { | |
| return res.json({ challenge: bodyObj.challenge }); | |
| } | |
| // ββ 3. Build the forwarded request ββ | |
| try { | |
| const forwardHeaders = sanitizeHeaders(req.headers); | |
| // Attach the authentication header for the private HF Space | |
| forwardHeaders["Authorization"] = `Bearer ${TARGET_API_KEY}`; | |
| // Determine body to forward | |
| let body; | |
| const rawCT = req.headers["content-type"] || ""; | |
| const baseCT = rawCT.split(";")[0].trim().toLowerCase(); | |
| let contentType = ALLOWED_CONTENT_TYPES.has(baseCT) | |
| ? rawCT | |
| : "application/json"; | |
| if (Buffer.isBuffer(req.body)) { | |
| body = req.body; | |
| } else if (typeof req.body === "object" && req.body !== null) { | |
| body = JSON.stringify(req.body); | |
| contentType = "application/json"; | |
| } else if (req.body) { | |
| body = String(req.body); | |
| } | |
| forwardHeaders["Content-Type"] = contentType; | |
| const fetchOptions = { | |
| method: req.method, | |
| headers: forwardHeaders, | |
| }; | |
| if (body && !["GET", "HEAD"].includes(fetchOptions.method)) { | |
| fetchOptions.body = body; | |
| } | |
| // ββ 4. Log received request ββ | |
| console.log("\nβββ [RELAY] Incoming request βββββββββββββββββββββββββββ"); | |
| console.log(" Method :", req.method); | |
| console.log(" Path :", req.originalUrl); | |
| console.log( | |
| " Headers:", | |
| JSON.stringify( | |
| Object.fromEntries( | |
| Object.entries(req.headers) | |
| .filter( | |
| ([k]) => | |
| !["x-relay-secret", "x-relay-env"].includes(k.toLowerCase()), | |
| ) | |
| .map(([k, v]) => | |
| k.toLowerCase() === "authorization" ? [k, "Bearer ***"] : [k, v], | |
| ), | |
| ), | |
| null, | |
| 2, | |
| ), | |
| ); | |
| console.log( | |
| " Body :", | |
| body ? (Buffer.isBuffer(body) ? body.toString("utf8") : body) : "(none)", | |
| ); | |
| // ββ 5. Fan out to all resolved target(s) ββ | |
| const targets = resolveTargetUrls(req); | |
| if (targets.length === 0) { | |
| return res.status(500).json({ error: "No target URL configured" }); | |
| } | |
| const settled = await Promise.allSettled( | |
| targets.map(async ({ env: tEnv, url: tUrl }) => { | |
| const targetUrlObj = new URL(tUrl); | |
| for (const [k, v] of Object.entries(req.query)) { | |
| if (k !== "secret" && k !== "env") | |
| targetUrlObj.searchParams.append(k, v); | |
| } | |
| const targetUrlFinal = targetUrlObj.toString(); | |
| console.log( | |
| `\nβββ [RELAY] Forwarding to ${tEnv} ββββββββββββββββββββββββββββββββββββ`, | |
| ); | |
| console.log(" Target :", censorUrl(targetUrlFinal)); | |
| console.log(" Method :", fetchOptions.method); | |
| console.log( | |
| " Headers:", | |
| JSON.stringify( | |
| Object.fromEntries( | |
| Object.entries(forwardHeaders).map(([k, v]) => | |
| k.toLowerCase() === "authorization" | |
| ? [k, "Bearer ***"] | |
| : [k, v], | |
| ), | |
| ), | |
| null, | |
| 2, | |
| ), | |
| ); | |
| console.log( | |
| " Body :", | |
| fetchOptions.body | |
| ? Buffer.isBuffer(fetchOptions.body) | |
| ? fetchOptions.body.toString("utf8") | |
| : fetchOptions.body | |
| : "(none)", | |
| ); | |
| console.log( | |
| "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n", | |
| ); | |
| const controller = new AbortController(); | |
| const fetchTimer = setTimeout(() => controller.abort(), 30_000); | |
| try { | |
| const upstream = await fetch(targetUrlFinal, { | |
| ...fetchOptions, | |
| signal: controller.signal, | |
| }); | |
| const responseBody = await upstream.text(); | |
| return { tEnv, upstream, responseBody }; | |
| } finally { | |
| clearTimeout(fetchTimer); | |
| } | |
| }), | |
| ); | |
| // Log failures; return primary response (prod preferred, fallback to test) | |
| for (const r of settled) { | |
| if (r.status === "rejected") { | |
| console.error("[RELAY] Target failed:", r.reason?.message || r.reason); | |
| } | |
| } | |
| const primary = | |
| settled.find( | |
| (r) => r.status === "fulfilled" && r.value.tEnv === "prod", | |
| ) || settled.find((r) => r.status === "fulfilled"); | |
| if (!primary) { | |
| throw ( | |
| settled.find((r) => r.status === "rejected")?.reason || | |
| new Error("All targets failed") | |
| ); | |
| } | |
| const { upstream, responseBody } = primary.value; | |
| res.status(upstream.status); | |
| const upstreamCT = upstream.headers.get("content-type"); | |
| if (upstreamCT) res.set("Content-Type", upstreamCT); | |
| return res.send(responseBody); | |
| } catch (err) { | |
| console.error("Relay error:", err.message); | |
| return res | |
| .status(502) | |
| .json({ error: "Bad gateway β upstream unreachable" }); | |
| } | |
| }); | |
| // βββ Wildcard relay (optional: /relay/*) ββββββββββββββββββββββββββββββββββββββββ | |
| // Allows forwarding to sub-paths of the target, e.g. /relay/my-workflow | |
| app.all( | |
| "/relay/:path(*)", | |
| relayLimiter, | |
| ipAllowlist, | |
| corsGuard, | |
| async (req, res) => { | |
| const secret = req.headers["x-relay-secret"] || req.query.secret; | |
| if (!verifySecret(secret)) { | |
| return res.status(403).json({ error: "Forbidden" }); | |
| } | |
| // ...existing code... | |
| try { | |
| const subPath = req.params.path; | |
| // Guard against SSRF: reject protocol-relative or absolute URLs in the path. | |
| if (/^(https?:)?\/\//i.test(subPath)) { | |
| return res.status(400).json({ error: "Invalid path" }); | |
| } | |
| const forwardHeaders = sanitizeHeaders(req.headers); | |
| forwardHeaders["Authorization"] = `Bearer ${TARGET_API_KEY}`; | |
| let body; | |
| const rawCT2 = req.headers["content-type"] || ""; | |
| const baseCT2 = rawCT2.split(";")[0].trim().toLowerCase(); | |
| let contentType = ALLOWED_CONTENT_TYPES.has(baseCT2) | |
| ? rawCT2 | |
| : "application/json"; | |
| if (Buffer.isBuffer(req.body)) { | |
| body = req.body; | |
| } else if (typeof req.body === "object" && req.body !== null) { | |
| body = JSON.stringify(req.body); | |
| contentType = "application/json"; | |
| } else if (req.body) { | |
| body = String(req.body); | |
| } | |
| forwardHeaders["Content-Type"] = contentType; | |
| const fetchOptions = { | |
| method: req.method, | |
| headers: forwardHeaders, | |
| }; | |
| if (body && !["GET", "HEAD"].includes(fetchOptions.method)) { | |
| fetchOptions.body = body; | |
| } | |
| // ββ Log received request ββ | |
| console.log("\nβββ [RELAY/:path] Incoming request βββββββββββββββββββββ"); | |
| console.log(" Method :", req.method); | |
| console.log(" Path :", req.originalUrl); | |
| console.log( | |
| " Headers:", | |
| JSON.stringify( | |
| Object.fromEntries( | |
| Object.entries(req.headers) | |
| .filter( | |
| ([k]) => | |
| !["x-relay-secret", "x-relay-env"].includes(k.toLowerCase()), | |
| ) | |
| .map(([k, v]) => | |
| k.toLowerCase() === "authorization" | |
| ? [k, "Bearer ***"] | |
| : [k, v], | |
| ), | |
| ), | |
| null, | |
| 2, | |
| ), | |
| ); | |
| console.log( | |
| " Body :", | |
| body | |
| ? Buffer.isBuffer(body) | |
| ? body.toString("utf8") | |
| : body | |
| : "(none)", | |
| ); | |
| // ββ Fan out to all resolved target(s) ββ | |
| const targetBases = resolveTargetUrls(req); | |
| if (targetBases.length === 0) { | |
| return res.status(500).json({ error: "No target URL configured" }); | |
| } | |
| const settled = await Promise.allSettled( | |
| targetBases.map(async ({ env: tEnv, url: tBase }) => { | |
| const targetUrl = new URL( | |
| subPath, | |
| tBase.endsWith("/") ? tBase : tBase + "/", | |
| ); | |
| for (const [k, v] of Object.entries(req.query)) { | |
| if (k !== "secret" && k !== "env") | |
| targetUrl.searchParams.append(k, v); | |
| } | |
| console.log( | |
| `\nβββ [RELAY/:path] Forwarding to ${tEnv} ββββββββββββββββββββββββββββββββ`, | |
| ); | |
| console.log(" Target :", censorUrl(targetUrl.toString())); | |
| console.log(" Method :", fetchOptions.method); | |
| console.log( | |
| " Headers:", | |
| JSON.stringify( | |
| Object.fromEntries( | |
| Object.entries(forwardHeaders).map(([k, v]) => | |
| k.toLowerCase() === "authorization" | |
| ? [k, "Bearer ***"] | |
| : [k, v], | |
| ), | |
| ), | |
| null, | |
| 2, | |
| ), | |
| ); | |
| console.log( | |
| " Body :", | |
| fetchOptions.body | |
| ? Buffer.isBuffer(fetchOptions.body) | |
| ? fetchOptions.body.toString("utf8") | |
| : fetchOptions.body | |
| : "(none)", | |
| ); | |
| console.log( | |
| "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n", | |
| ); | |
| const controller2 = new AbortController(); | |
| const fetchTimer2 = setTimeout(() => controller2.abort(), 30_000); | |
| try { | |
| const upstream = await fetch(targetUrl.toString(), { | |
| ...fetchOptions, | |
| signal: controller2.signal, | |
| }); | |
| const responseBody = await upstream.text(); | |
| return { tEnv, upstream, responseBody }; | |
| } finally { | |
| clearTimeout(fetchTimer2); | |
| } | |
| }), | |
| ); | |
| // Log failures; return primary response (prod preferred, fallback to test) | |
| for (const r of settled) { | |
| if (r.status === "rejected") { | |
| console.error( | |
| "[RELAY/:path] Target failed:", | |
| r.reason?.message || r.reason, | |
| ); | |
| } | |
| } | |
| const primary = | |
| settled.find( | |
| (r) => r.status === "fulfilled" && r.value.tEnv === "prod", | |
| ) || settled.find((r) => r.status === "fulfilled"); | |
| if (!primary) { | |
| throw ( | |
| settled.find((r) => r.status === "rejected")?.reason || | |
| new Error("All targets failed") | |
| ); | |
| } | |
| const { upstream, responseBody } = primary.value; | |
| res.status(upstream.status); | |
| const upstreamCT = upstream.headers.get("content-type"); | |
| if (upstreamCT) res.set("Content-Type", upstreamCT); | |
| return res.send(responseBody); | |
| } catch (err) { | |
| console.error("Relay error:", err.message); | |
| return res | |
| .status(502) | |
| .json({ error: "Bad gateway β upstream unreachable" }); | |
| } | |
| }, | |
| ); | |
| // βββ 404 for everything else βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.use((_req, res) => { | |
| res.status(404).json({ error: "Not found" }); | |
| }); | |
| // βββ Start βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.listen(PORT, "0.0.0.0", () => { | |
| console.log(`π Webhook relay listening on port ${PORT}`); | |
| if (PROD_URL) console.log(`π‘ PROD URL : ${censorUrl(PROD_URL)}`); | |
| if (TEST_URL) console.log(`π§ͺ TEST URL : ${censorUrl(TEST_URL)}`); | |
| console.log(`π― Active env: ${activeEnv}`); | |
| console.log( | |
| `π‘ Switch env: POST /admin/env {"env":"test"|"prod"|"both"} with X-Relay-Secret`, | |
| ); | |
| console.log( | |
| `π IP allowlist: ${allowedIPs.length > 0 ? allowedIPs.join(", ") : "disabled (all IPs allowed)"}`, | |
| ); | |
| console.log( | |
| `π CORS origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(", ") : "not set (browser cross-origin blocked)"}`, | |
| ); | |
| console.log( | |
| `β‘ Rate limits: relay=${RATE_LIMIT_RELAY}/min, admin=${RATE_LIMIT_ADMIN}/min, global=60/min`, | |
| ); | |
| }); | |