Compare commits

...
9 Commits
Author SHA1 Message Date
Krystie 75f835641f [grade=B] fix(auth): use host-only session cookies on custom TLDs
CI / Security audit (push) Has been cancelled
CI / Test & Lint (push) Has been cancelled
Codex: urn:ump:7c22nwh67kot23f6czg5ax7e47hu2r7vowjpz3q6z63o73ti67vq
2026-07-24 05:15:08 -07:00
Krystie 872923dba2 fix(auth): route /api/auth/sso-exchange through the v1 rewrite shim
Caddy handle_path /dashcaddy-api/* only strips the /dashcaddy-api prefix, so
the login-page fetch to /dashcaddy-api/api/auth/sso-exchange arrived at the
app as /api/auth/sso-exchange - one path segment short of the canonical
/api/v1/auth/sso-exchange mount, so it 404d (masked by isPublicRoute never
even being reached). Add it to the same narrow gate/app-token rewrite case.
Caught by an end-to-end curl replay of the actual handoff flow before
asking for another live retest.
2026-07-24 05:15:07 -07:00
Krystie 10f2bf707b fix(auth): token-based handoff for cross-subdomain SSO
Domain=.sami cookies are silently rejected by real browsers - .sami is an
unregistered custom TLD, so browsers treat sami itself as the effective
public suffix and refuse to set a cookie scoped to it (the same rule that
stops a site from setting a supercookie for all of .com). Confirmed via
curl verbose (cookie dropped, domain must not set cookies for sami) and
via the Firefox console on the actual device (Cookie rejected for invalid
domain) for the same cookie. The session cookie set on status.sami after
TOTP verify could never reach plex.sami/jellyfin.sami/emby.sami/chat.sami
no matter how the cookie itself was built - prior fixes tonight left this
mechanism untouched, which is why the loop persisted.

Fix: /totp/verify mints a short-lived (60s) single-use opaque token. The
status.sami frontend appends it to the redirect URL when bouncing the
user back to a gated service. That services login page exchanges the
token via the new public GET /api/v1/auth/sso-exchange for a host-only
session cookie (no Domain attribute - always accepted). isSessionValid
only checks the cookies HMAC signature, never its Domain, so the host-only
cookie validates identically to the cross-domain one on every existing
check with zero changes to that logic.
2026-07-24 05:15:03 -07:00
Krystie f42e761e52 fix(auth): remove stray brace breaking auto-login page inline script
The never-trap fallback commit (fb638f6) left an extra closing brace after
the fail() call in the plex/jellyfin/emby page bodies (JSON.stringify(j))}
followed by another }).catch(...) on the next line - one brace too many,
since unlike the chat body these have no try/catch needing the extra scope).
This threw a SyntaxError parsing the inline <script>, which silently killed
the ENTIRE script - including the 15s failsafe redirect - leaving the page
stuck on "Signing in to ..." forever with zero console output explaining
why. Confirmed via node --check on the actual generated <script> contents
for all four services.
2026-07-24 05:15:00 -07:00
Krystie e208e05b83 fix(auth): relax CSP script-src for auto-login page (inline JS was silently blocked) 2026-07-24 05:14:59 -07:00
Krystie ba21dad550 DC-XXX: never-trap auto-login fallback — stale localStorage + manual links
Sami reported plex.sami/dashcaddy-login hangs at 'Signing in to Plex...'
indefinitely. Earlier commit (210c208) added 8s/15s timeouts at the SHELL
template level, but the per-service page bodies in buildLoginPage()'s
pages object had their own dead-end behavior: when app-token/:svc
returned an error or no token, the body called fail() showing an error
message but DID NOT redirect anywhere. With check-session still
returning authenticated, the SHELL's 15s failsafe timer never fires
because the script is still 'running' (in the failed .then chain).

Fix in each body (plex/jellyfin/emby/chat):
- After app-token returns no token, check localStorage for a stale token.
  If present, redirect to /web/?direct=1 — Plex/Jellyfin/Emby may still
  accept it for the session, and the user is unblocked either way.
- If no stale token, the fail() message now includes a manual link to
  /web/?direct=1 (not just status.sami re-auth), so the user always has
  an exit. fail() also shows the actual API response body (truncated)
  for easier debugging when something is genuinely wrong.
- catch() handlers get the same manual-link treatment.
- Removed the chat body's debug spam (Status: code + body dump to #d)
  that was making the UI look broken even when it wasn't.

Verified: 133/133 auth/sso/csrf/session tests pass; served page on
plex.sami/jellyfin.sami/emby.sami/chat.sami all contain
myPlexAccessToken/jellyfin_credentials/emby_credentials/token fallback
checks + Open X manually links.
2026-07-24 05:14:57 -07:00
Krystie 09d2451f2c DC-XXX: add AbortSignal timeouts to auto-login page JS, kill hang
buildLoginPage() in routes/auth/sso-gate.js shipped with bare fetches
(no signal). When app-token/:serviceId hung in the browser (slow upstream,
no response after 30s+, etc.), the page sat on 'Signing in to Plex...'
indefinitely. Verified on DNS2 2026-07-22: user reported 'still doing the
same thing' even after cookie + XFF fixes were verified working end-to-end.

Hardening:
- check-session fetch: 5s AbortSignal timeout
- app-token/:svc fetch (via ft()): 8s AbortSignal timeout
- 15s hard overall timer: if nothing succeeds, force-redirect to
  status.sami?auth=required so the user can re-auth
- try/catch around fail() to prevent DOM exception from breaking flow

Verified live: 133/133 auth/sso/csrf/session tests pass; container
healthy; served page contains 'withTimeout' + 'overallTimer' + '15000'.
Auto-login can no longer hang the page.
2026-07-24 05:14:57 -07:00
Krystie e69a93a825 bump version: cookie-only session 2026-07-24 05:14:56 -07:00
Krystie 96e2ef8609 DC-XXX: cookie-only session validation, kill IP-key cache mismatch
isSessionValid previously checked verifyIPSession() first, falling back to
verifySessionCookie() only if IP miss. Under Caddy --network host forward_auth,
req.ip arrived as 100.121.150.22 (DNS2 tailnet) instead of the user's real IP,
causing every cross-subdomain auto-login (plex/jellyfin/emby/chat) to 401 even
with a valid cookie. Now cookie-only; the IP cache write-back is kept as a
no-op for telemetry compat.

Verification on DNS2: /dashcaddy-login renders in 184ms (was 7s).
app-token/plex with the TOTP-issued cookie returns 200 with a real Plex token.
2026-07-24 05:14:55 -07:00
10 changed files with 310 additions and 83 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
8ea41e0
20260722-065235-cookie-only-session-653478a
@@ -0,0 +1,64 @@
'use strict';
const configureMiddleware = require('../src/utilities/middleware');
function buildSession() {
const app = {
param: jest.fn(),
set: jest.fn(),
use: jest.fn(),
};
return configureMiddleware(app, {
siteConfig: { dashboardHost: 'status.sami', tld: '.sami' },
totpConfig: { enabled: true, sessionDuration: '24h' },
tailscaleConfig: { enabled: false, requireAuth: false },
metrics: { recordRequest: jest.fn() },
auditLogger: { middleware: () => (_req, _res, next) => next() },
authManager: { verifyJWT: jest.fn(), verifyAPIKey: jest.fn() },
log: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
cryptoUtils: { loadOrCreateKey: () => Buffer.alloc(32, 7) },
isValidContainerId: () => true,
isTailscaleIP: () => false,
getTailscaleStatus: async () => null,
});
}
function captureCookie(setCookie) {
const headers = {};
setCookie({ setHeader: (name, value) => { headers[name.toLowerCase()] = value; } }, '24h');
return headers['set-cookie'];
}
describe('TOTP session cookie scope', () => {
test('primary login cookie is host-only for custom TLD deployments', () => {
const session = buildSession();
const cookie = captureCookie(session.setSessionCookie);
expect(cookie).toContain('dashcaddy_session=');
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('Secure');
expect(cookie).toContain('SameSite=Lax');
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
});
test('SSO exchange uses the same host-only cookie contract', () => {
const session = buildSession();
const cookie = captureCookie(session.setHostOnlySessionCookie);
expect(cookie).toContain('dashcaddy_session=');
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
});
test('logout clears the host-only secure cookie', () => {
const session = buildSession();
const headers = {};
session.clearSessionCookie({
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
});
expect(headers['set-cookie']).toContain('Max-Age=0');
expect(headers['set-cookie']).toContain('Secure');
expect(headers['set-cookie']).not.toMatch(/(?:^|;)\s*Domain=/i);
});
});
+98 -34
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../src/utilities/constants');
const { AuthenticationError, NotFoundError } = require('../../src/utilities/errors');
const { ok } = require('../../src/utils/responses');
/**
* Auth SSO gate routes factory
@@ -9,10 +10,10 @@ const { AuthenticationError, NotFoundError } = require('../../src/utilities/erro
*/
module.exports = function(deps) {
const router = express.Router();
// Extract dependencies
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps;
// Create ctx-like object for compatibility
const ctx = {
credentialManager,
@@ -202,6 +203,25 @@ module.exports = function(deps) {
}
}, 'auth-app-token'));
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
// *.sami origin calls this. Needed because Domain=.sami cookies are
// silently rejected by real browsers (.sami is an unregistered TLD, so
// browsers treat "sami" as the effective public suffix and refuse to set
// a cookie scoped to it) — see middleware.js for the full explanation.
// Public route (no session required to call it) since a fresh visitor to
// a gated service has no session yet by definition; the token itself is
// the credential, and it's one-time-use with a 60s TTL.
router.get('/auth/sso-exchange', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
const token = req.query.token;
if (!session.redeemHandoffToken(token)) {
return errorResponse(res, 401, 'Invalid or expired handoff token');
}
session.setCookieHostOnly(res, totpConfig.sessionDuration);
ok(res, { authenticated: true });
});
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
router.get('/auth/login-page', (req, res) => {
const service = (req.query.service || '').replace(/[^a-z]/g, '');
@@ -209,6 +229,14 @@ module.exports = function(deps) {
if (!html) return res.status(404).send('Unknown service');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
// This page is a server-rendered shell whose entire auto-login logic runs
// in an inline <script> (no external bundle - it's built per-service in
// buildLoginPage()). The app-wide Helmet CSP sets script-src 'self' with
// no inline exception, which silently blocks that script from ever
// running - no console-visible error on the page, no JS timeout fires,
// it just sits on "Signing in to ..." forever. Relax script-src for this
// one response only; every other route keeps the strict app-wide policy.
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'");
res.send(html);
});
@@ -222,57 +250,93 @@ function buildLoginPage(service) {
// session and we render the auto-login body; if 401, the meta-refresh kicks
// in and sends them to status.sami to authenticate first.
const SHELL = (body) => `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-space:pre-wrap}</style>
</head><body><p id="m">__TITLE__</p><div id="d"></div>
<script>(function(){
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
function go(u){setTimeout(function(){location.replace(u)},300)}
function fail(msg,info){m.innerHTML=msg;d.textContent=info||''}
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include'})}
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
// Pre-check session before attempting auto-login. If the user is not logged
// in, redirect to status.sami for TOTP auth first. The return= param sends
// them back to this login page after authenticating so auto-login can run.
fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store'}).then(function(r){return r.json()}).then(function(st){
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
${body}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+e.message)})
})()</script></body></html>`;
<html><head><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
</head><body><p id="m">__TITLE__</p><div id="d"></div>
<script>(function(){
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
// (default 8s) so a hung upstream can NEVER leave the page stuck on
// "Signing in to Plex..." indefinitely. Also: if check-session returns
// authenticated but app-token fails for any reason (no creds stored,
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
// stale token exists in localStorage, instead of failing silently.
function go(u){setTimeout(function(){location.replace(u)},300)}
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
// within 15s, force-redirect to status.sami so the user can re-auth.
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000);
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
// with this origin (Domain=.sami cookies are silently rejected by real
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
// effective public suffix). Instead status.sami hands us a one-time token
// in the URL after a successful TOTP verify; exchange it here for a cookie
// scoped to just this host, then strip it from the URL so it can't be
// reused or leak via history/referrer. If there's no token (or the
// exchange fails - expired, already used, etc.) this is a no-op and we
// fall through to the normal check-session flow below exactly as before.
var dcParams=new URLSearchParams(location.search);
var dcToken=dcParams.get('dc_token');
var preExchange=Promise.resolve();
if(dcToken){
dcParams.delete('dc_token');
var dcQs=dcParams.toString();
try{history.replaceState({},'',location.pathname+(dcQs?'?'+dcQs:''))}catch(_){}
preExchange=fetch('/dashcaddy-api/api/auth/sso-exchange?token='+encodeURIComponent(dcToken),{credentials:'include',signal:withTimeout(5000)}).catch(function(){});
}
// Pre-check session before attempting auto-login. If the user is not logged
// in, redirect to status.sami for TOTP auth first. The return= param sends
// them back to this login page after authenticating so auto-login can run.
preExchange.then(function(){
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
}).then(function(r){return r.json()}).then(function(st){
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
${body}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))})
})()</script></body></html>`;
const pages = {
chat: {
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
body: `if(ls.getItem('token')){go('/?direct=1');return}
d.textContent='Fetching token from DashCaddy...';
ft('chat').then(function(r){d.textContent+=' Status: '+r.status;return r.text()}).then(function(t){
d.textContent+='\\n'+t.substring(0,300);
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1')}
else{fail('Auto-login unavailable. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','No token field in response')}}
catch(e){fail('Auto-login error. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Parse error: '+e.message)}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Fetch error: '+e.message)})`
ft('chat').then(function(r){return r.text()}).then(function(t){
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
// No token but chat is reachable — fall through to manual UI link below
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/?direct=1">Open Chat manually</a>','Fetch error: '+(e&&e.message||'unknown'))})`
},
plex: {
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
ft('plex').then(function(r){return r.json()}).then(function(j){
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1')}
else{fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
// No token returned. Three fallbacks in priority order:
// 1. Stale token in localStorage — Plex may still accept it.
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
// 2. Manual link so the user is never trapped on this page.
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
},
jellyfin: {
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/')}
else{fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
},
emby: {
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/')}
else{fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
},
};
+14 -2
View File
@@ -220,8 +220,17 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// Rotate CSRF token for the new session
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
// Cross-subdomain SSO handoff token (see middleware.js "Cross-subdomain
// SSO token handoff" for why): the Domain=.sami cookie set above is
// silently dropped by real browsers on any OTHER *.sami subdomain, so
// status.sami's login-page frontend appends this token to the redirect
// URL when bouncing the user back to a gated service. That service's
// login page exchanges it via /auth/sso-exchange for its own host-only
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
const ssoToken = ctx.session.createHandoffToken();
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
}, 'totp-verify'));
// Check session validity (used by Caddy forward_auth)
@@ -243,7 +252,10 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
const valid = ctx.session.isValid(req);
log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
if (valid) {
return res.status(200).json({ authenticated: true });
// Response contract: { success: true, authenticated: true } — login-page
// consumer in /api/v1/auth/login-page reads `if(!st.success||!st.authenticated)`
// and would otherwise redirect valid sessions to status.sami in a TOTP loop.
return ok(res, { authenticated: true });
}
throw new AuthenticationError('Session expired or invalid');
+9 -1
View File
@@ -217,15 +217,23 @@ async function createApp() {
// /api/v1/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (drift)
// /api/auth/totp/check-session -> /api/v1/totp/check-session (mounted at /totp/check-session — no /auth prefix)
// /api/v1/auth/totp/check-session->/api/v1/totp/check-session (drift)
// /api/auth/sso-exchange -> /api/v1/auth/sso-exchange (mounted at /auth/sso-exchange, same shape as gate/app-token)
//
// The totp case drops `/auth` because the canonical route is /totp/check-session
// (no /auth prefix) but the legacy JS still uses /api/auth/totp/check-session
// (and a stale-browser version of the page uses /api/v1/auth/totp/check-session).
// Without these rewrites the JS gets a 404 and the page hangs at
// "Signing in to Plex..." forever (user-reported 2026-07-09).
//
// sso-exchange added 2026-07-24: same Caddy handle_path /dashcaddy-api/*
// strips only the /dashcaddy-api prefix, so the login-page JS's fetch to
// /dashcaddy-api/api/auth/sso-exchange arrives here as /api/auth/sso-exchange
// — needs the same rewrite as gate/app-token, not the check-session one
// (this route's canonical mount already includes /auth/).
app.use((req, res, next) => {
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/v1/auth/gate/')
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')) {
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')
|| req.url.startsWith('/api/auth/sso-exchange')) {
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
// Legacy: /api/auth/totp/check-session -> /api/v1/totp/check-session
+8 -1
View File
@@ -4,7 +4,11 @@
*/
function createSessionContext(middlewareResult) {
const { ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie, clearIPSession, clearSessionCookie, isSessionValid } = middlewareResult;
const {
ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie,
clearIPSession, clearSessionCookie, isSessionValid,
createHandoffToken, redeemHandoffToken, setHostOnlySessionCookie
} = middlewareResult;
return {
ipSessions,
@@ -15,6 +19,9 @@ function createSessionContext(middlewareResult) {
clear: clearIPSession,
clearCookie: clearSessionCookie,
isValid: isSessionValid,
createHandoffToken,
redeemHandoffToken,
setCookieHostOnly: setHostOnlySessionCookie,
};
}
+66 -6
View File
@@ -227,6 +227,10 @@ module.exports = function configureMiddleware(app, {
ipSessions.delete(getClientIP(req));
}
// Session cookies are intentionally host-only. Browsers reject Domain=.sami
// because .sami is an unregistered custom TLD and therefore treated as a
// public suffix. Cross-subdomain login is handled by the one-time SSO
// handoff below, which mints a separate host-only cookie on each service.
function setSessionCookie(res, durationKey) {
const durationMs = SESSION_DURATIONS[durationKey];
if (!durationMs) return;
@@ -235,9 +239,8 @@ module.exports = function configureMiddleware(app, {
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const key = cryptoUtils.loadOrCreateKey();
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
@@ -268,16 +271,28 @@ module.exports = function configureMiddleware(app, {
}
function clearSessionCookie(res) {
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
`${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
// COOKIE-ONLY session validation. The previous IP-keyed cache (verifyIPSession
// + the write-back in this function) caused cross-subdomain SSO breakage when
// Caddy on --network host forwards auth to the container: req.ip arrives as
// 100.121.150.22 (DNS2's tailnet IP) instead of the user's real IP, so the
// IP cache misses even when the cookie is valid. The host-only cookie is
// signed with a persisted HMAC key (loadOrCreateKey()). Cross-subdomain
// authentication uses the one-time SSO handoff because browsers reject
// Domain=.sami. HttpOnly + Secure + SameSite=Lax makes it a stronger
// credential than the IP cache. Ref: skill auth-and-monitoring-pitfalls.md
// "TOTP session validation IP-key issue" (FIXED 2026-07-21).
function isSessionValid(req) {
if (verifyIPSession(req)) return true;
const cookies = parseCookies(req.headers.cookie);
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
// Re-warm the IP cache as a no-op-only fast path (kept for backwards
// compat with code that reads ctx.session.ipSessions.size for telemetry,
// but it is NOT consulted for auth decisions). The next line intentionally
// does NOT gate the return on verifyIPSession anymore.
const ip = getClientIP(req);
if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) {
ipSessions.set(ip, { exp: Date.now() + SESSION_DURATIONS[totpConfig.sessionDuration] });
@@ -287,6 +302,43 @@ module.exports = function configureMiddleware(app, {
return false;
}
// ── Cross-subdomain SSO token handoff ──
// Domain=.sami cookies are silently rejected by real browsers: .sami is an
// unregistered custom TLD, so browsers treat "sami" itself as the effective
// public suffix and refuse to set a cookie scoped to it (the same rule that
// stops a site from setting a supercookie for all of .com). That means the
// session cookie set on status.sami never reaches plex.sami/jellyfin.sami/
// etc, and cross-subdomain SSO can never work via a shared cookie no matter
// how the cookie itself is constructed.
//
// Fix: after TOTP verify, mint a short-lived single-use opaque token and
// pass it in the redirect URL back to the target service. That service's
// origin exchanges the token (via /auth/sso-exchange) for its OWN host-only
// cookie (no Domain attribute — always accepted, since it's scoped to the
// exact host that set it). isSessionValid/verifySessionCookie don't care
// about the cookie's Domain at all, only its HMAC signature, so a host-only
// cookie validates identically to the cross-domain one — no changes needed
// to any existing session-check code path.
const ssoHandoffTokens = new Map();
const SSO_HANDOFF_TTL_MS = 60 * 1000;
function createHandoffToken() {
const token = crypto.randomBytes(24).toString('base64url');
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
return token;
}
function redeemHandoffToken(token) {
if (!token) return false;
const entry = ssoHandoffTokens.get(token);
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
return !!entry && entry.exp > Date.now();
}
function setHostOnlySessionCookie(res, durationKey) {
setSessionCookie(res, durationKey);
}
// ── Public routes (bypass TOTP and JWT auth) ──
// Routes here are accessible without authentication. By default the
// monitoring/health-check endpoints are public so the dashboard can
@@ -327,6 +379,11 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/auth/gate/', prefix: true },
{ path: '/api/v1/auth/app-token/', prefix: true },
{ path: '/api/v1/auth/login-page', exact: true, method: 'GET' },
// Must be public: a fresh cross-subdomain visitor has no session yet by
// definition — that's exactly the gap /auth/sso-exchange closes. The
// endpoint itself only accepts a valid single-use handoff token minted
// moments earlier by a successful TOTP verify, so this isn't an open door.
{ path: '/api/v1/auth/sso-exchange', exact: true, method: 'GET' },
// DC-046 pluggable auth endpoints — public by design (they ARE login).
// Use :provider placeholder; today's only provider is TOTP, but the
// route is parameterized so DC-047's email provider just works.
@@ -573,6 +630,9 @@ module.exports = function configureMiddleware(app, {
clearSessionCookie,
isSessionValid,
ipSessions,
renewCSRFToken
renewCSRFToken,
createHandoffToken,
redeemHandoffToken,
setHostOnlySessionCookie
};
};
+36 -36
View File
@@ -23,7 +23,7 @@
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(v),g>0&&setTimeout(()=>v.remove(),g)}function timeAgo(l){const n=Date.now()-new Date(l).getTime();return n<6e4?"just now":n<36e5?Math.floor(n/6e4)+"m ago":n<864e5?Math.floor(n/36e5)+"h ago":Math.floor(n/864e5)+"d ago"}function safeGet(l,n=null){try{const g=localStorage.getItem(l);return g!==null?g:n}catch{return n}}function safeSet(l,n){try{localStorage.setItem(l,n)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,n=null){try{const g=sessionStorage.getItem(l);return g!==null?g:n}catch{return n}}function safeSessionSet(l,n){try{sessionStorage.setItem(l,n)}catch{}}function safeGetJSON(l,n=null){try{const g=localStorage.getItem(l);return g?JSON.parse(g):n}catch{return n}}function escapeHtml(l){return String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(l,n){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",n)}const DC_BUS={_handlers:{},on(l,n){var g;((g=this._handlers)[l]||(g[l]=[])).push(n)},off(l,n){this._handlers[l]=this._handlers[l]?.filter(g=>g!==n)},emit(l,n){this._handlers[l]?.forEach(g=>g(n))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(n=>n.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const n=this._apps.findIndex(g=>g.id===l);return n>-1&&(this._apps.splice(n,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),n>-1},updateApp(l,n){const g=this._apps.find(b=>b.id===l);if(g){for(const[b,f]of Object.entries(n))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(g[b]=f);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function l(){const b=document.createElement("div");return b.className="skeleton-card",b.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',b}function n(b){const f=document.getElementById("cards");if(!(!f||f.querySelector(".card"))){b=b||6;for(let u=0;u<b;u++){const v=l();f.appendChild(v),setTimeout(function(){v.classList.add("loaded")},u*60)}}}function g(){const b=document.getElementById("cards");if(b){var f=b.querySelectorAll(".skeleton-card");f.length&&(f.forEach(function(u,v){setTimeout(function(){u.style.opacity="0",u.style.transform="translateY(-10px)"},v*25)}),setTimeout(function(){f.forEach(function(u){u.parentNode&&u.remove()})},f.length*25+300))}}window.SkeletonLoader={show:n,hide:g}})(),(function(){var l="theme",n="user-themes",g="custom-theme",b=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],f=b.slice(),u=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],v=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],s=["fg-muted","hover","card-hover","base","success","error","warning"],y={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
`,document.body.appendChild(v),g>0&&setTimeout(()=>v.remove(),g)}function timeAgo(l){const n=Date.now()-new Date(l).getTime();return n<6e4?"just now":n<36e5?Math.floor(n/6e4)+"m ago":n<864e5?Math.floor(n/36e5)+"h ago":Math.floor(n/864e5)+"d ago"}function safeGet(l,n=null){try{const g=localStorage.getItem(l);return g!==null?g:n}catch{return n}}function safeSet(l,n){try{localStorage.setItem(l,n)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,n=null){try{const g=sessionStorage.getItem(l);return g!==null?g:n}catch{return n}}function safeSessionSet(l,n){try{sessionStorage.setItem(l,n)}catch{}}function safeGetJSON(l,n=null){try{const g=localStorage.getItem(l);return g?JSON.parse(g):n}catch{return n}}function escapeHtml(l){return String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(l,n){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",n)}const DC_BUS={_handlers:{},on(l,n){var g;((g=this._handlers)[l]||(g[l]=[])).push(n)},off(l,n){this._handlers[l]=this._handlers[l]?.filter(g=>g!==n)},emit(l,n){this._handlers[l]?.forEach(g=>g(n))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(n=>n.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const n=this._apps.findIndex(g=>g.id===l);return n>-1&&(this._apps.splice(n,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),n>-1},updateApp(l,n){const g=this._apps.find(b=>b.id===l);if(g){for(const[b,f]of Object.entries(n))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(g[b]=f);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function l(){const b=document.createElement("div");return b.className="skeleton-card",b.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',b}function n(b){const f=document.getElementById("cards");if(!(!f||f.querySelector(".card"))){b=b||6;for(let u=0;u<b;u++){const v=l();f.appendChild(v),setTimeout(function(){v.classList.add("loaded")},u*60)}}}function g(){const b=document.getElementById("cards");if(b){var f=b.querySelectorAll(".skeleton-card");f.length&&(f.forEach(function(u,v){setTimeout(function(){u.style.opacity="0",u.style.transform="translateY(-10px)"},v*25)}),setTimeout(function(){f.forEach(function(u){u.parentNode&&u.remove()})},f.length*25+300))}}window.SkeletonLoader={show:n,hide:g}})(),(function(){var l="theme",n="user-themes",g="custom-theme",b=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],f=b.slice(),u=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],v=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],s=["fg-muted","hover","card-hover","base","success","error","warning"],y={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
background:
radial-gradient(1200px 800px at 10% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+x.r+","+x.g+","+x.b+`, .05), transparent 55%),
@@ -35,7 +35,7 @@
radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%),
var(--bg);
}
`}function i(E,B){var $=B.lightBg||B.bg&&d(B.bg)>.4;return $?":root."+E+` button:hover {
`}function d(E,B){var $=B.lightBg||B.bg&&i(B.bg)>.4;return $?":root."+E+` button:hover {
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
border-color: rgba(0, 0, 0, .15);
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
@@ -44,20 +44,20 @@
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function a(){u.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function h(E,B){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),b.indexOf($)!==-1&&($=$+"-custom");for(var C=safeGetJSON(n,{}),x=$,I=2;C[$]&&$!==B;)$=x+"-"+I++;return $}function p(E){var B=document.getElementById("user-theme-styles");B&&B.remove(),f.length=b.length,Object.keys(y).forEach(function(k){b.indexOf(k)===-1&&delete y[k]});var $=E||safeGetJSON(n,{}),C=Object.keys($);if(C=C.filter(function(k){return b.indexOf(k)===-1}),!!C.length){var x="";C.forEach(function(k){var S=$[k];f.indexOf(k)===-1&&f.push(k);var A={};u.forEach(function(O){S[O]&&(A[O]=S[O])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var D=e(A);s.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),y[k]=A,x+=":root."+k+` {
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function r(){u.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function h(E,B){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),b.indexOf($)!==-1&&($=$+"-custom");for(var C=safeGetJSON(n,{}),x=$,I=2;C[$]&&$!==B;)$=x+"-"+I++;return $}function p(E){var B=document.getElementById("user-theme-styles");B&&B.remove(),f.length=b.length,Object.keys(y).forEach(function(k){b.indexOf(k)===-1&&delete y[k]});var $=E||safeGetJSON(n,{}),C=Object.keys($);if(C=C.filter(function(k){return b.indexOf(k)===-1}),!!C.length){var x="";C.forEach(function(k){var S=$[k];f.indexOf(k)===-1&&f.push(k);var A={};u.forEach(function(O){S[O]&&(A[O]=S[O])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var D=e(A);s.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),y[k]=A,x+=":root."+k+` {
`,u.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`;
`)}),x+=`}
`,x+=o(k,A),x+=i(k,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(E){return E.json()}).then(function(E){if(!(!E.success||!E.themes)){var B=E.themes,$=safeGetJSON(n,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(n,JSON.stringify(B)),p(B);var C=safeGet(l);C&&f.indexOf(C)!==-1&&L(C)}}}).catch(function(){})}function T(){var E=safeGetJSON(g);if(E){var B=E.name||"Custom",$=h(B),C={name:B};u.forEach(function(k){E[k]&&(C[k]=E[k])});var x=safeGetJSON(n,{});x[$]=C,safeSet(n,JSON.stringify(x)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(g);var I={};u.forEach(function(k){C[k]&&(I[k]=C[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(E){document.documentElement.classList.add("theme-transitioning"),f.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),a(),E!=="dark"&&document.documentElement.classList.add(E),safeSet(l,E);var B=y[E],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var C=B&&B.lightBg;!C&&B&&B.bg&&(C=d(B.bg)>.4),C?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),p();var P=safeGet(l);P==="red"&&(P="black",safeSet(l,"black")),P&&P!=="dark"&&f.indexOf(P)===-1&&(P=null),L(P||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(E){safeGet(l)||L(E.matches?"dark":"light")}),window.THEMES=f,window.BUILTIN_THEMES=b,window.THEME_COLORS=y,window.THEME_PROPS=u,window.BASE_PROPS=v,window.DERIVED_PROPS=s,window.USER_THEMES_KEY=n,window.applyTheme=L,window.clearCustomProperties=a,window.injectUserThemeStyles=p,window.syncThemesFromServer=w,window.slugifyThemeName=h,window.getActiveTheme=function(){return safeGet(l)||t()},window.deriveExtendedColors=e,window.hexToRgb=c,window.rgbToHex=r,window.blendColors=m})(),(function(){let l=null;async function n(){if(l)return l;try{const y=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!y.ok)throw new Error(`methods HTTP ${y.status}`);const c=await y.json();return l=Array.isArray(c.providers)?c.providers:[],l}catch(y){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",y),[]}}function g(y){const c=document.getElementById("totp-overlay");if(!c)return;const r=c.querySelector(".totp-card");if(!r)return;const m=r.innerHTML;r.dataset.originalBody||(r.dataset.originalBody=m);const d=y.map(e=>{const o=e.config&&(e.config.label||e.name)||e.name;return`<button class="provider-btn" data-provider="${e.name}"
`,x+=o(k,A),x+=d(k,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(E){return E.json()}).then(function(E){if(!(!E.success||!E.themes)){var B=E.themes,$=safeGetJSON(n,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(n,JSON.stringify(B)),p(B);var C=safeGet(l);C&&f.indexOf(C)!==-1&&L(C)}}}).catch(function(){})}function T(){var E=safeGetJSON(g);if(E){var B=E.name||"Custom",$=h(B),C={name:B};u.forEach(function(k){E[k]&&(C[k]=E[k])});var x=safeGetJSON(n,{});x[$]=C,safeSet(n,JSON.stringify(x)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(g);var I={};u.forEach(function(k){C[k]&&(I[k]=C[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(E){document.documentElement.classList.add("theme-transitioning"),f.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),r(),E!=="dark"&&document.documentElement.classList.add(E),safeSet(l,E);var B=y[E],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var C=B&&B.lightBg;!C&&B&&B.bg&&(C=i(B.bg)>.4),C?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),p();var P=safeGet(l);P==="red"&&(P="black",safeSet(l,"black")),P&&P!=="dark"&&f.indexOf(P)===-1&&(P=null),L(P||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(E){safeGet(l)||L(E.matches?"dark":"light")}),window.THEMES=f,window.BUILTIN_THEMES=b,window.THEME_COLORS=y,window.THEME_PROPS=u,window.BASE_PROPS=v,window.DERIVED_PROPS=s,window.USER_THEMES_KEY=n,window.applyTheme=L,window.clearCustomProperties=r,window.injectUserThemeStyles=p,window.syncThemesFromServer=w,window.slugifyThemeName=h,window.getActiveTheme=function(){return safeGet(l)||t()},window.deriveExtendedColors=e,window.hexToRgb=c,window.rgbToHex=a,window.blendColors=m})(),(function(){let l=null;async function n(){if(l)return l;try{const y=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!y.ok)throw new Error(`methods HTTP ${y.status}`);const c=await y.json();return l=Array.isArray(c.providers)?c.providers:[],l}catch(y){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",y),[]}}function g(y){const c=document.getElementById("totp-overlay");if(!c)return;const a=c.querySelector(".totp-card");if(!a)return;const m=a.innerHTML;a.dataset.originalBody||(a.dataset.originalBody=m);const i=y.map(e=>{const o=e.config&&(e.config.label||e.name)||e.name;return`<button class="provider-btn" data-provider="${e.name}"
style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;">
Sign in with ${o}
</button>`}).join(`
`);r.innerHTML=`
`);a.innerHTML=`
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Choose how to sign in</p>
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${d}</div>
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${i}</div>
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
`,c.classList.add("show"),r.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const o=e.dataset.provider,i=y.find(t=>t.name===o);f(i)})})}function b(){const y=document.getElementById("totp-overlay");if(!y)return;const c=y.querySelector(".totp-card");!c||!c.dataset.originalBody||(c.innerHTML=c.dataset.originalBody,window.location.reload())}function f(y){const c=document.getElementById("totp-overlay");if(!c)return;const r=c.querySelector(".totp-card");if(r){if(y.name==="totp"){window.location.reload();return}if(y.name==="email"){r.innerHTML=`
`,c.classList.add("show"),a.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const o=e.dataset.provider,d=y.find(t=>t.name===o);f(d)})})}function b(){const y=document.getElementById("totp-overlay");if(!y)return;const c=y.querySelector(".totp-card");!c||!c.dataset.originalBody||(c.innerHTML=c.dataset.originalBody,window.location.reload())}function f(y){const c=document.getElementById("totp-overlay");if(!c)return;const a=c.querySelector(".totp-card");if(a){if(y.name==="totp"){window.location.reload();return}if(y.name==="email"){a.innerHTML=`
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Sign in with email</p>
@@ -75,13 +75,13 @@
<div style="margin-top: 16px;">
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">\u2190 Back</a>
</div>
`,c.classList.add("show");const m=r.querySelector("#auth-gate-email-input"),d=r.querySelector("#auth-gate-email-submit"),e=r.querySelector("#auth-gate-email-status"),o=r.querySelector("#auth-gate-back");d.addEventListener("click",async()=>{const i=(m.value||"").trim();if(!i||!i.includes("@")){e.textContent="Enter a valid email address.",e.style.color="var(--error, #d33)";return}d.disabled=!0,e.textContent="Sending\u2026",e.style.color="";try{const a=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:i})})).json();if(a.success){const h=a.deliveredVia||"email",p=a.maskedEmail||i;h==="dev-console"?e.innerHTML=`
`,c.classList.add("show");const m=a.querySelector("#auth-gate-email-input"),i=a.querySelector("#auth-gate-email-submit"),e=a.querySelector("#auth-gate-email-status"),o=a.querySelector("#auth-gate-back");i.addEventListener("click",async()=>{const d=(m.value||"").trim();if(!d||!d.includes("@")){e.textContent="Enter a valid email address.",e.style.color="var(--error, #d33)";return}i.disabled=!0,e.textContent="Sending\u2026",e.style.color="";try{const r=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:d})})).json();if(r.success){const h=r.deliveredVia||"email",p=r.maskedEmail||d;h==="dev-console"?e.innerHTML=`
Check the server logs for your one-time link.
(Dev mode: no SMTP configured. In production this would email <strong>${p}</strong>.)`:e.innerHTML=`Sign-in link sent to <strong>${p}</strong>.
Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=a.error||"Could not send link.",e.style.color="var(--error, #d33)",d.disabled=!1}catch{e.textContent="Connection error. Try again.",e.style.color="var(--error, #d33)",d.disabled=!1}}),m.addEventListener("keydown",i=>{i.key==="Enter"&&d.click()}),o.addEventListener("click",i=>{i.preventDefault(),g(providers)});return}console.warn("[auth-gate] unknown provider",y.name),window.location.reload()}}async function u(){if(!document.getElementById("totp-overlay"))return;const c=await n();if(c.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(c.length===1&&c[0].name==="totp"){v(c[0]);return}g(c)}function v(y){const c=l&&l.find(e=>e.name==="email");if(!c){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const r=document.getElementById("totp-overlay"),m=r.querySelector(".totp-card");if(!m)return;if(m.dataset.originalBody||(m.dataset.originalBody=m.innerHTML),!m.querySelector("#auth-gate-email-alt")){const e=document.createElement("div");e.id="auth-gate-email-alt",e.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",e.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=r.error||"Could not send link.",e.style.color="var(--error, #d33)",i.disabled=!1}catch{e.textContent="Connection error. Try again.",e.style.color="var(--error, #d33)",i.disabled=!1}}),m.addEventListener("keydown",d=>{d.key==="Enter"&&i.click()}),o.addEventListener("click",d=>{d.preventDefault(),g(providers)});return}console.warn("[auth-gate] unknown provider",y.name),window.location.reload()}}async function u(){if(!document.getElementById("totp-overlay"))return;const c=await n();if(c.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(c.length===1&&c[0].name==="totp"){v(c[0]);return}g(c)}function v(y){const c=l&&l.find(e=>e.name==="email");if(!c){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const a=document.getElementById("totp-overlay"),m=a.querySelector(".totp-card");if(!m)return;if(m.dataset.originalBody||(m.dataset.originalBody=m.innerHTML),!m.querySelector("#auth-gate-email-alt")){const e=document.createElement("div");e.id="auth-gate-email-alt",e.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",e.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
style="color: var(--accent); text-decoration: none;">
Or sign in with email instead \u2192
</a>`,m.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",o=>{o.preventDefault(),f(c)})}r.classList.add("show")}window.__dc_049_handled=!0;const s=new URLSearchParams(window.location.search);if(s.get("auth")==="required"){const y=s.get("return");if(y)try{if(new URL(y,window.location.origin).origin===window.location.origin)try{sessionStorage.setItem("totp_redirect",y)}catch{}}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(u,0)}window._showAuthGate=u,window._authGateMethods=n})(),(function(){function l(){const v=document.querySelector(".totp-card");if(!v)return;const y=getComputedStyle(v).backgroundColor.match(/\d+/g);if(!y)return;const c=(.299*+y[0]+.587*+y[1]+.114*+y[2])/255,r=v.querySelector(".totp-logo-dark"),m=v.querySelector(".totp-logo-light");r&&(r.style.display=c>.5?"none":""),m&&(m.style.display=c>.5?"":"none")}function n(){const v=document.getElementById("totp-overlay");if(v){v.classList.add("show"),setTimeout(l,50);const s=v.querySelector(".totp-digits input");s&&setTimeout(()=>s.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function g(){const v=document.getElementById("totp-overlay");v&&v.classList.remove("show")}const b=document.getElementById("totp-digits");if(b){const v=b.querySelectorAll("input");v.forEach((s,y)=>{s.addEventListener("input",c=>{const r=c.target.value.replace(/\D/g,"");c.target.value=r.slice(0,1),r&&y<v.length-1&&v[y+1].focus();const m=Array.from(v).map(d=>d.value).join("");m.length===6&&f(m)}),s.addEventListener("keydown",c=>{c.key==="Backspace"&&!c.target.value&&y>0&&(v[y-1].focus(),v[y-1].value="")}),s.addEventListener("paste",c=>{c.preventDefault();const r=(c.clipboardData.getData("text")||"").replace(/\D/g,"");r.length>=6&&(v.forEach((m,d)=>{m.value=r[d]||""}),v[5].focus(),f(r.slice(0,6)))})})}async function f(v){const s=document.getElementById("totp-error");s.textContent="Verifying...",s.className="totp-error verifying";try{const c=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:v})})).json();if(c.success){s.textContent="",c.csrfToken&&(csrfToken=c.csrfToken),g();const r=safeSessionGet("totp_redirect");if(r){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=r;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{s.textContent=c.error||"Invalid code",s.className="totp-error";const r=document.querySelectorAll("#totp-digits input");r.forEach(m=>{m.value=""}),r[0]?.focus()}}catch{s.textContent="Connection error",s.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const v=urlParams.get("return");if(v)try{const s=new URL(v,window.location.origin),y=s.hostname,c=s.origin===window.location.origin,r=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,m=y.endsWith(r)||y===r.substring(1);(c||m)&&safeSessionSet("totp_redirect",v)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=n})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function n(v){const s=document.getElementById("totp-recovery-link");s&&(s.style.display=v?"":"none")}function g(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="");const s=document.getElementById("totp-recovery-status"),y=document.getElementById("totp-recovery-import"),c=document.getElementById("totp-recovery-verify");y&&(y.style.display=""),c&&(c.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(r=>{s.textContent=r.hint||"",r.status==="healthy"?s.style.borderColor="var(--ok-fg, #7ef2ff)":r.status==="unreadable"?(s.style.borderColor="var(--bad-fg, #ff9aa3)",s.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):r.status==="not_configured"?s.style.borderColor="var(--muted)":s.style.borderColor="var(--border)"}),setTimeout(()=>{document.getElementById("totp-recovery-secret")?.focus()},100)}function b(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="none")}async function f(){const v=document.getElementById("totp-recovery-secret").value.trim(),s=document.getElementById("totp-recovery-error");if(s.textContent="",!v){s.textContent="Paste your Base32 key first";return}if(!/^[A-Za-z2-7\s]+=*$/.test(v)){s.textContent="Invalid Base32 format \u2014 should be letters A-Z and digits 2-7 only";return}try{const y=await fetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:v})}),c=await y.json();if(!y.ok||!c.success){s.textContent=c.Line truncated
</a>`,m.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",o=>{o.preventDefault(),f(c)})}a.classList.add("show")}window.__dc_049_handled=!0;const s=new URLSearchParams(window.location.search);if(s.get("auth")==="required"){const y=s.get("return");if(y)try{if(new URL(y,window.location.origin).origin===window.location.origin)try{sessionStorage.setItem("totp_redirect",y)}catch{}}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(u,0)}window._showAuthGate=u,window._authGateMethods=n})(),(function(){function l(){const v=document.querySelector(".totp-card");if(!v)return;const y=getComputedStyle(v).backgroundColor.match(/\d+/g);if(!y)return;const c=(.299*+y[0]+.587*+y[1]+.114*+y[2])/255,a=v.querySelector(".totp-logo-dark"),m=v.querySelector(".totp-logo-light");a&&(a.style.display=c>.5?"none":""),m&&(m.style.display=c>.5?"":"none")}function n(){const v=document.getElementById("totp-overlay");if(v){v.classList.add("show"),setTimeout(l,50);const s=v.querySelector(".totp-digits input");s&&setTimeout(()=>s.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function g(){const v=document.getElementById("totp-overlay");v&&v.classList.remove("show")}const b=document.getElementById("totp-digits");if(b){const v=b.querySelectorAll("input");v.forEach((s,y)=>{s.addEventListener("input",c=>{const a=c.target.value.replace(/\D/g,"");c.target.value=a.slice(0,1),a&&y<v.length-1&&v[y+1].focus();const m=Array.from(v).map(i=>i.value).join("");m.length===6&&f(m)}),s.addEventListener("keydown",c=>{c.key==="Backspace"&&!c.target.value&&y>0&&(v[y-1].focus(),v[y-1].value="")}),s.addEventListener("paste",c=>{c.preventDefault();const a=(c.clipboardData.getData("text")||"").replace(/\D/g,"");a.length>=6&&(v.forEach((m,i)=>{m.value=a[i]||""}),v[5].focus(),f(a.slice(0,6)))})})}async function f(v){const s=document.getElementById("totp-error");s.textContent="Verifying...",s.className="totp-error verifying";try{const c=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:v})})).json();if(c.success){s.textContent="",c.csrfToken&&(csrfToken=c.csrfToken),g();const a=safeSessionGet("totp_redirect");if(a){try{sessionStorage.removeItem("totp_redirect")}catch{}let m=a;if(c.ssoToken){const i=a.includes("?")?"&":"?";m=a+i+"dc_token="+encodeURIComponent(c.ssoToken)}window.location.href=m;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{s.textContent=c.error||"Invalid code",s.className="totp-error";const a=document.querySelectorAll("#totp-digits input");a.forEach(m=>{m.value=""}),a[0]?.focus()}}catch{s.textContent="Connection error",s.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const v=urlParams.get("return");if(v)try{const s=new URL(v,window.location.origin),y=s.hostname,c=s.origin===window.location.origin,a=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,m=y.endsWith(a)||y===a.substring(1);(c||m)&&safeSessionSet("totp_redirect",v)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=n})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function n(v){const s=document.getElementById("totp-recovery-link");s&&(s.style.display=v?"":"none")}function g(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="");const s=document.getElementById("totp-recovery-status"),y=document.getElementById("totp-recovery-import"),c=document.getElementById("totp-recovery-verify");y&&(y.style.display=""),c&&(c.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(a=>{s.textContent=a.hint||"",a.status==="healthy"?s.style.borderColor="var(--ok-fg, #7ef2ff)":a.status==="unreadable"?(s.style.borderColor="var(--bad-fg, #ff9aa3)",s.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):a.status==="not_configured"?s.style.borderColor="var(--muted)":s.style.borderColor="var(--border)"}),setTimeout(()=>{document.getElementById("totp-recovery-secret")?.focus()},100)}function b(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="none")}async function f(){const v=document.getElementById("totp-recovery-secret").value.trim(),s=document.getElementById("totp-recovery-error");if(s.textContent="",!v){s.textContent="Paste your Base32 key first";return}if(!/^[A-Za-z2-7\s]+=*$/.test(v)){s.textContent="Invalid Base32 format \u2014 should be letters A-Z and digits 2-7 only";return}try{const y=await fetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"applLine truncated
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>\u{1F4C2} Browse for Media Folders</h3>
@@ -179,7 +179,7 @@
</button>
</div>
</div>
</div>`);const n=document.getElementById("service-creds-modal");let g=null;const b=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function u(r){return r.externalUrl||r.url||""}function v(r){const m=document.getElementById("svc-creds-error");m.textContent=r,m.style.display=""}function s(){const r=document.getElementById("svc-creds-error");r.textContent="",r.style.display="none"}window.openServiceCredsModal=async function(r){g=r,s();const m=document.getElementById("svc-creds-title"),d=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),o=document.getElementById("svc-creds-apikey"),i=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");m.textContent=r.name+" Credentials";const a=!!r.isExternal,h=b.includes(r.id)||b.includes(r.appTemplate),p=f.includes(r.id)||f.includes(r.appTemplate);e.style.display=a?"":"none",o.style.display=h?"":"none",t.style.display=p?"":"none",i.style.display=a?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",a?(d.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${r.name}`):h?d.textContent="API key bypasses the app login screen automatically.":d.textContent="Credentials are injected automatically when accessing this service.",await y(r),n.classList.add("show")};async function y(r){const m=document.getElementById("svc-creds-dot"),d=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let o=!1;if(r.isExternal){try{const a=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();a.success?(document.getElementById("svc-seedhost-user").value=a.username||"",a.hasCredentials&&(o=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const a=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();a.success&&(a.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",o=!0):document.getElementById("svc-apikey-input").value="",a.hasBasicAuth&&!r.isExternal?(document.getElementById("svc-basic-user").value=a.username||"",o=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const i=r.id||r.appTemplate;if(f.includes(i)&&await c(r),o){m.style.background="var(--ok-fg, #74dfc4)",d.style.color="var(--ok-fg, #74dfc4)",d.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${r.id}`);t&&t.classList.add("has-creds")}else m.style.background="var(--muted)",d.style.color="var(--muted)",d.textContent="No credentials stored",e.style.display="none"}async function c(r){const m=document.getElementById("svc-quality-select"),d=document.getElementById("svc-quality-status"),e=r.id||r.appTemplate,o=u(r);if(!o){m.innerHTML='<option value="">-- No service URL --</option>';return}m.innerHTML='<option value="">Loading...</option>',d.textContent="";try{const i=new URLSearchParams({service:e,url:o}),a=await(await fetch(`/api/v1/arr/quality-profiles?${i}`)).json();if(!a.success||!a.profiles?.length){m.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}m.innerHTML="";for(const h of a.profiles){const p=document.createElement("option");p.value=h.id,p.textContent=h.name,m.appendChild(p)}if(a.storedProfileId&&(m.value=String(a.storedProfileId)),!m.value){const h=a.profiles.find(p=>/720/i.test(p.name));h&&(m.value=String(h.id))}!m.value&&a.profiles.length&&(m.value=String(a.profiles[0].id)),d.innerHTML=`<span style="color: var(--ok-fg);">${a.profiles.length} profiles loaded</span>`}catch(i){m.innerHTML='<option value="">-- Failed to load --</option>',d.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${i.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!g)return;const r=g.id||g.appTemplate,m=u(g),e=document.getElementById("svc-apikey-input")?.value.trim(),o=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status");if(!m){i.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){i.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}o.innerHTML='<option value="">Fetching...</option>',i.textContent="";try{const t=new URLSearchParams({service:r,url:m,apiKey:e}),h=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!h.success){o.innerHTML='<option value="">-- Error --</option>',i.innerHTML=`<span style="color: var(--error, #c62828);">${h.error||"Failed to feLine truncated
</div>`);const n=document.getElementById("service-creds-modal");let g=null;const b=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function u(a){return a.externalUrl||a.url||""}function v(a){const m=document.getElementById("svc-creds-error");m.textContent=a,m.style.display=""}function s(){const a=document.getElementById("svc-creds-error");a.textContent="",a.style.display="none"}window.openServiceCredsModal=async function(a){g=a,s();const m=document.getElementById("svc-creds-title"),i=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),o=document.getElementById("svc-creds-apikey"),d=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");m.textContent=a.name+" Credentials";const r=!!a.isExternal,h=b.includes(a.id)||b.includes(a.appTemplate),p=f.includes(a.id)||f.includes(a.appTemplate);e.style.display=r?"":"none",o.style.display=h?"":"none",t.style.display=p?"":"none",d.style.display=r?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",r?(i.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${a.name}`):h?i.textContent="API key bypasses the app login screen automatically.":i.textContent="Credentials are injected automatically when accessing this service.",await y(a),n.classList.add("show")};async function y(a){const m=document.getElementById("svc-creds-dot"),i=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let o=!1;if(a.isExternal){try{const r=await(await fetch(`/api/v1/seedhost-creds?serviceId=${a.id}`)).json();r.success?(document.getElementById("svc-seedhost-user").value=r.username||"",r.hasCredentials&&(o=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const r=await(await fetch(`/api/v1/services/${a.id}/credentials`)).json();r.success&&(r.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",o=!0):document.getElementById("svc-apikey-input").value="",r.hasBasicAuth&&!a.isExternal?(document.getElementById("svc-basic-user").value=r.username||"",o=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const d=a.id||a.appTemplate;if(f.includes(d)&&await c(a),o){m.style.background="var(--ok-fg, #74dfc4)",i.style.color="var(--ok-fg, #74dfc4)",i.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${a.id}`);t&&t.classList.add("has-creds")}else m.style.background="var(--muted)",i.style.color="var(--muted)",i.textContent="No credentials stored",e.style.display="none"}async function c(a){const m=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status"),e=a.id||a.appTemplate,o=u(a);if(!o){m.innerHTML='<option value="">-- No service URL --</option>';return}m.innerHTML='<option value="">Loading...</option>',i.textContent="";try{const d=new URLSearchParams({service:e,url:o}),r=await(await fetch(`/api/v1/arr/quality-profiles?${d}`)).json();if(!r.success||!r.profiles?.length){m.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}m.innerHTML="";for(const h of r.profiles){const p=document.createElement("option");p.value=h.id,p.textContent=h.name,m.appendChild(p)}if(r.storedProfileId&&(m.value=String(r.storedProfileId)),!m.value){const h=r.profiles.find(p=>/720/i.test(p.name));h&&(m.value=String(h.id))}!m.value&&r.profiles.length&&(m.value=String(r.profiles[0].id)),i.innerHTML=`<span style="color: var(--ok-fg);">${r.profiles.length} profiles loaded</span>`}catch(d){m.innerHTML='<option value="">-- Failed to load --</option>',i.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${d.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!g)return;const a=g.id||g.appTemplate,m=u(g),e=document.getElementById("svc-apikey-input")?.value.trim(),o=document.getElementById("svc-quality-select"),d=document.getElementById("svc-quality-status");if(!m){d.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){d.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}o.innerHTML='<option value="">Fetching...</option>',d.textContent="";try{const t=new URLSearchParams({service:a,url:m,apiKey:e}),h=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!h.success){o.innerHTML='<option value="">-- Error --</option>',d.innerHTML=`<span style="color: var(--error, #c62828);">${h.error||"Failed to feLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
@@ -288,7 +288,7 @@
</button>
</div>
</div>
</div>`);async function n(){try{const u=await(await fetch("/api/v1/totp/config")).json();if(!u.success)return;const{enabled:v,sessionDuration:s,isSetUp:y}=u.config,c=document.getElementById("totp-status-dot"),r=document.getElementById("totp-status-text"),m=document.getElementById("totp-status-banner"),d=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),o=document.getElementById("totp-duration-section"),i=document.getElementById("totp-disable-section");if(v&&y){c.style.background="var(--ok-fg, #7ef2ff)",m.style.borderColor="var(--ok-fg, #7ef2ff)",m.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",r.textContent="TOTP is active",r.style.color="var(--ok-fg, #7ef2ff)",d.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",o.style.display="block",i.style.display="block",document.getElementById("totp-duration-select").value=s}else c.style.background="var(--muted)",m.style.borderColor="var(--border)",m.style.background="transparent",r.textContent="TOTP is not configured",r.style.color="var(--muted)",d.style.display="block",e.style.display="none",o.style.display="none",i.style.display="none";b(v&&y,s)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const g={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function b(f,u){const v=document.getElementById("auth-card"),s=document.getElementById("auth-pill"),y=document.getElementById("auth-dot"),c=document.getElementById("auth-status-text");v&&(f?(v.setAttribute("data-status","on"),s.className="badge on",s.textContent="YES",y.className="dot ok at-bl",c.textContent="Session: "+(g[u]||u)):(v.setAttribute("data-status","off"),s.className="badge off",s.textContent="NO",y.className="dot bad at-bl",c.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const u=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();u.success&&(document.getElementById("totp-qr-image").src=u.qrCode,document.getElementById("totp-manual-key").textContent=u.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(f){l.logError("[TOTP] Setup Failed",f,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),u=document.getElementById("totp-import-error");if(u.textContent="",!f){u.textContent="Paste a Base32 secret key first";return}try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();s.success?(u.textContent="",document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):u.textContent=s.error||s.message||"Import failed"}catch{u.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const u=document.getElementById("totp-copy-key");u.textContent="\u2705",setTimeout(()=>{u.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent.trim();if(!f)return;const u={service:"DashCaddy",type:"totp-secret",secret:f,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},v=new Blob([JSON.stringify(u,null,2)],{type:"application/json"}),s=URL.createObjectURL(v),y=document.createElement("a");y.href=s,y.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(y),y.click(),document.body.removeChild(y),URL.revokeObjectURL(s);const c=document.getElementById("totp-download-backup");c.textContent="\u2705 Saved",setTimeout(()=>{c.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=doLine truncated
</div>`);async function n(){try{const u=await(await fetch("/api/v1/totp/config")).json();if(!u.success)return;const{enabled:v,sessionDuration:s,isSetUp:y}=u.config,c=document.getElementById("totp-status-dot"),a=document.getElementById("totp-status-text"),m=document.getElementById("totp-status-banner"),i=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),o=document.getElementById("totp-duration-section"),d=document.getElementById("totp-disable-section");if(v&&y){c.style.background="var(--ok-fg, #7ef2ff)",m.style.borderColor="var(--ok-fg, #7ef2ff)",m.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",a.textContent="TOTP is active",a.style.color="var(--ok-fg, #7ef2ff)",i.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",o.style.display="block",d.style.display="block",document.getElementById("totp-duration-select").value=s}else c.style.background="var(--muted)",m.style.borderColor="var(--border)",m.style.background="transparent",a.textContent="TOTP is not configured",a.style.color="var(--muted)",i.style.display="block",e.style.display="none",o.style.display="none",d.style.display="none";b(v&&y,s)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const g={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function b(f,u){const v=document.getElementById("auth-card"),s=document.getElementById("auth-pill"),y=document.getElementById("auth-dot"),c=document.getElementById("auth-status-text");v&&(f?(v.setAttribute("data-status","on"),s.className="badge on",s.textContent="YES",y.className="dot ok at-bl",c.textContent="Session: "+(g[u]||u)):(v.setAttribute("data-status","off"),s.className="badge off",s.textContent="NO",y.className="dot bad at-bl",c.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const u=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();u.success&&(document.getElementById("totp-qr-image").src=u.qrCode,document.getElementById("totp-manual-key").textContent=u.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(f){l.logError("[TOTP] Setup Failed",f,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),u=document.getElementById("totp-import-error");if(u.textContent="",!f){u.textContent="Paste a Base32 secret key first";return}try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();s.success?(u.textContent="",document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):u.textContent=s.error||s.message||"Import failed"}catch{u.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const u=document.getElementById("totp-copy-key");u.textContent="\u2705",setTimeout(()=>{u.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent.trim();if(!f)return;const u={service:"DashCaddy",type:"totp-secret",secret:f,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},v=new Blob([JSON.stringify(u,null,2)],{type:"application/json"}),s=URL.createObjectURL(v),y=document.createElement("a");y.href=s,y.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(y),y.click(),document.body.removeChild(y),URL.revokeObjectURL(s);const c=document.getElementById("totp-download-backup");c.textContent="\u2705 Saved",setTimeout(()=>{c.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=doLine truncated
<div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3>
@@ -306,7 +306,7 @@
</div>
</div>
</div>
`);function l(){return Object.keys(SITE.dnsServers||{})}function n(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function g(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const a=l();if(a.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const h of a)t.insertAdjacentHTML("beforeend",`
`);function l(){return Object.keys(SITE.dnsServers||{})}function n(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function g(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const r=l();if(r.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const h of r)t.insertAdjacentHTML("beforeend",`
<div class="token-section">
<h4 class="token-section-title">${n(h)}</h4>
<div class="token-grid">
@@ -329,17 +329,17 @@
</div>
<div class="token-status" id="${h}-token-status"></div>
</div>
`)}function b(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const a=safeGet("dashcaddy-encryption-key");if(a)return safeSessionSet("dashcaddy-encryption-key",a),safeRemove("dashcaddy-encryption-key"),a;const h=new Uint8Array(32);return crypto.getRandomValues(h),t=Array.from(h,p=>p.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const f=b();function u(t,a){if(!t)return"";const h=crypto.getRandomValues(new Uint8Array(8)),p=Array.from(h,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(a+p);let T="";for(let L=0;L<t.length;L++){const P=t.charCodeAt(L)^w[L%w.length]^h[L%h.length]^L*31+17&255;T+=String.fromCharCode(P)}return p+":"+btoa(T)}function v(t,a){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),P=atob(t.substring(17)),E=new TextEncoder().encode(a+T);let B="";for(let $=0;$<P.length;$++){const C=P.charCodeAt($)^E[$%E.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(C)}return B}const p=atob(t);let w="";for(let T=0;T<p.length;T++){const L=p.charCodeAt(T)^a.charCodeAt(T%a.length);w+=String.fromCharCode(L)}return w}catch{return""}}function s(t,a,h){const p=safeGet(`${t}-${a}-${h}-enc`);return v(p,f)}function y(t,a,h,p){const w=`${t}-${a}-${h}-enc`;p?safeSet(w,u(p,f)):safeRemove(w)}function c(t,a){return s(t,a,"token")}function r(t,a){return s(t,a,"username")}function m(t,a,h){y(t,a,"token",h)}function d(t,a,h){y(t,a,"username",h)}function e(){const t={};for(const a of l())t[a]={readonly:{username:r(a,"readonly"),token:c(a,"readonly")},admin:{username:r(a,"admin"),token:c(a,"admin")}};return t}function o(){l().forEach(t=>{["readonly","admin"].forEach(a=>{["token","username"].forEach(h=>{safeRemove(`${t}-${a}-${h}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function i(t){const a=c(t,"readonly"),h=r(t,"readonly"),p=c(t,"admin"),w=r(t,"admin"),T=v(safeGet(`${t}-token-enc`),f),L=v(safeGet(`${t}-username-enc`),f);return{username:w||h||L,token:p||a||T,readonlyToken:a||T,readonlyUsername:h||L,adminToken:p||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const t=document.getElementById("token-management-modal"),a=e();l().forEach(h=>{const p=a[h];document.getElementById(`${h}-readonly-username`).value=p.readonly.username,document.getElementById(`${h}-readonly-token`).value=p.readonly.token,document.getElementById(`${h}-admin-username`).value=p.admin.username,document.getElementById(`${h}-admin-token`).value=p.admin.token,document.getElementById(`${h}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const a=t.target.closest(".token-toggle");if(a){const h=a.dataset.target,p=document.getElementById(h);p.type==="password"?(p.type="text",a.textContent="\u{1F648}"):(p.type="password",a.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=l();t.forEach(p=>{d(p,"readonly",document.getElementById(`${p}-readonly-username`).value.trim()),m(p,"readonly",document.getElementById(`${p}-readonly-token`).value.trim()),d(p,"admin",document.getElementById(`${p}-admin-username`).value.trim()),m(p,"admin",document.getElementById(`${p}-admin-token`).value.trim())});const a={};let h=!1;if(t.forEach(p=>{const w={},T=document.getElementById(`${p}-readonly-username`).value.trim(),L=document.getElementById(`${p}-readonly-token`).value.trim(),P=document.getElementById(`${p}-admin-username`).value.trim(),E=document.getElementById(`${p}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},h=!0),P&&E&&(w.admin={username:P,password:E},h=!0),Object.keys(w).length>0&&(a[p]=w)}),h){t.forEach(p=>{a[p]&&(document.getElementById(`${p}-token-status`).textContent="Verifying...",document.getElementById(`${p}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:a})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!a[T]){L.textContent="";return}const P=w.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{a[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{a[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(p){console.erro Line truncated
`)}function b(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const r=safeGet("dashcaddy-encryption-key");if(r)return safeSessionSet("dashcaddy-encryption-key",r),safeRemove("dashcaddy-encryption-key"),r;const h=new Uint8Array(32);return crypto.getRandomValues(h),t=Array.from(h,p=>p.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const f=b();function u(t,r){if(!t)return"";const h=crypto.getRandomValues(new Uint8Array(8)),p=Array.from(h,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(r+p);let T="";for(let L=0;L<t.length;L++){const P=t.charCodeAt(L)^w[L%w.length]^h[L%h.length]^L*31+17&255;T+=String.fromCharCode(P)}return p+":"+btoa(T)}function v(t,r){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),P=atob(t.substring(17)),E=new TextEncoder().encode(r+T);let B="";for(let $=0;$<P.length;$++){const C=P.charCodeAt($)^E[$%E.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(C)}return B}const p=atob(t);let w="";for(let T=0;T<p.length;T++){const L=p.charCodeAt(T)^r.charCodeAt(T%r.length);w+=String.fromCharCode(L)}return w}catch{return""}}function s(t,r,h){const p=safeGet(`${t}-${r}-${h}-enc`);return v(p,f)}function y(t,r,h,p){const w=`${t}-${r}-${h}-enc`;p?safeSet(w,u(p,f)):safeRemove(w)}function c(t,r){return s(t,r,"token")}function a(t,r){return s(t,r,"username")}function m(t,r,h){y(t,r,"token",h)}function i(t,r,h){y(t,r,"username",h)}function e(){const t={};for(const r of l())t[r]={readonly:{username:a(r,"readonly"),token:c(r,"readonly")},admin:{username:a(r,"admin"),token:c(r,"admin")}};return t}function o(){l().forEach(t=>{["readonly","admin"].forEach(r=>{["token","username"].forEach(h=>{safeRemove(`${t}-${r}-${h}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function d(t){const r=c(t,"readonly"),h=a(t,"readonly"),p=c(t,"admin"),w=a(t,"admin"),T=v(safeGet(`${t}-token-enc`),f),L=v(safeGet(`${t}-username-enc`),f);return{username:w||h||L,token:p||r||T,readonlyToken:r||T,readonlyUsername:h||L,adminToken:p||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const t=document.getElementById("token-management-modal"),r=e();l().forEach(h=>{const p=r[h];document.getElementById(`${h}-readonly-username`).value=p.readonly.username,document.getElementById(`${h}-readonly-token`).value=p.readonly.token,document.getElementById(`${h}-admin-username`).value=p.admin.username,document.getElementById(`${h}-admin-token`).value=p.admin.token,document.getElementById(`${h}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const r=t.target.closest(".token-toggle");if(r){const h=r.dataset.target,p=document.getElementById(h);p.type==="password"?(p.type="text",r.textContent="\u{1F648}"):(p.type="password",r.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=l();t.forEach(p=>{i(p,"readonly",document.getElementById(`${p}-readonly-username`).value.trim()),m(p,"readonly",document.getElementById(`${p}-readonly-token`).value.trim()),i(p,"admin",document.getElementById(`${p}-admin-username`).value.trim()),m(p,"admin",document.getElementById(`${p}-admin-token`).value.trim())});const r={};let h=!1;if(t.forEach(p=>{const w={},T=document.getElementById(`${p}-readonly-username`).value.trim(),L=document.getElementById(`${p}-readonly-token`).value.trim(),P=document.getElementById(`${p}-admin-username`).value.trim(),E=document.getElementById(`${p}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},h=!0),P&&E&&(w.admin={username:P,password:E},h=!0),Object.keys(w).length>0&&(r[p]=w)}),h){t.forEach(p=>{r[p]&&(document.getElementById(`${p}-token-status`).textContent="Verifying...",document.getElementById(`${p}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:r})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!r[T]){L.textContent="";return}const P=w.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{r[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{r[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(p){console.erro Line truncated
Current: ${d.currentVersion}
New: ${d.updateVersion}
Current: ${i.currentVersion}
New: ${i.updateVersion}
`+(d.updateTitle?`${d.updateTitle}
`+(i.updateTitle?`${i.updateTitle}
`:"")+`The DNS server will restart during the update.
Proceed?`)){c.textContent=r,c.disabled=!1,c.title="Update DNS server";return}c.textContent="\u{1F504}",c.title="Updating...";const i=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(y)}`,{method:"POST"})).json();if(!i.success)throw new Error(i.error||"Update failed");if(i.manualUpdateRequired){c.textContent="\u2B06\uFE0F",c.title=`Update available: ${i.newVersion}`;const t=i.downloadLink?`
Download: ${i.downloadLink}`:"",a=i.instructionsLink?`
Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} update requires manual installation. Current: ${i.previousVersion} \u2192 ${i.newVersion}. Please update manually on the host machine.`,"warning",8e3),c.disabled=!1;return}c.textContent="\u2705",c.title="Updated successfully!",showNotification(`${s.toUpperCase()} updated successfully! ${i.previousVersion} \u2192 ${i.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{c.textContent=r,c.disabled=!1,c.title="Update DNS server",window.refreshAll()},1e4)}catch(m){console.error("DNS update error:",m),c.textContent="\u274C",c.title="Update failed",showNotification(`Failed to update ${s.toUpperCase()}: ${m.message}`,"error"),setTimeout(()=>{c.textContent=r,c.disabled=!1,c.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",s=>{const y=s.target.closest('[id$="-update"]');if(!y)return;const c=y.id.replace("-update","");SITE.dnsServers[c]&&n(c,SITE.dnsServers[c]?.ip)}),injectModal("dns-settings-modal",`
Proceed?`)){c.textContent=a,c.disabled=!1,c.title="Update DNS server";return}c.textContent="\u{1F504}",c.title="Updating...";const d=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(y)}`,{method:"POST"})).json();if(!d.success)throw new Error(d.error||"Update failed");if(d.manualUpdateRequired){c.textContent="\u2B06\uFE0F",c.title=`Update available: ${d.newVersion}`;const t=d.downloadLink?`
Download: ${d.downloadLink}`:"",r=d.instructionsLink?`
Instructions: ${d.instructionsLink}`:"";showNotification(`${s.toUpperCase()} update requires manual installation. Current: ${d.previousVersion} \u2192 ${d.newVersion}. Please update manually on the host machine.`,"warning",8e3),c.disabled=!1;return}c.textContent="\u2705",c.title="Updated successfully!",showNotification(`${s.toUpperCase()} updated successfully! ${d.previousVersion} \u2192 ${d.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{c.textContent=a,c.disabled=!1,c.title="Update DNS server",window.refreshAll()},1e4)}catch(m){console.error("DNS update error:",m),c.textContent="\u274C",c.title="Update failed",showNotification(`Failed to update ${s.toUpperCase()}: ${m.message}`,"error"),setTimeout(()=>{c.textContent=a,c.disabled=!1,c.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",s=>{const y=s.target.closest('[id$="-update"]');if(!y)return;const c=y.id.replace("-update","");SITE.dnsServers[c]&&n(c,SITE.dnsServers[c]?.ip)}),injectModal("dns-settings-modal",`
<div id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3>
@@ -366,7 +366,7 @@ Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
<button id="dns-settings-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);let g=null;function b(s){g=s;const y=SITE.dnsServers[s]||{},c=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(y.name||s).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=y.ip||"",document.getElementById("dns-edit-port").value=y.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=y.name||"",c.classList.add("show")}async function f(){if(!g)return;const s=document.getElementById("dns-edit-ip").value.trim(),y=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,c=document.getElementById("dns-edit-name").value.trim();if(!s){showNotification("Server IP is required","warning");return}const r={dnsServers:{}};r.dnsServers[g]={ip:s,port:String(y)},c&&(r.dnsServers[g].name=c);try{const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)})).json();d.success?(SITE.dnsServers[g]=r.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(d.error||"Failed to save settings","error")}catch(m){showNotification("Failed to save: "+m.message,"error")}}async function u(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const y=await(await secureFetch("/api/v1/config")).json();y.dnsServers&&delete y.dnsServers[g];const r=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:y.dnsServers||{}})})).json();if(r.success){delete SITE.dnsServers[g];const m=document.querySelector(`.top [data-app="${g}"]`);m&&m.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(r.error||"Failed to remove","error")}catch(s){showNotification("Failed to remove: "+s.message,"error")}}function v(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",f),document.getElementById("dns-settings-delete")?.addEventListener("click",u),document.getElementById("dns-settings-modal")?.addEventListener("click",s=>{s.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",s=>{const y=s.target.closest('[id$="-settings"]');if(!y)return;const c=y.id.replace("-settings","");SITE.dnsServers[c]&&(s.stopPropagation(),b(c))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
</div>`);let g=null;function b(s){g=s;const y=SITE.dnsServers[s]||{},c=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(y.name||s).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=y.ip||"",document.getElementById("dns-edit-port").value=y.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=y.name||"",c.classList.add("show")}async function f(){if(!g)return;const s=document.getElementById("dns-edit-ip").value.trim(),y=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,c=document.getElementById("dns-edit-name").value.trim();if(!s){showNotification("Server IP is required","warning");return}const a={dnsServers:{}};a.dnsServers[g]={ip:s,port:String(y)},c&&(a.dnsServers[g].name=c);try{const i=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();i.success?(SITE.dnsServers[g]=a.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(i.error||"Failed to save settings","error")}catch(m){showNotification("Failed to save: "+m.message,"error")}}async function u(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const y=await(await secureFetch("/api/v1/config")).json();y.dnsServers&&delete y.dnsServers[g];const a=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:y.dnsServers||{}})})).json();if(a.success){delete SITE.dnsServers[g];const m=document.querySelector(`.top [data-app="${g}"]`);m&&m.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(a.error||"Failed to remove","error")}catch(s){showNotification("Failed to remove: "+s.message,"error")}}function v(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",f),document.getElementById("dns-settings-delete")?.addEventListener("click",u),document.getElementById("dns-settings-modal")?.addEventListener("click",s=>{s.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",s=>{const y=s.target.closest('[id$="-settings"]');if(!y)return;const c=y.id.replace("-settings","");SITE.dnsServers[c]&&(s.stopPropagation(),b(c))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
<div id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header">
@@ -390,7 +390,7 @@ Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
</div>
</div>
</div>
</div>`);let l=null,n=null,g=!1,b=null,f=null,u=!1,v=null,s=null,y=!1,c=null,r=!1;async function m(C,x=25){try{const I=getDnsServerAddr(C),k=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function d(C){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[C]||"var(--fg)"}function e(C){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",C.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(C.raw)}</span>`,x;const I=d(C.rcode),k=C.rcode==="Refused"||C.rcode==="REFUSED";return x.innerHTML=`
</div>`);let l=null,n=null,g=!1,b=null,f=null,u=!1,v=null,s=null,y=!1,c=null,a=!1;async function m(C,x=25){try{const I=getDnsServerAddr(C),k=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function i(C){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[C]||"var(--fg)"}function e(C){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",C.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(C.raw)}</span>`,x;const I=i(C.rcode),k=C.rcode==="Refused"||C.rcode==="REFUSED";return x.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(C.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(C.client)}">${escapeHtml(C.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${k?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(C.domain)}">${escapeHtml(C.domain)}</span>
@@ -413,10 +413,10 @@ Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
</div>`}catch(I){x.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function i(C){l=C,g=!1,u=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${C.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),o(),n=setInterval(o,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),n&&(clearInterval(n),n=null),h(),l=null,u=!1,b=null,f=null,y=!1,v=null,s=null,g=!1}function a(C){c&&h();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),k=document.getElementById("logs-content");n&&(clearInterval(n),n=null);try{c=new EventSource(`/api/v1/logs/stream/${C}`),r=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),c.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),h();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",_=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
</div>`}}function d(C){l=C,g=!1,u=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${C.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),o(),n=setInterval(o,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),n&&(clearInterval(n),n=null),h(),l=null,u=!1,b=null,f=null,y=!1,v=null,s=null,g=!1}function r(C){c&&h();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),k=document.getElementById("logs-content");n&&(clearInterval(n),n=null);try{c=new EventSource(`/api/v1/logs/stream/${C}`),a=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),c.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),h();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",_=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
<div style="flex-shrink: 0;">${_}</div>
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
`,k.appendChild(O),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},c.onerror=A=>{console.error("EventSource error:",A),h()}}catch(S){console.error("Failed to start streaming:",S),h()}}function h(){c&&(c.close(),c=null),r=!1;const C=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");C&&(C.classList.remove("active"),C.textContent="\u{1F4E1} Live",C.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),u&&b&&!n&&(n=setInterval(T,DC.POLL.LOGS))}async function p(C,x=100){try{const I=`/api/v1/logs/container/${C}?tail=${x}&timestamps=true`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(C){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=C.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=C.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=`
`,k.appendChild(O),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},c.onerror=A=>{console.error("EventSource error:",A),h()}}catch(S){console.error("Failed to start streaming:",S),h()}}function h(){c&&(c.close(),c=null),a=!1;const C=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");C&&(C.classList.remove("active"),C.textContent="\u{1F4E1} Live",C.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),u&&b&&!n&&(n=setInterval(T,DC.POLL.LOGS))}async function p(C,x=100){try{const I=`/api/v1/logs/container/${C}?tail=${x}&timestamps=true`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(C){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=C.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=C.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=`
<div style="flex-shrink: 0;">${k}</div>
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(C.text)}</div>
`,x}async function T(){if(g||!b||!u)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await p(b,C);if(I.error){x.innerHTML=`
@@ -449,7 +449,7 @@ Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
</div>`}catch(I){x.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function $(C,x){v=C,s=x,y=!0,u=!1,g=!1;const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),n=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",C=>{const x=C.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&i(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const C=document.getElementById("logs-pause");g?(C.textContent="\u25B6\uFE0F Resume",C.classList.add("paused")):(C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),o())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||o()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!u||!b||(r?h():a(b))}),document.getElementById("logs-modal")?.addEventListener("click",C=>{C.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",C=>{C.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=i})(),(function(){injectModal("service-edit-modal",`
</div>`}}function $(C,x){v=C,s=x,y=!0,u=!1,g=!1;const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),n=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",C=>{const x=C.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&d(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const C=document.getElementById("logs-pause");g?(C.textContent="\u25B6\uFE0F Resume",C.classList.add("paused")):(C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),o())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||o()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!u||!b||(a?h():r(b))}),document.getElementById("logs-modal")?.addEventListener("click",C=>{C.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",C=>{C.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=d})(),(function(){injectModal("service-edit-modal",`
<div id="service-edit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3>
@@ -805,17 +805,17 @@ Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
<button id="add-service-create" class="btn-accent">Create Service</button>
</div>
</div>
</div>`)})(),(function(){async function l(u){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(u)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const s=await v.json();if(s.success){const y=document.getElementById("existing-ca-select");return y.innerHTML="",s.cas.length===0?y.innerHTML='<option value="">No CAs found in Caddyfile</option>':(y.innerHTML='<option value="">Select existing CA...</option>',s.cas.forEach(c=>{const r=document.createElement("option");typeof c=="object"?(r.value=c.id,r.textContent=c.displayName||c.name):(r.value=c,r.textContent=c),y.appendChild(r)})),s.data.cas}else throw new Error(s.message)}catch(v){console.error("Error loading CAs:",v);const s=document.getElementById("existing-ca-select");return s.innerHTML='<option value="">Error loading CAs</option>',[]}}function n(u){const{subdomain:v,port:s,ip:y,sslType:c,caName:r,existingCa:m,enableAuth:d,enableCors:e,customHeaders:o,upstreamPath:i,healthCheck:t,timeout:a,tailscaleOnly:h}=u;let p=`${buildDomain(v)} {
</div>`)})(),(function(){async function l(u){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(u)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const s=await v.json();if(s.success){const y=document.getElementById("existing-ca-select");return y.innerHTML="",s.cas.length===0?y.innerHTML='<option value="">No CAs found in Caddyfile</option>':(y.innerHTML='<option value="">Select existing CA...</option>',s.cas.forEach(c=>{const a=document.createElement("option");typeof c=="object"?(a.value=c.id,a.textContent=c.displayName||c.name):(a.value=c,a.textContent=c),y.appendChild(a)})),s.data.cas}else throw new Error(s.message)}catch(v){console.error("Error loading CAs:",v);const s=document.getElementById("existing-ca-select");return s.innerHTML='<option value="">Error loading CAs</option>',[]}}function n(u){const{subdomain:v,port:s,ip:y,sslType:c,caName:a,existingCa:m,enableAuth:i,enableCors:e,customHeaders:o,upstreamPath:d,healthCheck:t,timeout:r,tailscaleOnly:h}=u;let p=`${buildDomain(v)} {
`;switch(h&&(p+=` @blocked not remote_ip 100.64.0.0/10
`,p+=` respond @blocked "Access denied. Tailscale connection required." 403
`),c){case"letsencrypt":break;case"caddy-managed":p+=` tls internal
`;break;case"existing-ca":m&&(p+=` tls {
ca ${m}
}
`);break;case"custom-ca":r&&(p+=` tls {
ca ${r}
`);break;case"custom-ca":a&&(p+=` tls {
ca ${a}
}
`);break}if(d&&(p+=` basicauth {
`);break}if(i&&(p+=` basicauth {
admin $2a$14$hashed_password_here
}
`),e&&(p+=` header {
@@ -828,21 +828,21 @@ Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
`}),p+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(p+=` health_uri ${t}
`),p+=` reverse_proxy ${y}:${s} {
`,i&&i!=="/"&&(p+=` rewrite ${i}
`),a&&a!==30&&(p+=` transport http {
`,p+=` dial_timeout ${a}s
`,p+=` response_header_timeout ${a}s
`,d&&d!=="/"&&(p+=` rewrite ${d}
`),r&&r!==30&&(p+=` transport http {
`,p+=` dial_timeout ${r}s
`,p+=` response_header_timeout ${r}s
`,p+=` }
`),p+=` }
`,p+=`}
`,p}async function g(u,v,s=DC.DEFAULTS.TTL){const y=window.getToken(getPrimaryDnsId(),"admin");if(!y)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const c=buildDomain(u),r=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:c,ip:v,ttl:s,token:y,server:SITE.dnsIp})});if(!r.ok){const d=await r.text();throw new Error(`DNS API Error: ${r.status} - ${d}`)}const m=await r.json();if(!m.success)throw new Error(`DNS Error: ${m.error||"Unknown error"}`);return m}async function b(u){const v={id:u.subdomain,name:u.name,logo:u.logo||`/assets/${u.subdomain}.png`};u.category&&(v.category=u.category),u.containerId&&(v.containerId=u.containerId);try{const s=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!s.ok){const y=await s.json();throw new Error(y.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(s){throw console.error("Failed to add service to config:",s),s}}async function f(u){const v=document.getElementById("service-subdomain-input").value.trim(),s=document.getElementById("service-ip-input").value.trim()||"localhost",y=document.getElementById("service-port-input").value.trim()||"80",c=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${s}:${y}`,config:u})}),r=await c.json();if(!c.ok||!r.success)throw new Error(r.error||`Caddy API Error: ${c.status}`);return r}window.loadExistingCAs=l,window.generateCaddyConfig=n,window.createDnsRecord=g,window.addServiceToConfig=b,window.addToCaddyfile=f})(),(function(){let l=null;function n(s){l=s;const y=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${s.name}`,document.getElementById("edit-service-name").value=s.name,document.getElementById("edit-service-url-display").textContent=s.url||buildServiceUrl(s.id),document.getElementById("edit-service-logo-preview").src=s.logo||`/assets/${s.id}.png`,document.getElementById("edit-subdomain").value=s.id,document.getElementById("edit-port").value=s.port||"",document.getElementById("edit-ip").value=s.ip||"localhost",document.getElementById("edit-tailscale-only").checked=s.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=s.logo||"";const c=document.getElementById("edit-service-category");c&&(c.dataset.current=s.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),y.classList.add("show")}function g(){closeModal("service-edit-modal"),l=null}async function b(){if(!l)return;const s=document.getElementById("edit-subdomain").value.trim().toLowerCase(),y=document.getElementById("edit-service-name").value.trim(),c=document.getElementById("edit-port").value.trim(),r=document.getElementById("edit-ip").value.trim()||"localhost",m=document.getElementById("edit-tailscale-only").checked,d=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!s){showNotification("Subdomain is required","warning");return}const o=l.id,i=[];if(s!==o&&i.push("subdomain"),y&&y!==l.name&&i.push("name"),c&&c!==String(l.port)&&i.push("port"),r!==l.ip&&i.push("ip"),m!==(l.tailscaleOnly||!1)&&i.push("tailscale"),d&&d!==l.logo&&i.push("logo"),e!==(l.category||"")&&i.push("category"),i.length===0){g();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const h=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:o,newSubdomain:s,name:y||l.name,port:c||l.port,ip:r,tailscaleOnly:m,logo:d||void 0,category:e})})).json();if(!h.success)throw new Error(h.error||"Failed to update service");const p=window.APPS.findIndex(w=>w.id===o);p!==-1&&(window.APPS[p]={...window.APPS[p],id:s,name:y||window.APPS[p].name,port:c||window.APPS[p].port,ip:r,tailscaleOnly:m,logo:d||window.APPS[p].logo,category:e||void 0}),g(),window.buildGrid(),window.refreshAll()}catch(a){console.error("Error saving service changes:",a),showNotification(`Error saving changes: ${a.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async s=>{const y=s.target.files[0];if(!y)return;if(!y.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const c=new FileReader;c.onload=async r=>{const m=r.target.result;if(document.getElementById("edit-service-logo-preview").src=m,document.getElementById("edit-logo-url").value=m,l)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:m})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val Line truncated
`,p}async function g(u,v,s=DC.DEFAULTS.TTL){const y=window.getToken(getPrimaryDnsId(),"admin");if(!y)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const c=buildDomain(u),a=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:c,ip:v,ttl:s,token:y,server:SITE.dnsIp})});if(!a.ok){const i=await a.text();throw new Error(`DNS API Error: ${a.status} - ${i}`)}const m=await a.json();if(!m.success)throw new Error(`DNS Error: ${m.error||"Unknown error"}`);return m}async function b(u){const v={id:u.subdomain,name:u.name,logo:u.logo||`/assets/${u.subdomain}.png`};u.category&&(v.category=u.category),u.containerId&&(v.containerId=u.containerId);try{const s=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!s.ok){const y=await s.json();throw new Error(y.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(s){throw console.error("Failed to add service to config:",s),s}}async function f(u){const v=document.getElementById("service-subdomain-input").value.trim(),s=document.getElementById("service-ip-input").value.trim()||"localhost",y=document.getElementById("service-port-input").value.trim()||"80",c=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${s}:${y}`,config:u})}),a=await c.json();if(!c.ok||!a.success)throw new Error(a.error||`Caddy API Error: ${c.status}`);return a}window.loadExistingCAs=l,window.generateCaddyConfig=n,window.createDnsRecord=g,window.addServiceToConfig=b,window.addToCaddyfile=f})(),(function(){let l=null;function n(s){l=s;const y=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${s.name}`,document.getElementById("edit-service-name").value=s.name,document.getElementById("edit-service-url-display").textContent=s.url||buildServiceUrl(s.id),document.getElementById("edit-service-logo-preview").src=s.logo||`/assets/${s.id}.png`,document.getElementById("edit-subdomain").value=s.id,document.getElementById("edit-port").value=s.port||"",document.getElementById("edit-ip").value=s.ip||"localhost",document.getElementById("edit-tailscale-only").checked=s.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=s.logo||"";const c=document.getElementById("edit-service-category");c&&(c.dataset.current=s.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),y.classList.add("show")}function g(){closeModal("service-edit-modal"),l=null}async function b(){if(!l)return;const s=document.getElementById("edit-subdomain").value.trim().toLowerCase(),y=document.getElementById("edit-service-name").value.trim(),c=document.getElementById("edit-port").value.trim(),a=document.getElementById("edit-ip").value.trim()||"localhost",m=document.getElementById("edit-tailscale-only").checked,i=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!s){showNotification("Subdomain is required","warning");return}const o=l.id,d=[];if(s!==o&&d.push("subdomain"),y&&y!==l.name&&d.push("name"),c&&c!==String(l.port)&&d.push("port"),a!==l.ip&&d.push("ip"),m!==(l.tailscaleOnly||!1)&&d.push("tailscale"),i&&i!==l.logo&&d.push("logo"),e!==(l.category||"")&&d.push("category"),d.length===0){g();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const h=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:o,newSubdomain:s,name:y||l.name,port:c||l.port,ip:a,tailscaleOnly:m,logo:i||void 0,category:e})})).json();if(!h.success)throw new Error(h.error||"Failed to update service");const p=window.APPS.findIndex(w=>w.id===o);p!==-1&&(window.APPS[p]={...window.APPS[p],id:s,name:y||window.APPS[p].name,port:c||window.APPS[p].port,ip:a,tailscaleOnly:m,logo:i||window.APPS[p].logo,category:e||void 0}),g(),window.buildGrid(),window.refreshAll()}catch(r){console.error("Error saving service changes:",r),showNotification(`Error saving changes: ${r.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async s=>{const y=s.target.files[0];if(!y)return;if(!y.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const c=new FileReader;c.onload=async a=>{const m=a.target.result;if(document.getElementById("edit-service-logo-preview").src=m,document.getElementById("edit-logo-url").value=m,l)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:m})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val Line truncated
This will:
1. Pull the latest image
2. Stop the container
3. Recreate with same settings
The service will be briefly unavailable.`))try{r&&(r.textContent="\u{1F504}",r.disabled=!0,r.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${s}/update`,{method:"POST"})).json();if(e.success){const o=window.APPS.find(i=>i.id===c);o&&e.newContainerId&&(o.containerId=e.newContainerId),r&&(r.textContent="\u2705",r.title="Updated successfully!",setTimeout(()=>{r.textContent=m,r.disabled=!1,r.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${y} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(d){console.error("Update error:",d),r&&(r.textContent="\u274C",r.title="Update failed",setTimeout(()=>{r.textContent=m,r.disabled=!1,r.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${y}: ${d.message}`,"error")}}async function v(s,y){const c=window.APPS.find(p=>p.id===s),r=c?buildDomain(c.id):null,m=c?.containerId,d=await f(y||s,m,c?.containerId);if(d===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(d&&m)try{const p=new URLSearchParams({containerId:c.containerId,subdomain:c.id,ip:c.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(c.id)}?${p.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(p){console.error("App removal error:",p)}else if(d&&r){try{const p=c?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(r)}&type=A&ipAddress=${encodeURIComponent(p)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(p){e.dns=p.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(p){e.caddy=p.message}}const o=window.APPS.findIndex(p=>p.id===s);o>-1&&(window.APPS.splice(o,1),e.dashboard=!0);try{const p=safeGetJSON("custom-apps",[]),w=p.findIndex(T=>T.id===s);w>-1&&(p.splice(w,1),safeSet("custom-apps",JSON.stringify(p)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(s)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(p){e.service=p.message}window.buildGrid(),window.refreshAll();let i=!1,t=[];e.dashboard||(i=!0,t.push("\u2717 Failed to remove from dashboard"));const a=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],h=p=>!p||a.some(w=>p.toLowerCase().includes(w.toLowerCase()));e.container&&!h(e.container)&&(i=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!h(e.dns)&&(i=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!h(e.caddy)&&(i=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!h(e.service)&&(i=!0,t.push(`\u26A0 Service File: ${e.service}`)),i&&showNotification(`Error deleting "${y||s}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=n,window.showDeleteModal=f,window.updateContainer=u,window.deleteService=v})(),(function(){function l(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function n(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",o=document.getElementById("service-ip-input").value||b.lan||"localhost",i=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,a=document.getElementById("ca-name-input").value||"sami-ca",h=document.getElementById("existing-ca-select").value,p=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${o}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const C={subdomain:e,port:i,ip:o,sslType:t,caName:a,existingCa:h,enableAuth:p,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:E},x=window.generateCaddyConfig(C),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const b={localhost:"127.0.0.1",lan:"",tailscale:""};async function f(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const a=await t.json();a.lan&&(b.lan=a.lan),a.tailscale&&(b.tailscale=a.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),o=document.getElementById("quick-ip-tailscale");e&&(b.lan?(e.dataset.ip=b.lan,e.textContent=`LAN (${b.lan})`,e.title=`LAN IP: ${b.lan}`):e Line truncated
The service will be briefly unavailable.`))try{a&&(a.textContent="\u{1F504}",a.disabled=!0,a.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${s}/update`,{method:"POST"})).json();if(e.success){const o=window.APPS.find(d=>d.id===c);o&&e.newContainerId&&(o.containerId=e.newContainerId),a&&(a.textContent="\u2705",a.title="Updated successfully!",setTimeout(()=>{a.textContent=m,a.disabled=!1,a.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${y} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(i){console.error("Update error:",i),a&&(a.textContent="\u274C",a.title="Update failed",setTimeout(()=>{a.textContent=m,a.disabled=!1,a.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${y}: ${i.message}`,"error")}}async function v(s,y){const c=window.APPS.find(p=>p.id===s),a=c?buildDomain(c.id):null,m=c?.containerId,i=await f(y||s,m,c?.containerId);if(i===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(i&&m)try{const p=new URLSearchParams({containerId:c.containerId,subdomain:c.id,ip:c.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(c.id)}?${p.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(p){console.error("App removal error:",p)}else if(i&&a){try{const p=c?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(a)}&type=A&ipAddress=${encodeURIComponent(p)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(p){e.dns=p.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(p){e.caddy=p.message}}const o=window.APPS.findIndex(p=>p.id===s);o>-1&&(window.APPS.splice(o,1),e.dashboard=!0);try{const p=safeGetJSON("custom-apps",[]),w=p.findIndex(T=>T.id===s);w>-1&&(p.splice(w,1),safeSet("custom-apps",JSON.stringify(p)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(s)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(p){e.service=p.message}window.buildGrid(),window.refreshAll();let d=!1,t=[];e.dashboard||(d=!0,t.push("\u2717 Failed to remove from dashboard"));const r=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],h=p=>!p||r.some(w=>p.toLowerCase().includes(w.toLowerCase()));e.container&&!h(e.container)&&(d=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!h(e.dns)&&(d=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!h(e.caddy)&&(d=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!h(e.service)&&(d=!0,t.push(`\u26A0 Service File: ${e.service}`)),d&&showNotification(`Error deleting "${y||s}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=n,window.showDeleteModal=f,window.updateContainer=u,window.deleteService=v})(),(function(){function l(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function n(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",o=document.getElementById("service-ip-input").value||b.lan||"localhost",d=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,r=document.getElementById("ca-name-input").value||"sami-ca",h=document.getElementById("existing-ca-select").value,p=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${o}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const C={subdomain:e,port:d,ip:o,sslType:t,caName:r,existingCa:h,enableAuth:p,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:E},x=window.generateCaddyConfig(C),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const b={localhost:"127.0.0.1",lan:"",tailscale:""};async function f(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const r=await t.json();r.lan&&(b.lan=r.lan),r.tailscale&&(b.tailscale=r.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),o=document.getElementById("quick-ip-tailscale");e&&(b.lan?(e.dataset.ip=b.lan,e.textContent=`LAN (${b.lan})`,e.title=`LAN IP: ${b.lan}`):e Line truncated
<span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${w.self?.hostname} (${w.self?.ip})</span>
`,h.disabled=!1):w.installed?(a.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',h.disabled=!0):(a.innerHTML='<span style="color: var(--muted);">Not available</span>',h.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check</span>',h.disabled=!0}h.checked=!1,g()}function s(){const e=document.getElementById("service-type-local"),o=document.getElementById("service-type-external"),i=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),a=document.getElementById("tab-local"),h=document.getElementById("tab-external");function p(){e.checked?(i.style.display="grid",t.style.display="none",a&&(a.style.background="var(--accent)",a.style.color="var(--bg)"),h&&(h.style.background="transparent",h.style.color="var(--muted)")):(i.style.display="none",t.style.display="block",h&&(h.style.background="var(--accent)",h.style.color="var(--bg)"),a&&(a.style.background="transparent",a.style.color="var(--muted)"))}e?.addEventListener("change",p),o?.addEventListener("change",p)}function y(){const e=document.getElementById("service-name-input"),o=document.getElementById("service-subdomain-input"),i=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=l(e.value);!t&&o&&(o.value=L),i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),o?.addEventListener("input",()=>{t=o.value!==l(e?.value||"");const L=o.value.trim()||l(e?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const a=document.getElementById("external-service-name"),h=document.getElementById("external-service-subdomain"),p=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;a?.addEventListener("input",()=>{const L=l(a.value);!T&&h&&(h.value=L);const P=h?.value||L;p&&(p.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),h?.addEventListener("input",()=>{T=h.value!==l(a?.value||"");const L=h.value.trim()||l(a?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function c(){const e=document.getElementById("external-service-name").value.trim(),o=document.getElementById("external-service-url").value.trim(),i=(document.getElementById("external-service-subdomain").value.trim()||l(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),a=document.getElementById("external-service-icon").value.trim(),h=document.getElementById("external-create-dns").checked,p=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!o){showNotification("Please fill in Name and External URL","warning");return}if(!i){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!o.startsWith("http://")&&!o.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const E=buildDomain(i);try{const B={dns:null,caddy:null,dashboard:!1};if(h)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:E,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(p)try{const k={subdomain:i,externalUrl:o,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:i,name:e,url:`https://${E}`,externalUrl:o,logo:t||a||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const C=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(k=>!C.includes(k.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),r();const I=[`External service "${e}" added!`];h&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),p&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${E}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated
`,h.disabled=!1):w.installed?(r.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',h.disabled=!0):(r.innerHTML='<span style="color: var(--muted);">Not available</span>',h.disabled=!0)}catch{r.innerHTML='<span style="color: var(--muted);">Could not check</span>',h.disabled=!0}h.checked=!1,g()}function s(){const e=document.getElementById("service-type-local"),o=document.getElementById("service-type-external"),d=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),r=document.getElementById("tab-local"),h=document.getElementById("tab-external");function p(){e.checked?(d.style.display="grid",t.style.display="none",r&&(r.style.background="var(--accent)",r.style.color="var(--bg)"),h&&(h.style.background="transparent",h.style.color="var(--muted)")):(d.style.display="none",t.style.display="block",h&&(h.style.background="var(--accent)",h.style.color="var(--bg)"),r&&(r.style.background="transparent",r.style.color="var(--muted)"))}e?.addEventListener("change",p),o?.addEventListener("change",p)}function y(){const e=document.getElementById("service-name-input"),o=document.getElementById("service-subdomain-input"),d=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=l(e.value);!t&&o&&(o.value=L),d&&(d.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),o?.addEventListener("input",()=>{t=o.value!==l(e?.value||"");const L=o.value.trim()||l(e?.value||"");d&&(d.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const r=document.getElementById("external-service-name"),h=document.getElementById("external-service-subdomain"),p=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;r?.addEventListener("input",()=>{const L=l(r.value);!T&&h&&(h.value=L);const P=h?.value||L;p&&(p.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),h?.addEventListener("input",()=>{T=h.value!==l(r?.value||"");const L=h.value.trim()||l(r?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function c(){const e=document.getElementById("external-service-name").value.trim(),o=document.getElementById("external-service-url").value.trim(),d=(document.getElementById("external-service-subdomain").value.trim()||l(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),r=document.getElementById("external-service-icon").value.trim(),h=document.getElementById("external-create-dns").checked,p=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!o){showNotification("Please fill in Name and External URL","warning");return}if(!d){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!o.startsWith("http://")&&!o.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const E=buildDomain(d);try{const B={dns:null,caddy:null,dashboard:!1};if(h)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:E,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(p)try{const k={subdomain:d,externalUrl:o,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:d,name:e,url:`https://${E}`,externalUrl:o,logo:t||r||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const C=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(k=>!C.includes(k.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),a();const I=[`External service "${e}" added!`];h&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),p&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${E}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated
+13 -1
View File
@@ -91,7 +91,19 @@
const redirect = safeSessionGet('totp_redirect');
if (redirect) {
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
window.location.href = redirect;
// .sami is an unregistered TLD, so browsers silently drop the
// Domain=.sami session cookie on any OTHER *.sami subdomain (they
// treat "sami" as the effective public suffix, same protection
// that blocks a Domain=.com supercookie). The target service can't
// see our session cookie no matter how it's built, so instead we
// hand it a one-time token in the URL; its login page exchanges
// that for its own host-only cookie via /auth/sso-exchange.
let target = redirect;
if (data.ssoToken) {
const sep = redirect.includes('?') ? '&' : '?';
target = redirect + sep + 'dc_token=' + encodeURIComponent(data.ssoToken);
}
window.location.href = target;
return;
}
// Initialize dashboard
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-1b7c08184e';
const CACHE = 'dashcaddy-shell-4706f2a8fa';
const PRECACHE = [
'/',
'/index.html',