import crypto from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; import path from 'node:path'; const PORT = Number.parseInt(process.env.PORT || '8081', 10); if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`); const APP_VERSION = String(process.env.APP_VERSION || 'v1').trim() || 'v1'; const BUILD_TIMESTAMP = String(process.env.BUILD_TIMESTAMP || '').trim() || undefined; const STARTED_AT = new Date().toISOString(); const STATIC_DIR = process.env.STATIC_DIR || '/srv'; const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json'; const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json'; const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787'; const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on') .trim() .toLowerCase(); const BASIC_AUTH_ENABLED = !['off', 'false', '0', 'disabled', 'none'].includes(BASIC_AUTH_MODE); const AUTH_USER_HEADER = String(process.env.AUTH_USER_HEADER || 'x-trade-user') .trim() .toLowerCase(); function readJson(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } function timingSafeEqualStr(a, b) { const aa = Buffer.from(String(a), 'utf8'); const bb = Buffer.from(String(b), 'utf8'); if (aa.length !== bb.length) return false; return crypto.timingSafeEqual(aa, bb); } function loadBasicAuth() { const j = readJson(BASIC_AUTH_FILE); const username = (j?.username || '').toString(); const password = (j?.password || '').toString(); if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`); return { username, password }; } function loadApiReadToken() { const j = readJson(API_READ_TOKEN_FILE); const token = (j?.token || '').toString(); if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`); return token; } function send(res, status, headers, body) { res.statusCode = status; for (const [k, v] of Object.entries(headers || {})) res.setHeader(k, v); if (body == null) return void res.end(); res.end(body); } function sendJson(res, status, body) { send(res, status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, JSON.stringify(body)); } function basicAuthRequired(res) { res.setHeader('www-authenticate', 'Basic realm="trade"'); send(res, 401, { 'content-type': 'text/plain; charset=utf-8' }, 'unauthorized'); } function isAuthorized(req, creds) { const auth = req.headers.authorization || ''; const m = String(auth).match(/^Basic\s+(.+)$/i); if (!m?.[1]) return false; let decoded; try { decoded = Buffer.from(m[1], 'base64').toString('utf8'); } catch { return false; } const idx = decoded.indexOf(':'); if (idx < 0) return false; const u = decoded.slice(0, idx); const p = decoded.slice(idx + 1); return timingSafeEqualStr(u, creds.username) && timingSafeEqualStr(p, creds.password); } const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json; charset=utf-8', }; function contentTypeFor(filePath) { return MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; } function safePathFromUrlPath(urlPath) { const decoded = decodeURIComponent(urlPath); const cleaned = decoded.replace(/\0/g, ''); // strip leading slash so join() doesn't ignore STATIC_DIR const rel = cleaned.replace(/^\/+/, ''); const normalized = path.normalize(rel); // prevent traversal if (normalized.startsWith('..') || path.isAbsolute(normalized)) return null; return normalized; } function serveStatic(req, res) { if (req.method !== 'GET' && req.method !== 'HEAD') { send(res, 405, { 'content-type': 'text/plain; charset=utf-8' }, 'method_not_allowed'); return; } const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const rel = safePathFromUrlPath(url.pathname); if (rel == null) { send(res, 400, { 'content-type': 'text/plain; charset=utf-8' }, 'bad_request'); return; } const root = path.resolve(STATIC_DIR); const fileCandidate = path.resolve(root, rel); if (!fileCandidate.startsWith(root)) { send(res, 400, { 'content-type': 'text/plain; charset=utf-8' }, 'bad_request'); return; } const trySend = (filePath) => { try { const st = fs.statSync(filePath); if (st.isDirectory()) return trySend(path.join(filePath, 'index.html')); res.statusCode = 200; res.setHeader('content-type', contentTypeFor(filePath)); res.setHeader('cache-control', filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000'); if (req.method === 'HEAD') return void res.end(); fs.createReadStream(filePath).pipe(res); return true; } catch { return false; } }; // exact file, otherwise SPA fallback if (trySend(fileCandidate)) return; const indexPath = path.join(root, 'index.html'); if (trySend(indexPath)) return; send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found'); } function stripHopByHopHeaders(headers) { const hop = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ]); const out = {}; for (const [k, v] of Object.entries(headers || {})) { if (hop.has(k.toLowerCase())) continue; out[k] = v; } return out; } function readHeader(req, name) { const v = req.headers[String(name).toLowerCase()]; return Array.isArray(v) ? v[0] : v; } function resolveAuthUser(req) { const user = readHeader(req, AUTH_USER_HEADER) || readHeader(req, 'x-webauth-user'); const value = typeof user === 'string' ? user.trim() : ''; return value || null; } function hasCookie(req, name, value) { const raw = typeof req.headers.cookie === 'string' ? req.headers.cookie : ''; if (!raw) return false; return raw .split(';') .map((p) => p.trim()) .some((kv) => kv === `${name}=${value}`); } function proxyApi(req, res, apiReadToken) { const upstreamBase = new URL(API_UPSTREAM); const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const prefix = '/api'; const strippedPath = inUrl.pathname === prefix ? '/' : inUrl.pathname.startsWith(prefix + '/') ? inUrl.pathname.slice(prefix.length) : null; if (strippedPath == null) { send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found'); return; } const target = new URL(upstreamBase.toString()); target.pathname = strippedPath || '/'; target.search = inUrl.search; const isHttps = target.protocol === 'https:'; const lib = isHttps ? https : http; const headers = stripHopByHopHeaders(req.headers); delete headers.authorization; // basic auth from client must not leak upstream headers.host = target.host; headers.authorization = `Bearer ${apiReadToken}`; const upstreamReq = lib.request( { protocol: target.protocol, hostname: target.hostname, port: target.port || (isHttps ? 443 : 80), method: req.method, path: target.pathname + target.search, headers, }, (upstreamRes) => { const outHeaders = stripHopByHopHeaders(upstreamRes.headers); res.writeHead(upstreamRes.statusCode || 502, outHeaders); upstreamRes.pipe(res); } ); upstreamReq.on('error', (err) => { if (!res.headersSent) { send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`); } else { res.destroy(); } }); req.pipe(upstreamReq); } function handler(req, res) { if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) { send( res, 200, { 'content-type': 'application/json; charset=utf-8' }, JSON.stringify({ ok: true, version: APP_VERSION, buildTimestamp: BUILD_TIMESTAMP, startedAt: STARTED_AT }) ); return; } const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); if (req.method === 'GET' && url.pathname === '/whoami') { sendJson(res, 200, { ok: true, user: resolveAuthUser(req) }); return; } if (req.method === 'GET' && url.pathname === '/logout') { // NOTE: With HTTP basic auth handled upstream (Traefik), browser "logout" is best-effort. // We force a single 401 to show the browser prompt, then on retry we redirect back to '/'. const marker = 'trade_logout'; if (!hasCookie(req, marker, '1')) { res.setHeader('set-cookie', `${marker}=1; Path=/logout; Max-Age=10; SameSite=Lax`); res.setHeader('www-authenticate', 'Basic realm="trade"'); send(res, 401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }, 'logged_out'); return; } res.setHeader('set-cookie', `${marker}=; Path=/logout; Max-Age=0; SameSite=Lax`); res.statusCode = 302; res.setHeader('location', '/'); res.end(); return; } if (BASIC_AUTH_ENABLED) { let creds; try { creds = loadBasicAuth(); } catch (e) { send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); return; } if (!isAuthorized(req, creds)) { basicAuthRequired(res); return; } } if (req.url?.startsWith('/api') && (req.url === '/api' || req.url.startsWith('/api/'))) { let token; try { token = loadApiReadToken(); } catch (e) { send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); return; } proxyApi(req, res, token); return; } serveStatic(req, res); } const server = http.createServer(handler); server.listen(PORT, () => { console.log( JSON.stringify( { service: 'trade-frontend', port: PORT, staticDir: STATIC_DIR, apiUpstream: API_UPSTREAM, basicAuthFile: BASIC_AUTH_FILE, basicAuthMode: BASIC_AUTH_MODE, apiReadTokenFile: API_READ_TOKEN_FILE, authUserHeader: AUTH_USER_HEADER, }, null, 2 ) ); });