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 HOST = process.env.HOST || '127.0.0.1'; const PORT = Number(process.env.PORT || 3000); const DASHBOARD_TARGET_URL = 'https://dashboard.informatika.untan.ac.id/index.php?penelitian'; const RODA_PATH_PREFIX = '/groups/RODA'; function logServerError(scope, error, extra = {}) { console.error(`[${scope}]`, error && error.stack ? error.stack : error, extra); } function getAppConfig() { return { debugLogs: false, dashboardRuntimeFetch: 'always', dashboardApiUrl: 'api/dashboard', dashboardTargetUrl: DASHBOARD_TARGET_URL, }; } const DASHBOARD_CACHE_TTL_MS = 5 * 60 * 1000; let dashboardCache = { html: null, timestamp: 0 }; async function fetchDashboardHtml() { 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(DASHBOARD_TARGET_URL, { 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 injectConfig(html) { const configScript = ``; return html.replace('', ` ${configScript}\n`); } function sendHtml(res) { try { const html = fs.readFileSync(INDEX_PATH, 'utf8'); 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'); } function getRodaStaticPathname(pathname) { if (pathname === RODA_PATH_PREFIX || pathname === `${RODA_PATH_PREFIX}/` || pathname === `${RODA_PATH_PREFIX}/index.html`) { return '/index.html'; } if (pathname.startsWith(`${RODA_PATH_PREFIX}/`)) { return pathname.slice(RODA_PATH_PREFIX.length); } return pathname; } 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' || pathname === RODA_PATH_PREFIX || pathname === `${RODA_PATH_PREFIX}/` || pathname === `${RODA_PATH_PREFIX}/index.html`) { sendHtml(res); return; } if (pathname === '/api/dashboard' || pathname === `${RODA_PATH_PREFIX}/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, getRodaStaticPathname(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; });