diff --git a/groups/RODA/.env b/groups/RODA/.env
new file mode 100644
index 0000000..ffbaf40
--- /dev/null
+++ b/groups/RODA/.env
@@ -0,0 +1,4 @@
+DASHBOARD_TARGET_URL=https://dashboard.informatika.untan.ac.id/index.php?penelitian
+DASHBOARD_PROXY_1=https://api.allorigins.win/get?url={{url}}&ts={{ts}}
+DASHBOARD_PROXY_2=https://api.codetabs.com/v1/proxy?quest={{url}}
+DASHBOARD_PROXY_3=https://thingproxy.freeboard.io/fetch/{{rawUrl}}
\ No newline at end of file
diff --git a/groups/RODA/server.js b/groups/RODA/server.js
new file mode 100644
index 0000000..42dd119
--- /dev/null
+++ b/groups/RODA/server.js
@@ -0,0 +1,279 @@
+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;
+});