const fs = require('fs'); const http = require('http'); const path = require('path'); const { URL } = require('url'); const ROOT_DIR = __dirname; const PARENT_DIR = path.join(__dirname, '..', '..'); const INDEX_PATH = path.join(ROOT_DIR, 'index.html'); const ENV_PATH = path.join(ROOT_DIR, '.env'); const HOST = '127.0.0.1'; const PORT = Number(process.env.PORT || 3000); function logServerError(scope, error, extra = {}) { console.error(`[${scope}]`, error && error.stack ? error.stack : error, extra); } function parseEnvFile(filePath) { if (!fs.existsSync(filePath)) { return {}; } const env = {}; const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/); for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } const separatorIndex = line.indexOf('='); if (separatorIndex === -1) { continue; } const key = line.slice(0, separatorIndex).trim(); let value = line.slice(separatorIndex + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } env[key] = value; } return env; } function getAppConfig() { const env = { ...parseEnvFile(ENV_PATH), ...process.env }; const dashboardProxyTemplates = [ env.DASHBOARD_PROXY_1, env.DASHBOARD_PROXY_2, env.DASHBOARD_PROXY_3 ].filter(Boolean); return { dashboardTargetUrl: env.DASHBOARD_TARGET_URL || '', dashboardProxyTemplates }; } const DASHBOARD_CACHE_TTL_MS = 5 * 60 * 1000; let dashboardCache = { html: null, timestamp: 0 }; async function fetchDashboardHtml() { const targetUrl = getAppConfig().dashboardTargetUrl; if (!targetUrl) { throw new Error('DASHBOARD_TARGET_URL kosong'); } const now = Date.now(); if (dashboardCache.html && (now - dashboardCache.timestamp) < DASHBOARD_CACHE_TTL_MS) { return { html: dashboardCache.html, cached: 'HIT' }; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 12000); try { const response = await fetch(targetUrl, { signal: controller.signal, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36' } }); if (!response.ok) { throw new Error(`HTTP_ERROR_${response.status}`); } const html = await response.text(); dashboardCache = { html, timestamp: Date.now() }; return { html, cached: 'MISS' }; } catch (error) { if (dashboardCache.html) { return { html: dashboardCache.html, cached: 'STALE' }; } throw error; } finally { clearTimeout(timeoutId); } } function validateAppConfig(config) { const errors = []; if (!config.dashboardTargetUrl) { errors.push('DASHBOARD_TARGET_URL belum diisi.'); } if (!Array.isArray(config.dashboardProxyTemplates) || !config.dashboardProxyTemplates.length) { errors.push('Minimal satu DASHBOARD_PROXY_* harus diisi.'); } return errors; } function injectConfig(html) { const configScript = ``; const defaultConfigScript = ''; if (html.includes(defaultConfigScript)) { return html.replace(defaultConfigScript, configScript); } return html.replace('', ` ${configScript}\n`); } function sendHtml(res) { try { const html = fs.readFileSync(INDEX_PATH, 'utf8'); const configErrors = validateAppConfig(getAppConfig()); if (configErrors.length) { logServerError('config', new Error('Invalid app config'), { configErrors }); } const withConfig = injectConfig(html); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(withConfig); } catch (error) { logServerError('sendHtml', error); res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Internal Server Error'); } } function getMimeType(pathname) { const ext = path.extname(pathname).toLowerCase(); const mimeTypes = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.eot': 'application/vnd.ms-fontobject' }; return mimeTypes[ext] || 'application/octet-stream'; } function sendStaticFile(res, filepath) { try { if (!fs.existsSync(filepath)) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not found'); return; } const content = fs.readFileSync(filepath); const mimeType = getMimeType(filepath); res.writeHead(200, { 'Content-Type': mimeType }); res.end(content); } catch (error) { logServerError('sendStaticFile', error, { filepath }); res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Internal Server Error'); } } function sendNotFound(res) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not found'); } const server = http.createServer((req, res) => { try { const requestUrl = new URL(req.url, `http://${req.headers.host}`); const pathname = requestUrl.pathname; if (pathname === '/' || pathname === '/index.html') { sendHtml(res); return; } if (pathname === '/api/dashboard') { fetchDashboardHtml() .then(({ html, cached }) => { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'X-Cache': cached }); res.end(html); }) .catch((error) => { logServerError('api-dashboard', error); res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('DASHBOARD_FETCH_FAILED'); }); return; } // Handle /assets/ from parent directory if (pathname.startsWith('/assets/')) { const assetsFilepath = path.join(PARENT_DIR, pathname); const normalizedAssetsPath = path.normalize(assetsFilepath); if (!normalizedAssetsPath.startsWith(path.normalize(PARENT_DIR))) { sendNotFound(res); return; } sendStaticFile(res, normalizedAssetsPath); return; } // Serve static files from ROOT_DIR const filepath = path.join(ROOT_DIR, pathname); const normalizedPath = path.normalize(filepath); // Security check: ensure the requested file is within ROOT_DIR if (!normalizedPath.startsWith(path.normalize(ROOT_DIR))) { sendNotFound(res); return; } sendStaticFile(res, normalizedPath); } catch (error) { logServerError('request-handler', error, { url: req.url }); res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Internal Server Error'); } }); server.on('clientError', (error, socket) => { logServerError('clientError', error); socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); }); server.listen(PORT, HOST, () => { console.log(`Server ready at http://${HOST}:${PORT}`); }); server.on('error', (error) => { logServerError('server', error, { host: HOST, port: PORT }); process.exitCode = 1; }); process.on('uncaughtException', (error) => { logServerError('uncaughtException', error); process.exitCode = 1; }); process.on('unhandledRejection', (reason) => { logServerError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))); process.exitCode = 1; });