// ===== DASHBOARD CONSTANTS ===== const DC = { NAME: 'DashCaddy', POLL: { DASHBOARD: 10000, // 10s — main refreshAll interval LOGS: 3000, // 3s — log viewer updates STATS: 5000, // 5s — resource monitor refresh WEATHER: 600000, // 10m — weather widget refresh HEALTH: 1000, // 1s — card health badge refresh DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy }, DELAYS: { BTN_RESET: 2000, // Button text reset after action RELOAD: 5000, // Page reload after restart MODAL_CLOSE: 500, // Modal close animation PORT_CHECK: 500, // Debounce for port availability check DEPLOY_INIT: 3000, // Initial deploy cert check delay }, DEFAULTS: { DNS_PORT: '5380', SERVICE_PORT: '8080', TTL: 300, CADDYFILE: 'C:\\caddy\\Caddyfile', }, }; // ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) ===== // Only non-sensitive display preferences are cached; DNS IPs/topology are fetched from API const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null'); const SITE = { tld: (_cachedCfg && _cachedCfg.tld) || '.home', dnsIp: '', dnsPort: DC.DEFAULTS.DNS_PORT, dnsServers: {}, configurationType: (_cachedCfg && _cachedCfg.configurationType) || 'homelab', domain: (_cachedCfg && _cachedCfg.domain) || '', defaults: (_cachedCfg && _cachedCfg.defaults) || {}, routingMode: (_cachedCfg && _cachedCfg.routingMode) || 'subdomain', onboardingCompleted: false }; window.__dashcaddySiteConfigLoaded = (async function loadSiteConfig() { try { const r = await fetch('/api/v1/config'); if (r.ok) { const c = await r.json(); if (c.tld) SITE.tld = c.tld.startsWith('.') ? c.tld : '.' + c.tld; if (c.dns) { SITE.dnsIp = c.dns.ip || ''; SITE.dnsPort = c.dns.port || DC.DEFAULTS.DNS_PORT; } if (c.dnsServers && typeof c.dnsServers === 'object') { for (const [k, v] of Object.entries(c.dnsServers)) { if (k !== '__proto__' && k !== 'constructor' && k !== 'prototype') SITE.dnsServers[k] = v; } } if (c.configurationType) SITE.configurationType = c.configurationType; if (c.domain) SITE.domain = c.domain; if (c.defaults) SITE.defaults = c.defaults; if (c.routingMode) SITE.routingMode = c.routingMode; SITE.onboardingCompleted = c.onboardingCompleted === true; // Cache only non-sensitive display config (TLD, domain, routing mode) // DNS IPs and server topology are NOT cached — fetched from API each load localStorage.setItem('dashcaddy_site_config', JSON.stringify({ tld: SITE.tld, configurationType: SITE.configurationType, domain: SITE.domain, routingMode: SITE.routingMode })); // Render DNS cards dynamically based on configured servers renderDnsCards(); // Hide Tokens button if no DNS servers configured const tokensBtn = document.getElementById('manage-tokens'); if (tokensBtn) tokensBtn.style.display = Object.keys(SITE.dnsServers).length ? '' : 'none'; } } catch (_) {} // Update static HTML elements with configured TLD document.querySelectorAll('[data-tld]').forEach(el => el.textContent = SITE.tld); const tldSuffix = document.getElementById('edit-tld-suffix'); if (tldSuffix) tldSuffix.textContent = SITE.tld; const proxyIpInput = document.getElementById('external-proxy-ip'); if (proxyIpInput && SITE.dnsIp) { proxyIpInput.value = SITE.dnsIp; proxyIpInput.placeholder = SITE.dnsIp; } })(); function buildDomain(sub) { return sub + SITE.tld; } function buildServiceUrl(sub) { if (SITE.routingMode === 'subdirectory' && SITE.domain) return 'https://' + SITE.domain + '/' + sub; if (SITE.configurationType === 'public' && SITE.domain) return 'https://' + sub + '.' + SITE.domain; return 'https://' + buildDomain(sub); } function getDnsServerAddr(dnsId) { const s = SITE.dnsServers[dnsId]; return s ? `${s.ip}:${s.port}` : buildDomain(dnsId); } /** Get the DNS ID whose IP matches the primary DNS config (dns.ip) */ function getPrimaryDnsId() { if (!SITE.dnsIp) return null; for (const [id, info] of Object.entries(SITE.dnsServers)) { if (info.ip === SITE.dnsIp) return id; } return null; } // ===== DYNAMIC DNS CARD RENDERER ===== function renderDnsCards() { const topRow = document.querySelector('.top'); if (!topRow) return; const dnsIds = Object.keys(SITE.dnsServers); if (!dnsIds.length) return; // No DNS servers configured — show nothing const svgIcon = '' + '' + '' + '' + '' + '' + '' + '' + ''; const firstChild = topRow.firstElementChild; dnsIds.forEach(id => { const safeId = escapeHtml(id); const label = escapeHtml((SITE.dnsServers[id].name || id).toUpperCase()); const card = document.createElement('div'); card.className = 'card'; card.setAttribute('data-app', id); card.setAttribute('data-status', 'off'); card.innerHTML = `` + `
${svgIcon}
` + `${label}` + `OFF
` + `
--
` + `
--
` + `
` + `` + `` + `` + `` + `` + `
`; topRow.insertBefore(card, firstChild); }); } window.renderDnsCards = renderDnsCards; // ===== CSRF PROTECTION ===== let csrfToken = null; /** * Get CSRF token from server (cached) * @returns {Promise} CSRF token */ async function getCSRFToken() { if (csrfToken) return csrfToken; try { const response = await fetch('/api/v1/csrf-token'); if (!response.ok) { throw new Error('Failed to fetch CSRF token'); } const data = await response.json(); csrfToken = data.token; return csrfToken; } catch (error) { console.error('Failed to get CSRF token:', error); throw error; } } /** * Secure fetch wrapper that automatically adds CSRF token to state-changing requests * @param {string} url - URL to fetch * @param {Object} options - Fetch options * @returns {Promise} Fetch response */ async function secureFetch(url, options = {}) { const method = (options.method || 'GET').toUpperCase(); // Add CSRF token for state-changing methods if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { try { const token = await getCSRFToken(); options.headers = { ...options.headers, 'X-CSRF-Token': token }; } catch (error) { console.error('Failed to add CSRF token to request:', error); } } // Default 15s timeout if no signal provided (prevents hanging requests) if (!options.signal) { options = { ...options, signal: AbortSignal.timeout(15000) }; } return fetch(url, options); } // ===== API CALL HELPERS ===== /** POST JSON and return parsed response. Throws on HTTP or API error. */ async function postJSON(url, data) { const resp = await secureFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const result = await resp.json(); if (!resp.ok || result.success === false) { throw new Error(result.error || `Request failed (${resp.status})`); } return result; } /** GET JSON and return parsed response. Throws on HTTP error with server message. */ async function getJSON(url) { const resp = await secureFetch(url); if (!resp.ok) { let msg = `Request failed (${resp.status})`; try { const body = await resp.json(); msg = body.error || msg; } catch (_) {} throw new Error(msg); } return resp.json(); } /** DELETE request. Returns parsed JSON response. */ async function deleteAPI(url) { const resp = await secureFetch(url, { method: 'DELETE' }); const result = await resp.json(); if (!resp.ok || result.success === false) { throw new Error(result.error || `Delete failed (${resp.status})`); } return result; } /** * Run an async operation with button loading state. * Disables the button, shows loading text, restores on complete. * @param {HTMLElement} btn - Button element * @param {string} loadingText - Text to show while loading (e.g. 'Saving...') * @param {function} asyncFn - Async function to execute * @param {Object} opts - Options: { successText, resetDelay } */ async function withButton(btn, loadingText, asyncFn, opts = {}) { const original = btn.innerHTML; const { successText = '✅', resetDelay = DC.DELAYS.BTN_RESET } = opts; btn.disabled = true; btn.innerHTML = loadingText; try { const result = await asyncFn(); btn.innerHTML = successText; setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, resetDelay); return result; } catch (e) { btn.innerHTML = original; btn.disabled = false; throw e; } } /** Show/hide a modal by ID */ function openModal(id) { document.getElementById(id)?.classList.add('show'); } function closeModal(id) { document.getElementById(id)?.classList.remove('show'); } /** Wire backdrop-click close + optional close buttons for a modal element */ function wireModal(modal, ...closeBtns) { if (!modal) return; modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('show'); }); closeBtns.forEach(btn => btn?.addEventListener('click', () => modal.classList.remove('show'))); } /** Toast-style notification (replaces all alert() usage) */ function showNotification(text, type = 'info', duration = 3000) { const existingNotif = document.querySelector('.deploy-notification'); if (existingNotif) existingNotif.remove(); const colors = { info: { bg: '#2196F3', fg: '#fff' }, success: { bg: 'var(--ok-bg)', fg: 'var(--ok-fg)' }, error: { bg: '#f44336', fg: '#fff' }, warning: { bg: '#ff9800', fg: '#fff' } }; const c = colors[type] || colors.info; const msg = document.createElement('div'); msg.className = 'deploy-notification'; msg.textContent = text; msg.style.cssText = ` position: fixed; top: 20px; right: 20px; background: ${c.bg}; color: ${c.fg}; padding: 16px 24px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.3); z-index: 10000; animation: slideIn 0.3s ease-out; max-width: 400px; white-space: pre-line; font-size: 14px; `; document.body.appendChild(msg); if (duration > 0) setTimeout(() => msg.remove(), duration); } /** Relative time display (e.g. "5m ago", "2h ago") */ function timeAgo(ts) { const diff = Date.now() - new Date(ts).getTime(); if (diff < 60000) return 'just now'; if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; return Math.floor(diff / 86400000) + 'd ago'; } // ===== SAFE STORAGE WRAPPERS ===== // Prevents crashes in Safari private browsing, quota exceeded, or restricted environments function safeGet(key, fallback = null) { try { const v = localStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; } } function safeSet(key, value) { try { localStorage.setItem(key, value); } catch (_) { /* quota exceeded or private mode */ } } function safeRemove(key) { try { localStorage.removeItem(key); } catch (_) {} } function safeSessionGet(key, fallback = null) { try { const v = sessionStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; } } function safeSessionSet(key, value) { try { sessionStorage.setItem(key, value); } catch (_) {} } /** Parse JSON from localStorage with fallback — avoids try/catch at every call site */ function safeGetJSON(key, fallback = null) { try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch (_) { return fallback; } } /** Escape HTML entities for safe innerHTML insertion (handles both content and attributes) */ function escapeHtml(text) { return String(text ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } /** Inject modal HTML into DOM (idempotent — skips if element already exists) */ function injectModal(id, html) { if (document.getElementById(id)) return; document.body.insertAdjacentHTML('beforeend', html); } // ===== EVENT BUS ===== // Lightweight pub/sub for cross-module communication without window globals. // Usage: DC_BUS.on('services:loaded', handler); DC_BUS.emit('services:loaded', data); const DC_BUS = { _handlers: {}, on(event, fn) { (this._handlers[event] ||= []).push(fn); }, off(event, fn) { this._handlers[event] = this._handlers[event]?.filter(h => h !== fn); }, emit(event, data) { this._handlers[event]?.forEach(fn => fn(data)); } }; // ===== CENTRALIZED APP STATE ===== // Single source of truth for the services array. Modules should use AppState // instead of mutating window.APPS directly. Emits 'apps:changed' on updates. const AppState = { _apps: [], getApps() { return this._apps; }, setApps(apps) { this._apps = apps; window.APPS = apps; DC_BUS.emit('apps:changed', apps); }, findApp(id) { return this._apps.find(a => a.id === id); }, addApp(app) { this._apps.push(app); window.APPS = this._apps; DC_BUS.emit('apps:changed', this._apps); }, removeApp(id) { const idx = this._apps.findIndex(a => a.id === id); if (idx > -1) { this._apps.splice(idx, 1); window.APPS = this._apps; DC_BUS.emit('apps:changed', this._apps); } return idx > -1; }, updateApp(id, changes) { const app = this._apps.find(a => a.id === id); if (app) { for (const [k, v] of Object.entries(changes)) { if (k !== '__proto__' && k !== 'constructor' && k !== 'prototype') app[k] = v; } DC_BUS.emit('apps:changed', this._apps); } return app; } };