Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2285a2550 | ||
|
|
54a1df5ac4 | ||
|
|
eb546bf468 | ||
|
|
84e051d975 | ||
|
|
628bbe32f6 | ||
|
|
f8b99f9b5a | ||
|
|
eab2b00b13 | ||
|
|
84edb035e3 | ||
|
|
d313b1e872 | ||
|
|
be021588c7 | ||
|
|
d8459a4a87 | ||
|
|
499fcc2742 | ||
|
|
93d6c44e45 | ||
|
|
4c4ffc35ca |
No files matched your search
+25
@@ -400,3 +400,28 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
|
||||
- **prerequisite:** None.
|
||||
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
|
||||
|
||||
|
||||
### DC-086: Service-status flicker fix — asymmetric hysteresis on the badge
|
||||
- **status:** in-progress
|
||||
- **owner:** hermes
|
||||
- **details:** Dashboard service badges perpetually flip between green and red for "a few seconds at a time, never stable" (Sami's report, 2026-08-20). Root cause: `src/monitoring/health-checker.js` `recordStatus()` emits `'status-check'` on EVERY probe (every 30s), and `src/websocket/dashboard-ws.js` forwards every probe as `'status-change'` to the browser with no diff. The frontend `live-events.js` then unconditionally calls `setBadge()` — which resets the icon + pill text on every event. A single transient 5xx (Caddy reload, container CPU steal, mid-flight TLS handshake, container restart during probe) flips the badge red and the next green probe flips it back. Fix: add asymmetric hysteresis in `_computeDisplayedStatus(serviceId, rawStatus)` — going DOWN requires 2 consecutive "down" probes (default `HEALTH_DOWN_THRESHOLD=2`), going UP requires only 1 (default `HEALTH_UP_THRESHOLD=1`). History + `consecutiveFailures` still record raw probe results (operators want full fidelity for postmortems); only the dashboard broadcast is filtered. `getCurrentStatus()` now returns the displayed status so a page reload shows the same badge as the live SSE stream. Both thresholds are env-var configurable so operators can tune. New tests in `__tests__/health-checker-hysteresis.test.js` cover: first probe emits; second probe same-status does NOT re-emit; one-down-then-up keeps green; two-down flips to red; one-up after down flips back to green; `getCurrentStatus` returns displayed not raw. Effort: ~30 min. Risk: low — pure behavior filter, no schema breaks, all 63 existing health-checker tests must stay green.
|
||||
- **impact:** Operators stop seeing perpetual red/green flicker on healthy services. Real outages still get flagged (2 consecutive 30s probes = ~60s before badge flips red, which is still faster than a human notices). Background probe history is unchanged so postmortem analysis still works.
|
||||
- **prerequisite:** None.
|
||||
- **result:** _pending — ship + codex round_
|
||||
|
||||
### DC-085: Link-first invite — Discord-style "share it however you want"
|
||||
- **status:** in-progress
|
||||
- **owner:** hermes
|
||||
- **details:** Today `POST /api/v1/auth/admin/invites` defaults to sending the invite link via SMTP; if SMTP is not configured it spams the server console with `[DC-048-DEV-INVITE-LINK]` log lines. Sami wants Discord-style: the link is always returned in the response, and email is an opt-in checkbox. Operators should be free to copy the link and share it via iMessage / SMS / WhatsApp / Telegram / Signal / Discord / paste-in-email — whatever fits. (1) Flip default `sendEmail !== false` to `sendEmail === true` in `routes/auth/admin.js` so omitting the field means "no email, just hand me the link." (2) Stop logging the raw invite URL to error.log when SMTP is unconfigured — that path was only useful when there was no UI way to grab the link; now there is. (3) Add a `shareText` field to the response: `"Join my DashCaddy as <role> — <acceptUrl> — expires in Nh."` for one-tap paste into any messenger. (4) Frontend: `status/js/admin.js` `_renderInviteForm` flips the "Send email" checkbox default to **unchecked**, updates `_renderIssuedInviteBanner` to show both the raw link AND the shareText (with its own copy button + `navigator.share()` native share-sheet button where available). (5) New tests in `__tests__/admin-invites.test.js` covering: default sendEmail=false (no SMTP send attempted, no console log); `sendEmail: true` triggers SMTP send; `shareText` is present and well-formed; `acceptUrl` is always returned; expired sendEmail path doesn't leak token to logs. Effort: ~1 hr. Risk: low — pure behavior flip + UI additive change.
|
||||
- **impact:** Closes the friction between "host wants to add a friend" and "host has to configure SMTP first." Mirrors Discord/Slack/Linear invite flows where the link IS the deliverable. No new tier changes, no schema breaks.
|
||||
- **prerequisite:** DC-048 (invite store + admin route), DC-052 (Pro gate stays).
|
||||
- **result:** _pending — ship + codex round_
|
||||
|
||||
### DC-084: Remove redundant active Caddy health check from `arch.sami` site — eliminate 6 syslog spam lines/min
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `/etc/caddy/sites/arch.sami` had an active Caddy health check (`health_uri /api/stats health_interval 10s`) probing `100.120.159.34:5000` every 10 seconds. The upstream Arch Linux server `100.120.159.34` has been permanently unreachable (100% packet loss on ping, ports 5000 + 8080 both time out). Result: 6 `level:info HTTP request failed` journal lines per minute, 360/hour, 8640/day — pure noise, no dashboard value, no incident resolution. The `src/monitoring/caddy-upstream-watcher.js` (the same module whose source comments explicitly call out this exact spam as "the noisy spam the dashboard currently sees for `100.120.159.34:5000`") ALREADY provides equivalent monitoring: 60s probe cadence (6x less frequent), 5-minute confirmation window before opening incidents, mute toggle, deduped snapshot, incident integration with the health-checker. The active Caddy check is redundant. Fix: edit `/etc/caddy/sites/arch.sami` to remove the `health_uri / health_interval` block, leaving only `reverse_proxy 100.120.159.34:5000`. Apply via `caddy-apply` (validates+reloads+commits atomically). Backup `.bak-DC-084-pre` created pre-edit; deleted after `caddy-apply` succeeded because the `.bak` file was being picked up by Caddy's `import sites/*` and causing an "ambiguous site definition" validation error.
|
||||
- **impact:** Eliminates 100% of recurring caddy journal spam from the dead Arch upstream. The dashboard's `caddy-upstream-watcher.js` continues to monitor the dead upstream correctly (now at `consecutiveFailures: 1905+`, `lastSuccessAt: null`, `status: down`, `dead: true`) — operators see the dead upstream in the dashboard, just without the journal noise. Future Caddyfile authors who add an active health check to a `*.sami` site will be unaware that they should not (since the dashboard handles monitoring), so a follow-up could add a CLAUDE.md note or a Caddyfile lint warning. Out of scope for this tick.
|
||||
- **prerequisite:** None. `caddy-upstream-watcher.js` already provides equivalent monitoring.
|
||||
- **result:** Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (`journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l`): **before = ~30 entries / 5min** (active probe every 10s, all failing); **after = 0 entries / 5min**. Live-verified: `caddy validate` succeeded (after removing `.bak` file that caused `ambiguous site definition`), Caddy reloaded via `caddy-apply`, route `arch.sami → 100.120.159.34:5000` still active in admin API (verified via `curl http://localhost:2019/config/apps/http/servers/srv0/routes` — `health_uri: None, health_interval: None` confirms the block is gone). Container `dashcaddy-api Up About an hour (healthy)` (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: `https://status.sami=200`, `https://dashcaddy.net=200`, `https://ca.sami=200`, `https://status.sami/api/health=401` (auth-gated, expected). Watcher state for `100.120.159.34:5000`: `consecutiveFailures: 1905`, `lastError: "probe timeout"`, `status: down`, `dead: true` — correctly tracked in `/opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json`. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's `/etc/caddy/sites/arch.sami` (the `/etc/caddy` git repo `.gitignore` excludes `sites/` per design — only the main `Caddyfile` is tracked). The dashcaddy source repo (`/root/dashcaddy`) carries only this BACKLOG.md documentation update on branch `dc/DC-084-arch-sami-caddy-healthcheck-removal`.
|
||||
- **Tests:** No source code change; existing `__tests__/caddy-upstream-watcher.test.js` 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with `Cannot find module pdfkit` — unrelated to this change).
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Tests for DC-085: link-first invite (Discord-style "share it however you want").
|
||||
*
|
||||
* - default sendEmail omission = no email sent, link returned, no token in logs
|
||||
* - sendEmail:true triggers SMTP send when configured
|
||||
* - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked
|
||||
* - shareText field present and well-formed in every response
|
||||
* - acceptUrl always present (regardless of sendEmail)
|
||||
* - role + ttl validation unchanged from DC-048
|
||||
*
|
||||
* Strategy: drive the route handler directly with mock req/res, mount the admin
|
||||
* router against an isolated userStore + inviteStore + email-sender stub.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
// Stub email-sender so we can assert "was it called?" without an SMTP server.
|
||||
// NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()`
|
||||
// call is allowed to reference it (Babel guard against out-of-scope access).
|
||||
const mockEmailSender = {
|
||||
isConfigured: jest.fn(() => false),
|
||||
sendEmail: jest.fn(async () => undefined),
|
||||
};
|
||||
jest.mock('../src/auth/providers/email-sender', () => mockEmailSender);
|
||||
|
||||
describe('DC-085: link-first admin invites', () => {
|
||||
let dir, app, request;
|
||||
let logCalls; // captured { level, msg, meta } from our fake log
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
dir = _tmpDir();
|
||||
logCalls = [];
|
||||
|
||||
// Set up email auth enable flag so userStore mounts.
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { createUserStore } = require('../src/security/user-store');
|
||||
const userStore = createUserStore({ dataDir: dir });
|
||||
|
||||
// Bootstrap the admin so we have a session-attributable user.
|
||||
await userStore.login({ email: 'admin@sami-host.me' });
|
||||
|
||||
// Build a tiny Express app with the admin router mounted, but skip the
|
||||
// global auth gate (we inject req.user directly).
|
||||
const adminRouter = require('../routes/auth/admin')({
|
||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||
log: {
|
||||
info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }),
|
||||
warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }),
|
||||
error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }),
|
||||
},
|
||||
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||
dataDir: dir,
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
// Inject req.user = admin so /admin/* passes the role gate.
|
||||
app.use((req, _res, next) => {
|
||||
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||
req.app.locals = req.app.locals || {};
|
||||
req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers
|
||||
req.app.locals.emailConfig = null; // SMTP not configured by default
|
||||
next();
|
||||
});
|
||||
app.use('/api/v1/auth', adminRouter);
|
||||
// Error handler — last in chain.
|
||||
app.use((err, _req, res, _next) => {
|
||||
const code = (err && err.statusCode) || 500;
|
||||
res.status(code).json({
|
||||
success: false,
|
||||
error: err && err.message,
|
||||
code: err && err.code,
|
||||
});
|
||||
});
|
||||
|
||||
request = require('supertest');
|
||||
});
|
||||
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('default sendEmail (omitted) returns link and does NOT send email', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'operator' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||
expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/);
|
||||
expect(res.body.deliveredVia).toBe('manual');
|
||||
});
|
||||
|
||||
test('default sendEmail does NOT log raw token to server log', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'operator' });
|
||||
|
||||
const acceptUrl = res.body.acceptUrl;
|
||||
// Extract the token from the URL and verify it does NOT appear in any log call.
|
||||
const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||
const tokenLeaked = logCalls.some(c =>
|
||||
typeof c.msg === 'string' && c.msg.includes(token)
|
||||
);
|
||||
expect(tokenLeaked).toBe(false);
|
||||
|
||||
// Also assert no log entry mentions the URL verbatim (the old
|
||||
// `[DC-048-DEV-INVITE-LINK] url=...` spam).
|
||||
const oldSpam = logCalls.find(c =>
|
||||
typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]')
|
||||
);
|
||||
expect(oldSpam).toBeUndefined();
|
||||
});
|
||||
|
||||
test('shareText is present and well-formed in every response', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 });
|
||||
|
||||
expect(res.body.shareText).toBeDefined();
|
||||
expect(res.body.shareText).toContain('Join my DashCaddy');
|
||||
expect(res.body.shareText).toContain('operator');
|
||||
expect(res.body.shareText).toContain(res.body.acceptUrl);
|
||||
expect(res.body.shareText).toContain('expires in 24h');
|
||||
});
|
||||
|
||||
test('acceptUrl is always returned regardless of sendEmail', async () => {
|
||||
const r1 = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'a@x.com', sendEmail: false });
|
||||
const r2 = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'b@x.com' });
|
||||
expect(r1.body.acceptUrl).toBeTruthy();
|
||||
expect(r2.body.acceptUrl).toBeTruthy();
|
||||
});
|
||||
|
||||
test('sendEmail: true triggers SMTP send when configured', async () => {
|
||||
// Build a SECOND app instance where emailConfig is a real-looking object,
|
||||
// so isConfigured() returns true. The first app uses emailConfig=null.
|
||||
mockEmailSender.isConfigured.mockReturnValueOnce(true);
|
||||
mockEmailSender.sendEmail.mockResolvedValueOnce(undefined);
|
||||
const app2 = express();
|
||||
app2.use(express.json());
|
||||
app2.use((req, _res, next) => {
|
||||
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||
req.app.locals = req.app.locals || {};
|
||||
req.app.locals.siteConfig = {};
|
||||
req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' };
|
||||
next();
|
||||
});
|
||||
const { createUserStore } = require('../src/security/user-store');
|
||||
const userStore2 = createUserStore({ dataDir: dir });
|
||||
await userStore2.login({ email: 'admin@sami-host.me' });
|
||||
const router2 = require('../routes/auth/admin')({
|
||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||
log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} },
|
||||
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||
dataDir: dir,
|
||||
});
|
||||
app2.use('/api/v1/auth', router2);
|
||||
|
||||
const res = await request(app2)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'viewer', sendEmail: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1);
|
||||
const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0];
|
||||
expect(to).toBe('friend@example.com');
|
||||
expect(subject).toMatch(/invited/i);
|
||||
expect(text).toContain(res.body.acceptUrl);
|
||||
expect(html).toContain(res.body.acceptUrl);
|
||||
expect(res.body.deliveredVia).toBe('email');
|
||||
});
|
||||
|
||||
test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => {
|
||||
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||
expect(res.body.deliveredVia).toBe('failed');
|
||||
// acceptUrl + shareText still present so the operator can share manually.
|
||||
expect(res.body.acceptUrl).toBeTruthy();
|
||||
expect(res.body.shareText).toBeTruthy();
|
||||
// Token does NOT appear in any log call.
|
||||
const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||
const tokenLeaked = logCalls.some(c =>
|
||||
typeof c.msg === 'string' && c.msg.includes(token)
|
||||
);
|
||||
expect(tokenLeaked).toBe(false);
|
||||
});
|
||||
|
||||
test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => {
|
||||
// DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'`
|
||||
// silently substitutes default rather than throwing. This test pins that
|
||||
// behavior so a future "strict role validation" change is a deliberate
|
||||
// decision, not a silent regression.
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'a@x.com', role: 'superuser' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.role).toBe('operator');
|
||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('email validation: missing email still rejected', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ role: 'operator' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ttlHours: 1 still produces shareText with correct expiry wording', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/admin/invites')
|
||||
.send({ email: 'a@x.com', ttlHours: 1 });
|
||||
expect(res.body.shareText).toContain('expires in 1h');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Tests for DC-086: asymmetric hysteresis on the dashboard service badge.
|
||||
*
|
||||
* - First probe always emits (no prior state).
|
||||
* - Same-status probe does NOT re-emit (dedup against repeated green).
|
||||
* - One "down" then back to "up" keeps the badge green (no flicker).
|
||||
* - Two consecutive "down" probes flip the badge to red.
|
||||
* - One "up" after a down streak flips back to green (fast recovery).
|
||||
* - History retains every raw probe even when no emit happens.
|
||||
* - getCurrentStatus returns displayed status, not raw.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// Use an isolated data dir so test history doesn't pollute the real one.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-hyst-'));
|
||||
process.env.HEALTH_DATA_DIR = tmpDir;
|
||||
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
|
||||
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
|
||||
|
||||
// Module exports a singleton instance, not a class — see module.exports in
|
||||
// src/monitoring/health-checker.js. The test creates fresh state by replacing
|
||||
// the relevant maps on the singleton in beforeEach.
|
||||
const healthCheckerSingleton = require('../src/monitoring/health-checker');
|
||||
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
|
||||
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
|
||||
|
||||
function restoreEnv(name, value) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
|
||||
function makeUp(serviceId = 'svc1') {
|
||||
return {
|
||||
serviceId,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'up',
|
||||
responseTime: 50,
|
||||
statusCode: 200,
|
||||
message: 'Service is healthy',
|
||||
details: { headers: {}, bodyLength: 12 }
|
||||
};
|
||||
}
|
||||
|
||||
function makeDown(serviceId = 'svc1') {
|
||||
return {
|
||||
serviceId,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'down',
|
||||
responseTime: 50,
|
||||
statusCode: 500,
|
||||
message: 'fail',
|
||||
details: { headers: {}, bodyLength: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
describe('DC-086: hysteresis on the dashboard badge', () => {
|
||||
let hc;
|
||||
let emitSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the singleton's per-test state so each case starts clean.
|
||||
healthCheckerSingleton.displayedStatus = new Map();
|
||||
healthCheckerSingleton.consecutiveSinceChange = new Map();
|
||||
healthCheckerSingleton.currentStatus = new Map();
|
||||
healthCheckerSingleton.history = {};
|
||||
healthCheckerSingleton.removeAllListeners('status-check');
|
||||
emitSpy = jest.fn();
|
||||
healthCheckerSingleton.on('status-check', emitSpy);
|
||||
hc = healthCheckerSingleton;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
|
||||
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('first probe (no prior state) emits', () => {
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(emitSpy.mock.calls[0][0].status).toBe('up');
|
||||
});
|
||||
|
||||
test('second probe with same status does NOT re-emit', () => {
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('one "down" then "up" keeps the badge green (the flicker bug)', () => {
|
||||
hc.recordStatus('svc1', makeUp()); // baseline: green, emit 1
|
||||
hc.recordStatus('svc1', makeDown()); // one blip — keep green, no emit
|
||||
hc.recordStatus('svc1', makeUp()); // recovered — still green, no emit
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||
});
|
||||
|
||||
test('up, down, up, down, down resets the first streak before flipping', () => {
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
hc.recordStatus('svc1', makeDown());
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
hc.recordStatus('svc1', makeDown());
|
||||
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
hc.recordStatus('svc1', makeDown());
|
||||
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('two consecutive "down" probes flip the badge to red', () => {
|
||||
hc.recordStatus('svc1', makeUp()); // baseline: green
|
||||
hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1)
|
||||
hc.recordStatus('svc1', makeDown()); // blip #2 — flip red (counter=2 >= DOWN_THRESHOLD)
|
||||
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||
expect(emitSpy.mock.calls[1][0].status).toBe('down');
|
||||
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||
});
|
||||
|
||||
test('one "up" after a down streak flips back to green (fast recovery)', () => {
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
hc.recordStatus('svc1', makeDown());
|
||||
hc.recordStatus('svc1', makeDown()); // now red
|
||||
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||
|
||||
hc.recordStatus('svc1', makeUp()); // first green — flip back
|
||||
expect(emitSpy).toHaveBeenCalledTimes(3);
|
||||
expect(emitSpy.mock.calls[2][0].status).toBe('up');
|
||||
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||
});
|
||||
|
||||
test('history retains every raw probe even when no emit happens', () => {
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
hc.recordStatus('svc1', makeDown()); // blip, no emit
|
||||
hc.recordStatus('svc1', makeUp()); // recovery, no emit
|
||||
expect(hc.history['svc1'].length).toBe(3);
|
||||
expect(hc.history['svc1'][0].status).toBe('up');
|
||||
expect(hc.history['svc1'][1].status).toBe('down');
|
||||
expect(hc.history['svc1'][2].status).toBe('up');
|
||||
});
|
||||
|
||||
test('getCurrentStatus returns the displayed status, not the raw probe', () => {
|
||||
const displayedUp = makeUp();
|
||||
displayedUp.timestamp = '2026-08-22T09:59:00.000Z';
|
||||
displayedUp.statusCode = 200;
|
||||
displayedUp.message = 'healthy';
|
||||
displayedUp.details = { source: 'accepted-up' };
|
||||
hc.recordStatus('svc1', displayedUp);
|
||||
const latestRaw = makeDown();
|
||||
latestRaw.timestamp = '2026-08-22T10:00:00.000Z';
|
||||
latestRaw.responseTime = 987;
|
||||
latestRaw.statusCode = 500;
|
||||
latestRaw.message = 'failed probe';
|
||||
latestRaw.error = 'upstream failure';
|
||||
latestRaw.details = { source: 'suppressed-down' };
|
||||
hc.recordStatus('svc1', latestRaw); // raw=down, displayed=up
|
||||
const out = hc.getCurrentStatus();
|
||||
expect(out['svc1'].status).toBe('up'); // shown to API consumers
|
||||
expect(out['svc1'].timestamp).toBe(displayedUp.timestamp);
|
||||
expect(out['svc1'].statusCode).toBe(200);
|
||||
expect(out['svc1'].message).toBe('healthy');
|
||||
expect(out['svc1'].error).toBeUndefined();
|
||||
expect(out['svc1'].details).toEqual({ source: 'accepted-up' });
|
||||
expect(hc.currentStatus.get('svc1')).toBe(latestRaw);
|
||||
});
|
||||
|
||||
test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => {
|
||||
for (let i = 0; i < 50; i++) hc.recordStatus('svc1', makeUp());
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a long steady-green-then-steady-red transition: 1 emit (up), 1 emit (red)', () => {
|
||||
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeUp());
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
hc.recordStatus('svc1', makeDown());
|
||||
hc.recordStatus('svc1', makeDown()); // flips to red
|
||||
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeDown());
|
||||
expect(emitSpy).toHaveBeenCalledTimes(2); // no further broadcasts
|
||||
});
|
||||
|
||||
test('DOWN_THRESHOLD env var is honored', () => {
|
||||
process.env.HEALTH_DOWN_THRESHOLD = '3';
|
||||
jest.resetModules();
|
||||
const HC2Module = require('../src/monitoring/health-checker');
|
||||
// Module is a singleton with DOWN_THRESHOLD captured at module load —
|
||||
// resetModules gives us a fresh module-level instance with the new env.
|
||||
const hc2 = HC2Module;
|
||||
hc2.displayedStatus = new Map();
|
||||
hc2.consecutiveSinceChange = new Map();
|
||||
hc2.currentStatus = new Map();
|
||||
hc2.history = {};
|
||||
hc2.removeAllListeners('status-check');
|
||||
const spy = jest.fn();
|
||||
hc2.on('status-check', spy);
|
||||
hc2.recordStatus('svc1', makeUp());
|
||||
hc2.recordStatus('svc1', makeDown()); // 1
|
||||
hc2.recordStatus('svc1', makeDown()); // 2 — still green (need 3)
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||
hc2.recordStatus('svc1', makeDown()); // 3 — flip
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||
});
|
||||
|
||||
test.each(['not-a-number', '0', '-2', '1.5'])('malformed DOWN_THRESHOLD %s falls back to 2', value => {
|
||||
process.env.HEALTH_DOWN_THRESHOLD = value;
|
||||
jest.resetModules();
|
||||
const hc2 = require('../src/monitoring/health-checker');
|
||||
hc2.displayedStatus = new Map();
|
||||
hc2.consecutiveSinceChange = new Map();
|
||||
hc2.currentStatus = new Map();
|
||||
hc2.history = {};
|
||||
hc2.removeAllListeners('status-check');
|
||||
const spy = jest.fn();
|
||||
hc2.on('status-check', spy);
|
||||
hc2.recordStatus('svc1', makeUp());
|
||||
hc2.recordStatus('svc1', makeDown());
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
hc2.recordStatus('svc1', makeDown());
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('UP_THRESHOLD env var greater than 1 is honored', () => {
|
||||
process.env.HEALTH_UP_THRESHOLD = '2';
|
||||
jest.resetModules();
|
||||
const hc2 = require('../src/monitoring/health-checker');
|
||||
hc2.displayedStatus = new Map();
|
||||
hc2.consecutiveSinceChange = new Map();
|
||||
hc2.currentStatus = new Map();
|
||||
hc2.history = {};
|
||||
hc2.removeAllListeners('status-check');
|
||||
const spy = jest.fn();
|
||||
hc2.on('status-check', spy);
|
||||
hc2.recordStatus('svc1', makeDown());
|
||||
hc2.recordStatus('svc1', makeUp());
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||
hc2.recordStatus('svc1', makeUp());
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||
});
|
||||
|
||||
test.each(['not-a-number', '0', '-2', '1.5'])('malformed UP_THRESHOLD %s falls back to 1', value => {
|
||||
process.env.HEALTH_UP_THRESHOLD = value;
|
||||
jest.resetModules();
|
||||
const hc2 = require('../src/monitoring/health-checker');
|
||||
hc2.displayedStatus = new Map();
|
||||
hc2.consecutiveSinceChange = new Map();
|
||||
hc2.currentStatus = new Map();
|
||||
hc2.history = {};
|
||||
hc2.removeAllListeners('status-check');
|
||||
const spy = jest.fn();
|
||||
hc2.on('status-check', spy);
|
||||
hc2.recordStatus('svc1', makeDown());
|
||||
hc2.recordStatus('svc1', makeUp());
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||
});
|
||||
|
||||
test('removeService clears hysteresis state before the same ID is re-added', () => {
|
||||
hc.config.services.svc1 = { name: 'Service 1' };
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
hc.recordStatus('svc1', makeDown());
|
||||
expect(hc.displayedStatus.has('svc1')).toBe(true);
|
||||
expect(hc.consecutiveSinceChange.get('svc1')).toBe(1);
|
||||
hc.consecutiveFailures.set('svc1', 3);
|
||||
const timer = setTimeout(() => {}, 60_000);
|
||||
hc.serviceTimers.set('svc1', timer);
|
||||
|
||||
hc.saveConfig = jest.fn();
|
||||
hc.removeService('svc1');
|
||||
|
||||
expect(hc.displayedStatus.has('svc1')).toBe(false);
|
||||
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
|
||||
expect(hc.currentStatus.has('svc1')).toBe(false);
|
||||
expect(hc.consecutiveFailures.has('svc1')).toBe(false);
|
||||
expect(hc.serviceTimers.has('svc1')).toBe(false);
|
||||
|
||||
hc.config.services.svc1 = { name: 'Service 1 re-added' };
|
||||
const emitSpyAfterReAdd = jest.fn();
|
||||
hc.on('status-check', emitSpyAfterReAdd);
|
||||
hc.recordStatus('svc1', makeDown());
|
||||
|
||||
expect(emitSpyAfterReAdd).toHaveBeenCalledTimes(1);
|
||||
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -203,6 +203,48 @@ describe('HealthChecker', () => {
|
||||
expect(result.error).toBe('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('opens and resolves an outage incident across real checkService transitions', async () => {
|
||||
healthChecker._doRequest = jest.fn()
|
||||
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} })
|
||||
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
||||
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} });
|
||||
|
||||
const config = { url: 'http://test.local' };
|
||||
await healthChecker.checkService('svc1', config);
|
||||
await healthChecker.checkService('svc1', config);
|
||||
|
||||
expect(healthChecker.incidents).toHaveLength(1);
|
||||
expect(healthChecker.incidents[0]).toMatchObject({
|
||||
serviceId: 'svc1',
|
||||
type: 'outage',
|
||||
status: 'open'
|
||||
});
|
||||
|
||||
await healthChecker.checkService('svc1', config);
|
||||
expect(healthChecker.incidents[0].status).toBe('resolved');
|
||||
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not resurrect state when an in-flight probe resolves after removal', async () => {
|
||||
let resolveProbe;
|
||||
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
|
||||
resolveProbe = resolve;
|
||||
}));
|
||||
|
||||
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||
healthChecker.saveConfig = jest.fn();
|
||||
healthChecker.removeService('svc1');
|
||||
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
|
||||
await pending;
|
||||
|
||||
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||
expect(healthChecker.displayedStatus.has('svc1')).toBe(false);
|
||||
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||
expect(healthChecker.history.svc1).toBeUndefined();
|
||||
expect(healthChecker.incidents).toEqual([]);
|
||||
});
|
||||
|
||||
it('increments consecutive failures on error', async () => {
|
||||
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
||||
|
||||
|
||||
@@ -26,6 +26,19 @@ jest.mock('dockerode', () => {
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||
|
||||
// DC-087 — mirror src/app.js faithfully: the caddy check goes through
|
||||
// fetchT (which injects the Origin header Caddy's enforce_origin allowlist
|
||||
// requires), and is MOCKED so the suite is hermetic — no live request to a
|
||||
// real Caddy admin on :2019. The previous raw-`fetch` mirror sent an
|
||||
// Origin-less probe to the LIVE admin whenever the full suite ran on the
|
||||
// prod host (adversarial cron every 30 min): 12 journal 403 lines per run,
|
||||
// ~700/day of `client is not allowed to access from origin ''` noise,
|
||||
// plus a false checks.caddy.ok=false in the mirrored readiness payload.
|
||||
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
||||
.mockImplementation(async () => (caddyOk
|
||||
? { ok: true, status: 200 }
|
||||
: { ok: false, status: 403 }));
|
||||
|
||||
const app = express();
|
||||
const config = {
|
||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||
@@ -103,9 +116,13 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// DC-087 — mirror src/app.js exactly (fetchT, not raw fetch). fetchT is
|
||||
// mocked at buildApp() scope, so this stays hermetic: no live probe to a
|
||||
// real Caddy admin (the old raw-fetch mirror 403-spammed the prod journal
|
||||
// every time the adversarial cron ran the full suite on this host).
|
||||
try {
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
||||
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
|
||||
@@ -33,9 +33,18 @@ jest.mock('dockerode', () => {
|
||||
|
||||
// Mirror the canonical handler block from src/app.js — if this drifts from
|
||||
// the real handler, these tests will start failing and force a sync.
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) {
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||
|
||||
// DC-087 — mirror src/app.js: caddy check via fetchT (Origin-injecting),
|
||||
// mocked here so the suite is hermetic. The old raw-fetch mirror probed the
|
||||
// LIVE Caddy admin on :2019 whenever the full suite ran on the prod host
|
||||
// (adversarial cron): Origin-less → 403 → 12 journal error lines per run.
|
||||
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
||||
.mockImplementation(async () => (caddyOk
|
||||
? { ok: true, status: 200 }
|
||||
: { ok: false, status: 403 }));
|
||||
|
||||
const app = express();
|
||||
const config = {
|
||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||
@@ -108,8 +117,10 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {})
|
||||
allOk = false;
|
||||
}
|
||||
try {
|
||||
// DC-087 — mirror src/app.js exactly: fetchT (mocked above), not raw
|
||||
// fetch. Hermetic: no live request to a real Caddy admin.
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
||||
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
|
||||
@@ -112,6 +112,7 @@ function createApp(depsOverride = {}) {
|
||||
errorResponse: jest.fn(),
|
||||
log,
|
||||
renewCSRFToken,
|
||||
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
|
||||
...depsOverride,
|
||||
};
|
||||
|
||||
@@ -299,7 +300,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
||||
const secret = await setupTOTP();
|
||||
const token = authenticator.generate(secret);
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||
@@ -308,8 +309,29 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
expect(deps.session.create).toHaveBeenCalled();
|
||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not issue an unbound handoff token for a dashboard-only login', async () => {
|
||||
const secret = await setupTOTP();
|
||||
const token = authenticator.generate(secret);
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ssoToken).toBeNull();
|
||||
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid handoff service ID before issuing a token', async () => {
|
||||
const secret = await setupTOTP();
|
||||
const token = authenticator.generate(secret);
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid service ID/);
|
||||
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
@@ -450,7 +472,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
|
||||
// 4. Re-login via /totp/verify (the "login" path)
|
||||
const loginCode = authenticator.generate(secret);
|
||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' });
|
||||
expect(loginRes.status).toBe(200);
|
||||
expect(loginRes.body.csrfToken).toBeDefined();
|
||||
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||
|
||||
@@ -288,6 +288,21 @@ describe('Services Routes', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasApiKey).toBe(true);
|
||||
});
|
||||
|
||||
it('requires both username and password before reporting Basic Auth ready', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn().mockImplementation((key) => {
|
||||
if (key === 'service.radarr.username') return Promise.resolve('admin');
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app).get('/api/services/radarr/credentials');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasBasicAuth).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
||||
|
||||
@@ -50,6 +50,17 @@ describe('TOTP session cookie scope', () => {
|
||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||
});
|
||||
|
||||
test('host-bound SSO token can only be redeemed on its intended service host', () => {
|
||||
const session = buildSession();
|
||||
const wrongHostToken = session.createHandoffToken('plex.sami');
|
||||
expect(session.redeemHandoffToken(wrongHostToken, 'chat.sami')).toBe(false);
|
||||
expect(session.redeemHandoffToken(wrongHostToken, 'plex.sami')).toBe(false);
|
||||
|
||||
const correctHostToken = session.createHandoffToken('plex.sami');
|
||||
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(true);
|
||||
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(false);
|
||||
});
|
||||
|
||||
test('logout clears the host-only secure cookie', () => {
|
||||
const session = buildSession();
|
||||
const headers = {};
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const request = require('supertest');
|
||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||
|
||||
function createApp({ redeem = true } = {}) {
|
||||
function loadCredentialVaultHandoff() {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
|
||||
'utf8',
|
||||
);
|
||||
const window = { location: { origin: 'https://status.sami' } };
|
||||
vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
|
||||
return window.DCCredentialVault;
|
||||
}
|
||||
|
||||
function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
|
||||
const app = express();
|
||||
const session = {
|
||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
||||
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
||||
setCookieHostOnly: jest.fn((res) => {
|
||||
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||
}),
|
||||
isValid: jest.fn().mockReturnValue(true),
|
||||
isValid: jest.fn().mockReturnValue(valid),
|
||||
createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
|
||||
};
|
||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
||||
@@ -21,14 +35,15 @@ function createApp({ redeem = true } = {}) {
|
||||
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
getAppSession: jest.fn(),
|
||||
appSessionCache: new Map(),
|
||||
credentialManager: { retrieve: jest.fn() },
|
||||
credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
|
||||
fetchT: jest.fn(),
|
||||
getServiceById: jest.fn(),
|
||||
getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
|
||||
licenseManager: {
|
||||
hasFeature: jest.fn().mockReturnValue(true),
|
||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||
},
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||
siteConfig: { dashboardHost },
|
||||
});
|
||||
app.use('/api/v1', router);
|
||||
return { app, session };
|
||||
@@ -44,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => {
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
|
||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -82,3 +97,105 @@ describe('cross-host SSO exchange redirect', () => {
|
||||
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing-session SSO handoff', () => {
|
||||
test('mints a handoff token without asking for TOTP again', async () => {
|
||||
const { app, session } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
|
||||
expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||
expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||
});
|
||||
|
||||
test('refuses to mint a handoff token without a valid session', async () => {
|
||||
const { app, session } = createApp({ valid: false });
|
||||
const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(session.createHandoffToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('completes the full mint, exchange, cookie, redirect lifecycle', async () => {
|
||||
const issued = new Set(['fresh-sso-handoff-token']);
|
||||
const redeemOnce = (token) => issued.delete(token);
|
||||
const { app } = createApp({ redeem: redeemOnce });
|
||||
|
||||
const mint = await request(app)
|
||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||
const exchange = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: mint.body.ssoToken, return: '/web/' });
|
||||
|
||||
expect(exchange.status).toBe(303);
|
||||
expect(exchange.headers.location).toBe('/web/');
|
||||
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
||||
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||
|
||||
const replay = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
.query({ token: mint.body.ssoToken, return: '/web/' });
|
||||
expect(replay.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypted-vault credential onboarding', () => {
|
||||
test('app-token identifies missing credentials as a form requirement', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/app-token/plex')
|
||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||
|
||||
expect(res.status).toBe(428);
|
||||
expect(res.body).toMatchObject({
|
||||
success: false,
|
||||
credentialsRequired: true,
|
||||
serviceId: 'plex',
|
||||
});
|
||||
});
|
||||
|
||||
test('service login page sends missing credentials to the encrypted vault form', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}");
|
||||
expect(res.text).toContain("dashboardOrigin+'?credentials='");
|
||||
});
|
||||
|
||||
test('service login page derives the vault origin from trusted dashboard config', async () => {
|
||||
const { app } = createApp({ dashboardHost: 'dashboard.home' });
|
||||
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('dashboardOrigin="https://dashboard.home"');
|
||||
});
|
||||
|
||||
test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => {
|
||||
const issued = new Set(['fresh-sso-handoff-token']);
|
||||
const { app } = createApp({ redeem: (token) => issued.delete(token) });
|
||||
const mint = await request(app)
|
||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||
|
||||
const vault = loadCredentialVaultHandoff();
|
||||
const target = new URL(vault.buildHandoffTarget(
|
||||
'https://plex.sami/web/?direct=1#home',
|
||||
mint.body.ssoToken,
|
||||
'plex',
|
||||
));
|
||||
// The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API
|
||||
// route while preserving the token and relative return query.
|
||||
const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search);
|
||||
|
||||
expect(target.pathname).toBe('/dashcaddy-sso');
|
||||
expect(exchange.status).toBe(303);
|
||||
expect(exchange.headers.location).toBe('/web/?direct=1#home');
|
||||
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
||||
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,67 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', ()
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('all :2019 call sites in TESTS use fetchT or a mocked fetchT (not raw fetch)', () => {
|
||||
// DC-087 — the same rule, extended into __tests__. The api-code walk above
|
||||
// skips __tests__, which let two mirrored health-handler test files keep a
|
||||
// raw await-fetch caddy probe long after src/app.js moved to fetchT. On a
|
||||
// host where the suite runs alongside a live Caddy admin (the prod box
|
||||
// runs the full jest suite every 30 min via a cron adversarial check),
|
||||
// that Origin-less raw fetch 403-spammed the Caddy journal (~700
|
||||
// client-not-allowed error lines per day) while the tests still passed —
|
||||
// checks.caddy.ok=false was silently accepted as sandbox noise. Mirrors
|
||||
// MUST call fetchT (mocked at buildApp scope for hermeticity). A raw
|
||||
// await-fetch at a Caddy-admin-URL call site in a test is an offender.
|
||||
// NOTE: keep this comment free of backticks — stripComments pairs
|
||||
// backtick spans across lines, and a stray pair shields real code from
|
||||
// the comment stripper (this test self-flagged its first draft).
|
||||
//
|
||||
// Detection is deliberately FILE-LEVEL, not call-window: the historical
|
||||
// drift kept the fetch call itself token-free (the URL came from a
|
||||
// caddyUrl variable defined on a PREVIOUS line from CADDY_ADMIN_URL),
|
||||
// so a call-window regex never fired. Any raw await-fetch in a file
|
||||
// that also references the Caddy admin anywhere is an offender.
|
||||
// Escape hatch for future tests that intentionally assert Origin-less
|
||||
// 403 behavior against their own local listener: put the marker
|
||||
// DC-087-ALLOW-RAW-FETCH in the file and it is skipped.
|
||||
const testsRoot = path.join(__dirname);
|
||||
const offenders = [];
|
||||
const skipped = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(p);
|
||||
else if (entry.name.endsWith('.js')) {
|
||||
const rawText = fs.readFileSync(p, 'utf8');
|
||||
// Escape hatch (checked on RAW text so a comment marker works —
|
||||
// comments are stripped below): a file carrying the
|
||||
// DC-087-ALLOW-RAW-FETCH marker declares it intentionally
|
||||
// raw-fetches the Caddy admin (e.g. asserting Origin-less 403
|
||||
// against its own local listener). The guard file itself is
|
||||
// always scanned (never skipped) so the hatch can't be used to
|
||||
// blind this very test.
|
||||
if (p !== __filename && /DC-087-ALLOW-RAW-FETCH/.test(rawText)) {
|
||||
skipped.push(p);
|
||||
continue;
|
||||
}
|
||||
const text = stripComments(rawText);
|
||||
const hasAdminToken = /:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(text);
|
||||
const hasRawAwaitFetch = /await\s+fetch\(/.test(text);
|
||||
if (hasAdminToken && hasRawAwaitFetch) {
|
||||
offenders.push(`${p}: raw await-fetch in a file referencing the Caddy admin (mock fetchT instead; documented escape-hatch marker available for intentional 403 tests)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(testsRoot);
|
||||
if (skipped.length) {
|
||||
// Visibility for hatch use — shows up in jest output for reviewers.
|
||||
console.info('[DC-087 guard] escape-hatch skipped:', skipped.join(', '));
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
|
||||
const raw = fs.readFileSync(
|
||||
path.join(__dirname, '../src/app.js'),
|
||||
@@ -127,9 +188,9 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', ()
|
||||
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
|
||||
// Goes through fetchT, NOT bare fetch — that's how the Origin injection
|
||||
// takes effect. Look at the 800 chars BEFORE the probe URL on the same
|
||||
// line / call site — the call must be `fetchT(...)`, not `await fetch(...)`.
|
||||
// (We look backward because the URL sits inside the call's argument list,
|
||||
// so the call site comes before the URL token.)
|
||||
// line / call site — the call must be fetchT(...), never a raw await of
|
||||
// the global fetch. (We look backward because the URL sits inside the
|
||||
// call's argument list, so the call site comes before the URL token.)
|
||||
const idx = raw.indexOf('srv0/listen');
|
||||
const around = raw.substr(Math.max(0, idx - 400), 800);
|
||||
expect(around).toMatch(/fetchT\(/);
|
||||
|
||||
@@ -32,23 +32,6 @@ const emailSender = require('../../src/auth/providers/email-sender');
|
||||
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
||||
const { ok, successMessage } = require('../../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Build the URL an invitee should click. Mirrors EmailMagicLinkProvider's
|
||||
* _resolvePublicUrl logic — kept duplicated (not extracted) because the two
|
||||
* callers have slightly different link paths and the duplication is smaller
|
||||
* than the abstraction would be.
|
||||
*/
|
||||
function _buildInviteUrl(req, siteConfig, token) {
|
||||
if (siteConfig && siteConfig.publicBaseUrl) {
|
||||
return siteConfig.publicBaseUrl.replace(/\/+$/, '') +
|
||||
'/api/v1/auth/invites/' + encodeURIComponent(token) + '/accept';
|
||||
}
|
||||
const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
|
||||
const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
|
||||
|| (siteConfig && siteConfig.dashboardHost) || 'localhost:3001';
|
||||
return `${proto}://${host}/api/v1/auth/invites/${encodeURIComponent(token)}/accept`;
|
||||
}
|
||||
|
||||
function _requireAdmin(req, _res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return next(new ForbiddenError('Admin role required'));
|
||||
@@ -240,11 +223,21 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
||||
});
|
||||
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
||||
|
||||
let deliveredVia = 'none';
|
||||
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
||||
if (sendEmail !== false) {
|
||||
// Best-effort send. If SMTP isn't configured, log to error.log (dev path).
|
||||
const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token);
|
||||
// Build the accept URL once — used both for the response and for email delivery.
|
||||
const baseUrl = (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
|
||||
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
|
||||
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
|
||||
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001')));
|
||||
const acceptUrl = baseUrl + '/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept';
|
||||
|
||||
// DC-085: link-first delivery. Default = no email, just hand the link back.
|
||||
// Operators opt INTO email by sending { sendEmail: true } (or the admin UI
|
||||
// checks the "Send email" checkbox). When SMTP is unconfigured AND the
|
||||
// operator did opt in, we surface the failure as `deliveredVia: 'failed'`
|
||||
// but NEVER leak the raw token into the server log — the link is already
|
||||
// in the response, so the operator has a UI-side fallback.
|
||||
let deliveredVia = 'manual';
|
||||
if (sendEmail === true) {
|
||||
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
||||
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
||||
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
||||
@@ -254,33 +247,37 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
||||
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
||||
deliveredVia = 'email';
|
||||
} else {
|
||||
// Dev fallback — log the raw link so operators can grab it.
|
||||
log.warn && log.warn('auth-invite-dev',
|
||||
'[DC-048-DEV-INVITE-LINK] email=' + issued.email +
|
||||
' role=' + issued.role + ' url=' + acceptUrl);
|
||||
deliveredVia = 'dev-console';
|
||||
// Operator asked for email but SMTP isn't configured. Surface the
|
||||
// failure cleanly; the link is still in the response so the
|
||||
// operator can share it manually. Do NOT log the raw URL — it
|
||||
// would duplicate what's already in the response and pollute the
|
||||
// server log on every unconfigured-install invite.
|
||||
log.warn && log.warn('auth-invite-send',
|
||||
'invite send skipped: SMTP not configured (operator opted in)',
|
||||
{ inviteId: issued.id, email: issued.email });
|
||||
deliveredVia = 'failed';
|
||||
}
|
||||
} catch (sendErr) {
|
||||
log.warn && log.warn('auth-invite-send',
|
||||
'invite send failed: ' + (sendErr.message || String(sendErr)));
|
||||
'invite send failed: ' + (sendErr.message || String(sendErr)),
|
||||
{ inviteId: issued.id });
|
||||
deliveredVia = 'failed';
|
||||
}
|
||||
} else {
|
||||
deliveredVia = 'manual';
|
||||
}
|
||||
|
||||
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
||||
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
||||
const shareText =
|
||||
'Join my DashCaddy as ' + issued.role + ' — ' + acceptUrl +
|
||||
' — expires in ' + ttlHoursOut + 'h.';
|
||||
|
||||
return ok(res, {
|
||||
id: issued.id,
|
||||
email: issued.email,
|
||||
role: issued.role,
|
||||
expiresAt: issued.expiresAt,
|
||||
// The raw token is returned ONCE so the admin UI can show/copy the
|
||||
// link. It is also embedded in the email when sendEmail !== false.
|
||||
acceptUrl: (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
|
||||
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
|
||||
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
|
||||
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001'))) +
|
||||
'/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept',
|
||||
acceptUrl,
|
||||
shareText,
|
||||
deliveredVia,
|
||||
maskedEmail,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ 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;
|
||||
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps;
|
||||
|
||||
// Create ctx-like object for compatibility
|
||||
const ctx = {
|
||||
@@ -126,7 +126,12 @@ module.exports = function(deps) {
|
||||
try {
|
||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
|
||||
if (!username || !password) {
|
||||
return errorResponse(res, 428, '[DC-500] No credentials stored', {
|
||||
credentialsRequired: true,
|
||||
serviceId,
|
||||
});
|
||||
}
|
||||
const service = await ctx.getServiceById(serviceId);
|
||||
const baseUrl = service?.url;
|
||||
if (!baseUrl) throw new NotFoundError('No service URL');
|
||||
@@ -181,7 +186,12 @@ module.exports = function(deps) {
|
||||
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||
}
|
||||
|
||||
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
|
||||
if (!username || !password) {
|
||||
return errorResponse(res, 428, '[DC-500] No credentials stored', {
|
||||
credentialsRequired: true,
|
||||
serviceId,
|
||||
});
|
||||
}
|
||||
|
||||
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
||||
if (appCookies) {
|
||||
@@ -203,8 +213,28 @@ module.exports = function(deps) {
|
||||
}
|
||||
}, 'auth-app-token'));
|
||||
|
||||
// A browser that already has a valid status.sami session must not be asked
|
||||
// for TOTP again just because it opened another private-TLD service host.
|
||||
// Mint a fresh one-time token that the target host can exchange for its own
|
||||
// host-only cookie. This route is intentionally session-protected both by
|
||||
// the global middleware and here (defence in depth).
|
||||
router.get('/auth/sso-handoff', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!session.isValid(req)) {
|
||||
return errorResponse(res, 401, 'Session expired or invalid');
|
||||
}
|
||||
const serviceId = String(req.query.serviceId || '');
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) {
|
||||
return errorResponse(res, 400, 'Valid serviceId is required');
|
||||
}
|
||||
const suffix = String(siteConfig?.tld || '.sami');
|
||||
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
|
||||
ok(res, { ssoToken: session.createHandoffToken(expectedHost) });
|
||||
});
|
||||
|
||||
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
||||
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
|
||||
// (minted by /totp/verify or /auth/sso-handoff) 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
|
||||
@@ -215,7 +245,9 @@ module.exports = function(deps) {
|
||||
router.get('/auth/sso-exchange', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const token = req.query.token;
|
||||
if (!session.redeemHandoffToken(token)) {
|
||||
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '')
|
||||
.split(',')[0].trim().replace(/:\d+$/, '').toLowerCase();
|
||||
if (!session.redeemHandoffToken(token, forwardedHost)) {
|
||||
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
||||
}
|
||||
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
||||
@@ -237,7 +269,12 @@ module.exports = function(deps) {
|
||||
// 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, '');
|
||||
const html = buildLoginPage(service);
|
||||
const configuredHost = siteConfig?.dashboardHost;
|
||||
const dashboardOrigin = typeof configuredHost === 'string'
|
||||
&& /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(configuredHost)
|
||||
? `https://${configuredHost}`
|
||||
: 'https://status.sami';
|
||||
const html = buildLoginPage(service, dashboardOrigin);
|
||||
if (!html) return res.status(404).send('Unknown service');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -255,7 +292,7 @@ module.exports = function(deps) {
|
||||
return router;
|
||||
};
|
||||
|
||||
function buildLoginPage(service) {
|
||||
function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
|
||||
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
|
||||
// disabled or blocked. The cookie is sent automatically because we hit the
|
||||
// same origin (plex.sami); if the API returns 200 the user has a valid
|
||||
@@ -266,7 +303,7 @@ function buildLoginPage(service) {
|
||||
<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');
|
||||
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'),dashboardOrigin=__DASHBOARD_ORIGIN__;
|
||||
// 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
|
||||
@@ -274,13 +311,16 @@ function buildLoginPage(service) {
|
||||
// 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 authUrl(){return dashboardOrigin+'?auth=required&return='+encodeURIComponent(location.href)}
|
||||
function authLink(label){return '<a href="'+authUrl()+'">'+label+'</a>'}
|
||||
function vault(svc){go(dashboardOrigin+'?credentials='+encodeURIComponent(svc)+'&return='+encodeURIComponent(location.href))}
|
||||
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);
|
||||
var overallTimer=setTimeout(function(){go(authUrl())},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
|
||||
@@ -305,18 +345,17 @@ function buildLoginPage(service) {
|
||||
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}
|
||||
if(!st||!st.success||!st.authenticated){go(authUrl());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'))})
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. '+authLink('Sign in at DashCaddy'),'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...';
|
||||
body: `d.textContent='Fetching token from DashCaddy...';
|
||||
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}
|
||||
try{var j=JSON.parse(t);if(j.credentialsRequired){vault('chat');return}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))}
|
||||
@@ -324,30 +363,29 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
|
||||
},
|
||||
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');return}
|
||||
body: `ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||
if(j.credentialsRequired){vault('plex');return}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))
|
||||
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or '+authLink('re-authenticate at DashCaddy'),'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/');return}
|
||||
if(j.credentialsRequired){vault('jellyfin');return}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))
|
||||
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or '+authLink('re-authenticate at DashCaddy'),'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/');return}
|
||||
if(j.credentialsRequired){vault('emby');return}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))
|
||||
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||
},
|
||||
};
|
||||
@@ -357,5 +395,6 @@ ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||
return SHELL(cfg.body)
|
||||
.replace(/__TITLE__/g, cfg.title)
|
||||
.replace('__BG__', cfg.bg)
|
||||
.replace('__ACCENT__', cfg.accent);
|
||||
.replace('__ACCENT__', cfg.accent)
|
||||
.replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const { ok, successMessage } = require('../../src/utils/responses');
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) {
|
||||
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken, siteConfig }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Ctx shim for backward compatibility
|
||||
@@ -23,7 +23,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
||||
credentialManager,
|
||||
totpConfig,
|
||||
saveTotpConfig,
|
||||
session
|
||||
session,
|
||||
siteConfig
|
||||
};
|
||||
|
||||
// Get current TOTP config (public route)
|
||||
@@ -193,11 +194,14 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
||||
// Login: verify TOTP code and set session cookie
|
||||
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
const { code } = req.body;
|
||||
const { code, serviceId } = req.body;
|
||||
|
||||
if (!code || !/^\d{6}$/.test(code)) {
|
||||
throw new ValidationError('Invalid code format', 'code');
|
||||
}
|
||||
if (serviceId != null && !/^[a-z0-9][a-z0-9-]*$/.test(String(serviceId))) {
|
||||
throw new ValidationError('Invalid service ID', 'serviceId');
|
||||
}
|
||||
|
||||
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
||||
throw new ValidationError('TOTP is not enabled');
|
||||
@@ -227,7 +231,12 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
||||
// 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();
|
||||
let ssoToken = null;
|
||||
if (serviceId) {
|
||||
const suffix = String(ctx.siteConfig?.tld || '.sami');
|
||||
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
|
||||
ssoToken = ctx.session.createHandoffToken(expectedHost);
|
||||
}
|
||||
|
||||
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
||||
|
||||
@@ -263,9 +263,10 @@ module.exports = function({
|
||||
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||
const password = await credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||
success(res, {
|
||||
hasApiKey: !!(arrKey || svcKey),
|
||||
hasBasicAuth: !!username,
|
||||
hasBasicAuth: !!username && !!password,
|
||||
username: username || null
|
||||
});
|
||||
}, 'service-creds'));
|
||||
|
||||
@@ -33,17 +33,45 @@ const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '30
|
||||
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||
|
||||
// DC-086: hysteresis thresholds for badge display.
|
||||
// The raw probe result can flap on a single transient blip (Caddy reload,
|
||||
// container CPU steal, network hiccup, mid-flight TLS handshake). Showing
|
||||
// every probe result as-is to the dashboard creates the "perpetual flicker"
|
||||
// UX. Asymmetric thresholds: going red is slow (don't false-alarm), going
|
||||
// green is fast (don't keep showing red after recovery).
|
||||
// - DOWN_THRESHOLD = N consecutive "down" probes before the badge flips to red
|
||||
// - UP_THRESHOLD = N consecutive "up" probes before the badge flips back to green
|
||||
// Single probe flips to green on purpose — false-positive-green is much less
|
||||
// painful than perpetual-red (operators notice red, ignore green).
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === '') return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value >= 1 ? value : fallback;
|
||||
}
|
||||
|
||||
const DOWN_THRESHOLD = readPositiveIntEnv('HEALTH_DOWN_THRESHOLD', 2);
|
||||
const UP_THRESHOLD = readPositiveIntEnv('HEALTH_UP_THRESHOLD', 1);
|
||||
|
||||
class HealthChecker extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.config = this.loadConfig();
|
||||
this.history = this.loadHistory();
|
||||
this.currentStatus = new Map();
|
||||
// DC-086: the status the dashboard SHOULD display (post-hysteresis).
|
||||
// Distinct from currentStatus, which is the latest raw probe result.
|
||||
this.displayedStatus = new Map();
|
||||
// DC-086: counter of consecutive healthy/unhealthy probes since the
|
||||
// last displayed-status change. Reset to 0 whenever displayed status flips.
|
||||
this.consecutiveSinceChange = new Map();
|
||||
this.incidents = [];
|
||||
this.checking = false;
|
||||
this.checkInterval = null;
|
||||
this.consecutiveFailures = new Map(); // serviceId -> failure count
|
||||
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
|
||||
// Invalidate probe completions that race with removal/reconfiguration.
|
||||
this.serviceGenerations = new Map(); // serviceId -> configuration generation
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +144,7 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
async checkService(serviceId, config) {
|
||||
const startTime = Date.now();
|
||||
const generation = this.serviceGenerations.get(serviceId) || 0;
|
||||
|
||||
try {
|
||||
const result = await this.performHealthCheck(config);
|
||||
@@ -131,6 +160,10 @@ class HealthChecker extends EventEmitter {
|
||||
details: result.details
|
||||
};
|
||||
|
||||
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
|
||||
return status;
|
||||
}
|
||||
|
||||
// Track consecutive failures for exponential backoff
|
||||
if (result.healthy) {
|
||||
this.consecutiveFailures.delete(serviceId);
|
||||
@@ -138,8 +171,9 @@ class HealthChecker extends EventEmitter {
|
||||
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
||||
}
|
||||
|
||||
const previousStatus = this.currentStatus.get(serviceId);
|
||||
this.recordStatus(serviceId, status);
|
||||
this.checkForIncidents(serviceId, status, config);
|
||||
this.checkForIncidents(serviceId, status, config, previousStatus);
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
@@ -156,8 +190,13 @@ class HealthChecker extends EventEmitter {
|
||||
error: error.message
|
||||
};
|
||||
|
||||
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const previousStatus = this.currentStatus.get(serviceId);
|
||||
this.recordStatus(serviceId, status);
|
||||
this.checkForIncidents(serviceId, status, config);
|
||||
this.checkForIncidents(serviceId, status, config, previousStatus);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -273,27 +312,109 @@ class HealthChecker extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the displayed status for a service given the latest raw probe
|
||||
* result. Applies asymmetric hysteresis:
|
||||
* - Going DOWN: requires DOWN_THRESHOLD (default 2) consecutive "down"
|
||||
* probes since the last display-state change. A single blip keeps the
|
||||
* badge green.
|
||||
* - Going UP: requires UP_THRESHOLD (default 1) consecutive "up" probes.
|
||||
* Any single "up" after a down streak flips back to green so the badge
|
||||
* doesn't linger red after the service has recovered.
|
||||
*
|
||||
* Returns the displayed status object (same shape as the raw status) so
|
||||
* recordStatus can use it both for the displayed map and as the broadcast
|
||||
* payload when the displayed status actually changes.
|
||||
*/
|
||||
_computeDisplayedStatus(serviceId, rawStatus) {
|
||||
const currentDisplayed = this.displayedStatus.get(serviceId);
|
||||
const previousStatus = currentDisplayed ? currentDisplayed.status : null;
|
||||
|
||||
// If no prior state, accept the raw probe as-is (first-check bootstrap).
|
||||
if (!previousStatus) {
|
||||
return rawStatus;
|
||||
}
|
||||
|
||||
// Probe agrees with current displayed → no change, reset the counter so
|
||||
// a brief blip doesn't accumulate against the displayed state.
|
||||
if (rawStatus.status === previousStatus) {
|
||||
this.consecutiveSinceChange.set(serviceId, 0);
|
||||
return rawStatus;
|
||||
}
|
||||
|
||||
// Probe disagrees with displayed. Bump the streak counter — this counts
|
||||
// CONSECUTIVE probes that disagree with what's shown, regardless of
|
||||
// whether the raw value itself changed between probes. That's what
|
||||
// makes "down, down" flip after threshold but "down, up, down" not flip.
|
||||
const prev = this.consecutiveSinceChange.get(serviceId) || 0;
|
||||
const next = prev + 1;
|
||||
|
||||
if (rawStatus.status === 'down') {
|
||||
// Going DOWN: need DOWN_THRESHOLD consecutive probes that disagree
|
||||
// with the displayed "up" state.
|
||||
if (previousStatus === 'up' && next < DOWN_THRESHOLD) {
|
||||
this.consecutiveSinceChange.set(serviceId, next);
|
||||
// Keep the last internally-consistent displayed snapshot. Mixing the
|
||||
// raw failure metadata with status="up" would expose contradictory
|
||||
// API data (for example statusCode=500 on an "up" service).
|
||||
return currentDisplayed;
|
||||
}
|
||||
// Threshold met (or already down) — flip to red.
|
||||
this.consecutiveSinceChange.set(serviceId, 0);
|
||||
return rawStatus;
|
||||
}
|
||||
|
||||
// rawStatus.status === 'up' (must be — the equal-to-displayed case above
|
||||
// already returned). Going UP after a down streak: need UP_THRESHOLD.
|
||||
if (previousStatus === 'down' && next < UP_THRESHOLD) {
|
||||
this.consecutiveSinceChange.set(serviceId, next);
|
||||
return currentDisplayed;
|
||||
}
|
||||
this.consecutiveSinceChange.set(serviceId, 0);
|
||||
return rawStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record service status
|
||||
*
|
||||
* DC-086: history + consecutiveFailures are updated for EVERY probe
|
||||
* (operators want full probe history for postmortems). The dashboard's
|
||||
* `status-check` event is only emitted when the DISPLAYED status changes,
|
||||
* so the badge stops re-rendering on every probe.
|
||||
*/
|
||||
recordStatus(serviceId, status) {
|
||||
// Update current status
|
||||
// Update current (raw) status — used by checkForIncidents and history.
|
||||
this.currentStatus.set(serviceId, status);
|
||||
|
||||
// Add to history
|
||||
// Add raw probe to history (full fidelity — operators rely on this).
|
||||
if (!this.history[serviceId]) {
|
||||
this.history[serviceId] = [];
|
||||
}
|
||||
|
||||
this.history[serviceId].push(status);
|
||||
|
||||
|
||||
// Cap entries to prevent unbounded growth (disk explosion fix)
|
||||
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||
}
|
||||
|
||||
// Emit status event
|
||||
this.emit('status-check', status);
|
||||
// Compute the post-hysteresis displayed status; only emit when it changes.
|
||||
// _computeDisplayedStatus compares the raw probe against the DISPLAYED
|
||||
// status (not the previous raw status), so the "consecutive since
|
||||
// change" counter doesn't depend on the order of writes here.
|
||||
const displayed = this._computeDisplayedStatus(serviceId, status);
|
||||
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||
const displayChanged =
|
||||
!previousDisplayed || previousDisplayed.status !== displayed.status;
|
||||
|
||||
this.displayedStatus.set(serviceId, displayed);
|
||||
|
||||
if (displayChanged) {
|
||||
// Emit with the displayed status so the dashboard renders the same
|
||||
// state the hysteresis just decided. The raw probe result is still
|
||||
// in `history` and `currentStatus` for anyone who wants it.
|
||||
this.emit('status-check', displayed);
|
||||
}
|
||||
|
||||
// Save history periodically
|
||||
if (Math.random() < 0.05) { // 5% chance (every ~20 checks)
|
||||
@@ -304,8 +425,7 @@ class HealthChecker extends EventEmitter {
|
||||
/**
|
||||
* Check for incidents (downtime, slow response, etc.)
|
||||
*/
|
||||
checkForIncidents(serviceId, status, config) {
|
||||
const previous = this.currentStatus.get(serviceId);
|
||||
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId)) {
|
||||
|
||||
// Check for status change (up -> down or down -> up)
|
||||
if (previous && previous.status !== status.status) {
|
||||
@@ -445,19 +565,29 @@ class HealthChecker extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status for all services
|
||||
* Get current status for all services.
|
||||
*
|
||||
* DC-086: returns the DISPLAYED status (post-hysteresis), not the latest
|
||||
* raw probe. A page reload should show the same badge state the live
|
||||
* SSE stream is currently showing — otherwise an operator who reloads
|
||||
* the page after a single blip sees red even though the hysteresis kept
|
||||
* the badge green for them.
|
||||
*/
|
||||
getCurrentStatus() {
|
||||
const result = {};
|
||||
|
||||
for (const [serviceId, status] of this.currentStatus.entries()) {
|
||||
|
||||
for (const [serviceId, rawStatus] of this.currentStatus.entries()) {
|
||||
const config = this.config.services[serviceId];
|
||||
const uptime24h = this.calculateUptime(serviceId, 24);
|
||||
const uptime7d = this.calculateUptime(serviceId, 168);
|
||||
const avgResponseTime = this.calculateAverageResponseTime(serviceId, 24);
|
||||
|
||||
|
||||
// Prefer the displayed status if we've already computed one; fall back
|
||||
// to the raw probe on the very first call (before recordStatus has run).
|
||||
const displayed = this.displayedStatus.get(serviceId) || rawStatus;
|
||||
|
||||
result[serviceId] = {
|
||||
...status,
|
||||
...displayed,
|
||||
name: config?.name || serviceId,
|
||||
uptime: {
|
||||
'24h': uptime24h,
|
||||
@@ -467,7 +597,7 @@ class HealthChecker extends EventEmitter {
|
||||
sla: config?.sla
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -530,6 +660,7 @@ class HealthChecker extends EventEmitter {
|
||||
this.config.services = {};
|
||||
}
|
||||
|
||||
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
|
||||
this.config.services[serviceId] = {
|
||||
enabled: config.enabled !== false,
|
||||
name: config.name || serviceId,
|
||||
@@ -552,12 +683,19 @@ class HealthChecker extends EventEmitter {
|
||||
* Remove service configuration
|
||||
*/
|
||||
removeService(serviceId) {
|
||||
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
|
||||
if (this.config.services) {
|
||||
delete this.config.services[serviceId];
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
this.currentStatus.delete(serviceId);
|
||||
this.displayedStatus.delete(serviceId);
|
||||
this.consecutiveSinceChange.delete(serviceId);
|
||||
this.consecutiveFailures.delete(serviceId);
|
||||
const timer = this.serviceTimers.get(serviceId);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.serviceTimers.delete(serviceId);
|
||||
delete this.history[serviceId];
|
||||
}
|
||||
|
||||
|
||||
@@ -330,17 +330,22 @@ module.exports = function configureMiddleware(app, {
|
||||
const ssoHandoffTokens = new Map();
|
||||
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
||||
|
||||
function createHandoffToken() {
|
||||
function createHandoffToken(expectedHost = null) {
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
|
||||
ssoHandoffTokens.set(token, {
|
||||
exp: Date.now() + SSO_HANDOFF_TTL_MS,
|
||||
expectedHost: expectedHost ? String(expectedHost).toLowerCase() : null,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
function redeemHandoffToken(token) {
|
||||
function redeemHandoffToken(token, actualHost = null) {
|
||||
if (!token) return false;
|
||||
const entry = ssoHandoffTokens.get(token);
|
||||
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
||||
return !!entry && entry.exp > Date.now();
|
||||
if (!entry || entry.exp <= Date.now()) return false;
|
||||
if (!entry.expectedHost) return true;
|
||||
return !!actualHost && entry.expectedHost === String(actualHost).toLowerCase();
|
||||
}
|
||||
|
||||
function setHostOnlySessionCookie(res, durationKey) {
|
||||
|
||||
@@ -630,10 +630,15 @@ generate_caddyfile() {
|
||||
SNIP
|
||||
|
||||
local auth_snippet="(dashcaddy_auth) {
|
||||
forward_auth localhost:${API_PORT} {
|
||||
@needsAuth not path /dashcaddy-sso
|
||||
forward_auth @needsAuth localhost:${API_PORT} {
|
||||
uri /api/v1/auth/gate/{args[0]}
|
||||
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
||||
}
|
||||
handle /dashcaddy-sso {
|
||||
rewrite * /api/v1/auth/sso-exchange
|
||||
reverse_proxy localhost:${API_PORT}
|
||||
}
|
||||
}"
|
||||
|
||||
local site_body=" root * ${DASHBOARD_DIR}
|
||||
|
||||
@@ -51,10 +51,15 @@ class CaddyfileGenerator {
|
||||
_authSnippet(apiPort) {
|
||||
return `# DashCaddy SSO auth snippet
|
||||
(dashcaddy_auth) {
|
||||
forward_auth localhost:${apiPort} {
|
||||
@needsAuth not path /dashcaddy-sso
|
||||
forward_auth @needsAuth localhost:${apiPort} {
|
||||
uri /api/v1/auth/gate/{args[0]}
|
||||
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
||||
}
|
||||
handle /dashcaddy-sso {
|
||||
rewrite * /api/v1/auth/sso-exchange
|
||||
reverse_proxy localhost:${apiPort}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const CaddyfileGenerator = require('./caddyfile-generator');
|
||||
|
||||
describe('cross-host SSO installer contract', () => {
|
||||
test('generated auth snippet exposes the public one-time exchange landing route', () => {
|
||||
const snippet = new CaddyfileGenerator()._authSnippet(3001);
|
||||
expect(snippet).toContain('@needsAuth not path /dashcaddy-sso');
|
||||
expect(snippet).toContain('handle /dashcaddy-sso');
|
||||
expect(snippet).toContain('rewrite * /api/v1/auth/sso-exchange');
|
||||
expect(snippet).toContain('reverse_proxy localhost:3001');
|
||||
});
|
||||
|
||||
test('shell installer emits the same exchange landing contract', () => {
|
||||
const installer = fs.readFileSync(path.join(__dirname, '..', '..', 'install.sh'), 'utf8');
|
||||
expect(installer).toContain('@needsAuth not path /dashcaddy-sso');
|
||||
expect(installer).toContain('handle /dashcaddy-sso');
|
||||
expect(installer).toContain('rewrite * /api/v1/auth/sso-exchange');
|
||||
});
|
||||
|
||||
test('Caddy parser accepts a complete service config using the generated snippet', () => {
|
||||
const available = spawnSync('caddy', ['version'], { encoding: 'utf8' });
|
||||
if (available.status !== 0) return;
|
||||
|
||||
const generator = new CaddyfileGenerator();
|
||||
const config = `${generator._authSnippet(3001)}\nexample.test {\n import dashcaddy_auth plex\n respond "ok" 200\n}\n`;
|
||||
const result = spawnSync('caddy', ['validate', '--config', '-', '--adapter', 'caddyfile'], {
|
||||
input: config,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(`${result.stdout}\n${result.stderr}`).toContain('Valid configuration');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const bundles = {
|
||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||
JS('totp-recovery.js'),
|
||||
JS('credential-vault-handoff.js'),
|
||||
JS('service-credentials.js'),
|
||||
JS('totp-settings.js'),
|
||||
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
||||
|
||||
Vendored
+108
-108
@@ -1,4 +1,4 @@
|
||||
(function(c){"use strict";class s{constructor(){this.errors=[],this.maxErrors=50}logError(h,b,y={}){const v={timestamp:new Date().toISOString(),context:h,message:b instanceof Error?b.message:b,stack:b instanceof Error?b.stack:null,metadata:y};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,b,y)}recoverFromError(h,b){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:b}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const b=h.message||h.toString();return b.includes("element")&&b.includes("not found")?"ELEMENT_NOT_FOUND":b.includes("storage")||b.includes("quota")?"STORAGE_UNAVAILABLE":b.includes("driver")||b.includes("undefined")?"DRIVER_NOT_LOADED":b.includes("invalid")||b.includes("validation")?"INVALID_TOOLTIP":b.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(b=>{h.byContext[b.context]=(h.byContext[b.context]||0)+1;const y=this.classifyError({message:b.message});h.byType[y]=(h.byType[y]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
|
||||
(function(c){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(h,b,g={}){const v={timestamp:new Date().toISOString(),context:h,message:b instanceof Error?b.message:b,stack:b instanceof Error?b.stack:null,metadata:g};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,b,g)}recoverFromError(h,b){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:b}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const b=h.message||h.toString();return b.includes("element")&&b.includes("not found")?"ELEMENT_NOT_FOUND":b.includes("storage")||b.includes("quota")?"STORAGE_UNAVAILABLE":b.includes("driver")||b.includes("undefined")?"DRIVER_NOT_LOADED":b.includes("invalid")||b.includes("validation")?"INVALID_TOOLTIP":b.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(b=>{h.byContext[b.context]=(h.byContext[b.context]||0)+1;const g=this.classifyError({message:b.message});h.byType[g]=(h.byType[g]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
@@ -16,48 +16,48 @@
|
||||
The interactive tour is unavailable, but you can explore the dashboard freely.
|
||||
Check the documentation for help getting started.
|
||||
</p>
|
||||
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(b){return this.data[b]||null},setItem(b,y){this.data[b]=y},removeItem(b){delete this.data[b]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=s,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var s=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(s){var m=JSON.parse(s);m.statsPollingInterval&&m.statsPollingInterval>=5&&m.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=m.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const b=await h.json();if(b.tld&&(SITE.tld=b.tld.startsWith(".")?b.tld:"."+b.tld),b.dns&&(SITE.dnsIp=b.dns.ip||"",SITE.dnsPort=b.dns.port||DC.DEFAULTS.DNS_PORT),b.dnsServers&&typeof b.dnsServers=="object")for(const[v,u]of Object.entries(b.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=u);b.configurationType&&(SITE.configurationType=b.configurationType),b.domain&&(SITE.domain=b.domain),b.defaults&&(SITE.defaults=b.defaults),b.routingMode&&(SITE.routingMode=b.routingMode),SITE.onboardingCompleted=b.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const y=document.getElementById("manage-tokens");y&&(y.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const s=document.getElementById("edit-tld-suffix");s&&(s.textContent=SITE.tld);const m=document.getElementById("external-proxy-ip");m&&SITE.dnsIp&&(m.value=SITE.dnsIp,m.placeholder=SITE.dnsIp)})();function buildDomain(c){return c+SITE.tld}function buildServiceUrl(c){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+c:SITE.configurationType==="public"&&SITE.domain?"https://"+c+"."+SITE.domain:"https://"+buildDomain(c)}function getDnsServerAddr(c){const s=SITE.dnsServers[c];return s?`${s.ip}:${s.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,s]of Object.entries(SITE.dnsServers))if(s.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const s=Object.keys(SITE.dnsServers);if(!s.length)return;const m='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=c.firstElementChild;s.forEach(b=>{const y=escapeHtml(b),v=escapeHtml((SITE.dnsServers[b].name||b).toUpperCase()),u=document.createElement("div");u.className="card",u.setAttribute("data-app",b),u.setAttribute("data-status","off"),u.innerHTML=`<span id="${y}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${m}</div><span class="name">${v}</span><span class="spacer"></span><span id="${y}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div><div class="response-row"><span id="${y}-time" class="response-time">--</span></div><div class="health-row" id="health-${y}"><span id="uptime-${y}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${y}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${y}-restart" class="restart-btn" data-i18n="action.restart">Restart</button><button id="${y}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${y}-open" data-i18n="action.open">OpLine truncated
|
||||
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(b){return this.data[b]||null},setItem(b,g){this.data[b]=g},removeItem(b){delete this.data[b]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var a=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(a){var m=JSON.parse(a);m.statsPollingInterval&&m.statsPollingInterval>=5&&m.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=m.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const b=await h.json();if(b.tld&&(SITE.tld=b.tld.startsWith(".")?b.tld:"."+b.tld),b.dns&&(SITE.dnsIp=b.dns.ip||"",SITE.dnsPort=b.dns.port||DC.DEFAULTS.DNS_PORT),b.dnsServers&&typeof b.dnsServers=="object")for(const[v,p]of Object.entries(b.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=p);b.configurationType&&(SITE.configurationType=b.configurationType),b.domain&&(SITE.domain=b.domain),b.defaults&&(SITE.defaults=b.defaults),b.routingMode&&(SITE.routingMode=b.routingMode),SITE.onboardingCompleted=b.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const g=document.getElementById("manage-tokens");g&&(g.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const a=document.getElementById("edit-tld-suffix");a&&(a.textContent=SITE.tld);const m=document.getElementById("external-proxy-ip");m&&SITE.dnsIp&&(m.value=SITE.dnsIp,m.placeholder=SITE.dnsIp)})();function buildDomain(c){return c+SITE.tld}function buildServiceUrl(c){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+c:SITE.configurationType==="public"&&SITE.domain?"https://"+c+"."+SITE.domain:"https://"+buildDomain(c)}function getDnsServerAddr(c){const a=SITE.dnsServers[c];return a?`${a.ip}:${a.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,a]of Object.entries(SITE.dnsServers))if(a.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const a=Object.keys(SITE.dnsServers);if(!a.length)return;const m='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=c.firstElementChild;a.forEach(b=>{const g=escapeHtml(b),v=escapeHtml((SITE.dnsServers[b].name||b).toUpperCase()),p=document.createElement("div");p.className="card",p.setAttribute("data-app",b),p.setAttribute("data-status","off"),p.innerHTML=`<span id="${g}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${m}</div><span class="name">${v}</span><span class="spacer"></span><span id="${g}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div><div class="response-row"><span id="${g}-time" class="response-time">--</span></div><div class="health-row" id="health-${g}"><span id="uptime-${g}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${g}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${g}-restart" class="restart-btn" data-i18n="action.restart">Restart</button><button id="${g}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${g}-open" data-i18n="action.open">OpLine truncated
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
background: ${y.bg}; color: ${y.fg};
|
||||
background: ${g.bg}; color: ${g.fg};
|
||||
padding: 16px 24px; border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
z-index: 10000; animation: slideIn 0.3s ease-out;
|
||||
max-width: 400px; white-space: pre-line; font-size: 14px;
|
||||
`,document.body.appendChild(v),m>0&&setTimeout(()=>v.remove(),m)}function timeAgo(c){const s=Date.now()-new Date(c).getTime();return s<6e4?"just now":s<36e5?Math.floor(s/6e4)+"m ago":s<864e5?Math.floor(s/36e5)+"h ago":Math.floor(s/864e5)+"d ago"}function safeGet(c,s=null){try{const m=localStorage.getItem(c);return m!==null?m:s}catch{return s}}function safeSet(c,s){try{localStorage.setItem(c,s)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,s=null){try{const m=sessionStorage.getItem(c);return m!==null?m:s}catch{return s}}function safeSessionSet(c,s){try{sessionStorage.setItem(c,s)}catch{}}function safeGetJSON(c,s=null){try{const m=localStorage.getItem(c);return m?JSON.parse(m):s}catch{return s}}function escapeHtml(c){return String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(c,s){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",s)}const DC_BUS={_handlers:{},on(c,s){var m;((m=this._handlers)[c]||(m[c]=[])).push(s)},off(c,s){this._handlers[c]=this._handlers[c]?.filter(m=>m!==s)},emit(c,s){this._handlers[c]?.forEach(m=>m(s))}},AppState={_apps:[],getApps(){return this._apps},setApps(c){this._apps=c,window.APPS=c,DC_BUS.emit("apps:changed",c)},findApp(c){return this._apps.find(s=>s.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const s=this._apps.findIndex(m=>m.id===c);return s>-1&&(this._apps.splice(s,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),s>-1},updateApp(c,s){const m=this._apps.find(h=>h.id===c);if(m){for(const[h,b]of Object.entries(s))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(m[h]=b);DC_BUS.emit("apps:changed",this._apps)}return m}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.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>',h}function s(h){const b=document.getElementById("cards");if(!(!b||b.querySelector(".card"))){h=h||6;for(let y=0;y<h;y++){const v=c();b.appendChild(v),setTimeout(function(){v.classList.add("loaded")},y*60)}}}function m(){const h=document.getElementById("cards");if(h){var b=h.querySelectorAll(".skeleton-card");b.length&&(b.forEach(function(y,v){setTimeout(function(){y.style.opacity="0",y.style.transform="translateY(-10px)"},v*25)}),setTimeout(function(){b.forEach(function(y){y.parentNode&&y.remove()})},b.length*25+300))}}window.SkeletonLoader={show:s,hide:m}})(),(function(){var c="theme",s="user-themes",m="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],b=h.slice(),y=["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"],u=["fg-muted","hover","card-hover","base","success","error","warning"],l={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),m>0&&setTimeout(()=>v.remove(),m)}function timeAgo(c){const a=Date.now()-new Date(c).getTime();return a<6e4?"just now":a<36e5?Math.floor(a/6e4)+"m ago":a<864e5?Math.floor(a/36e5)+"h ago":Math.floor(a/864e5)+"d ago"}function safeGet(c,a=null){try{const m=localStorage.getItem(c);return m!==null?m:a}catch{return a}}function safeSet(c,a){try{localStorage.setItem(c,a)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,a=null){try{const m=sessionStorage.getItem(c);return m!==null?m:a}catch{return a}}function safeSessionSet(c,a){try{sessionStorage.setItem(c,a)}catch{}}function safeGetJSON(c,a=null){try{const m=localStorage.getItem(c);return m?JSON.parse(m):a}catch{return a}}function escapeHtml(c){return String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(c,a){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(c,a){var m;((m=this._handlers)[c]||(m[c]=[])).push(a)},off(c,a){this._handlers[c]=this._handlers[c]?.filter(m=>m!==a)},emit(c,a){this._handlers[c]?.forEach(m=>m(a))}},AppState={_apps:[],getApps(){return this._apps},setApps(c){this._apps=c,window.APPS=c,DC_BUS.emit("apps:changed",c)},findApp(c){return this._apps.find(a=>a.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const a=this._apps.findIndex(m=>m.id===c);return a>-1&&(this._apps.splice(a,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),a>-1},updateApp(c,a){const m=this._apps.find(h=>h.id===c);if(m){for(const[h,b]of Object.entries(a))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(m[h]=b);DC_BUS.emit("apps:changed",this._apps)}return m}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.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>',h}function a(h){const b=document.getElementById("cards");if(!(!b||b.querySelector(".card"))){h=h||6;for(let g=0;g<h;g++){const v=c();b.appendChild(v),setTimeout(function(){v.classList.add("loaded")},g*60)}}}function m(){const h=document.getElementById("cards");if(h){var b=h.querySelectorAll(".skeleton-card");b.length&&(b.forEach(function(g,v){setTimeout(function(){g.style.opacity="0",g.style.transform="translateY(-10px)"},v*25)}),setTimeout(function(){b.forEach(function(g){g.parentNode&&g.remove()})},b.length*25+300))}}window.SkeletonLoader={show:a,hide:m}})(),(function(){var c="theme",a="user-themes",m="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],b=h.slice(),g=["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"],p=["fg-muted","hover","card-hover","base","success","error","warning"],d={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%),
|
||||
radial-gradient(1200px 800px at 10% -10%, rgba(`+C.r+","+C.g+","+C.b+`, .08), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% 10%, rgba(`+C.r+","+C.g+","+C.b+`, .05), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
`:":root."+C+` body {
|
||||
`:":root."+x+` body {
|
||||
background:
|
||||
radial-gradient(1200px 900px at 8% -12%, rgba(`+x.r+","+x.g+","+x.b+`, .10), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%),
|
||||
radial-gradient(1200px 900px at 8% -12%, rgba(`+C.r+","+C.g+","+C.b+`, .10), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% -10%, rgba(`+C.r+","+C.g+","+C.b+`, .07), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
`}function i(C,B){var $=B.lightBg||B.bg&&d(B.bg)>.4;return $?":root."+C+` button:hover {
|
||||
`}function o(x,B){var A=B.lightBg||B.bg&&s(B.bg)>.4;return A?":root."+x+` 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);
|
||||
}
|
||||
`:":root."+C+` button:hover {
|
||||
`:":root."+x+` button:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function r(){y.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function f(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var E=safeGetJSON(s,{}),x=$,S=2;E[$]&&$!==B;)$=x+"-"+S++;return $}function p(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),b.length=h.length,Object.keys(l).forEach(function(T){h.indexOf(T)===-1&&delete l[T]});var $=C||safeGetJSON(s,{}),E=Object.keys($);if(E=E.filter(function(T){return h.indexOf(T)===-1}),!!E.length){var x="";E.forEach(function(T){var k=$[T];b.indexOf(T)===-1&&b.push(T);var A={};y.forEach(function(O){k[O]&&(A[O]=k[O])}),A["card-bg"]=k["card-base"]||k.bg,k.lightBg&&(A.lightBg=!0);var D=t(A);u.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),l[T]=A,x+=":root."+T+` {
|
||||
`,y.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`;
|
||||
`)}),x+=`}
|
||||
`,x+=e(T,A),x+=i(T,A)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=x,document.head.appendChild(S)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(s,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(s,JSON.stringify(B)),p(B);var E=safeGet(c);E&&b.indexOf(E)!==-1&&L(E)}}}).catch(function(){})}function I(){var C=safeGetJSON(m);if(C){var B=C.name||"Custom",$=f(B),E={name:B};y.forEach(function(T){C[T]&&(E[T]=C[T])});var x=safeGetJSON(s,{});x[$]=E,safeSet(s,JSON.stringify(x)),safeGet(c)==="custom"&&safeSet(c,$),safeRemove(m);var S={};y.forEach(function(T){E[T]&&(S[T]=E[T])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:S})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),b.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),r(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(c,C);var B=l[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=d(B.bg)>.4),E?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}I(),p();var P=safeGet(c);P==="red"&&(P="black",safeSet(c,"black")),P&&P!=="dark"&&b.indexOf(P)===-1&&(P=null),L(P||n()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(c)||L(C.matches?"dark":"light")}),window.THEMES=b,window.BUILTIN_THEMES=h,window.THEME_COLORS=l,window.THEME_PROPS=y,window.BASE_PROPS=v,window.DERIVED_PROPS=u,window.USER_THEMES_KEY=s,window.applyTheme=L,window.clearCustomProperties=r,window.injectUserThemeStyles=p,window.syncThemesFromServer=w,window.slugifyThemeName=f,window.getActiveTheme=function(){return safeGet(c)||n()},window.deriveExtendedColors=t,window.hexToRgb=a,window.rgbToHex=o,window.blendColors=g})(),(function(){let c=null;async function s(){if(c)return c;try{const a=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!a.ok)throw new Error(`methods HTTP ${a.status}`);const o=await a.json();return c=Array.isArray(o.providers)?o.providers:[],c}catch(a){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",a),[]}}function m(a){const o=document.getElementById("totp-overlay");if(!o)return;const g=o.querySelector(".totp-card");if(!g)return;const d=g.innerHTML;g.dataset.originalBody||(g.dataset.originalBody=d);const t=a.map(e=>{const i=e.config&&(e.config.label||e.name)||e.name;return`<button class="provider-btn" data-provider="${e.name}"
|
||||
`}function u(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function n(){g.forEach(function(x){document.documentElement.style.removeProperty("--"+x)})}function i(x,B){var A=x.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");A||(A="custom"),h.indexOf(A)!==-1&&(A=A+"-custom");for(var E=safeGetJSON(a,{}),C=A,T=2;E[A]&&A!==B;)A=C+"-"+T++;return A}function l(x){var B=document.getElementById("user-theme-styles");B&&B.remove(),b.length=h.length,Object.keys(d).forEach(function($){h.indexOf($)===-1&&delete d[$]});var A=x||safeGetJSON(a,{}),E=Object.keys(A);if(E=E.filter(function($){return h.indexOf($)===-1}),!!E.length){var C="";E.forEach(function($){var I=A[$];b.indexOf($)===-1&&b.push($);var P={};g.forEach(function(O){I[O]&&(P[O]=I[O])}),P["card-bg"]=I["card-base"]||I.bg,I.lightBg&&(P.lightBg=!0);var D=t(P);p.forEach(function(O){!P[O]&&D[O]&&(P[O]=D[O])}),d[$]=P,C+=":root."+$+` {
|
||||
`,g.forEach(function(O){P[O]&&(C+=" --"+O+": "+P[O]+`;
|
||||
`)}),C+=`}
|
||||
`,C+=e($,P),C+=o($,P)});var T=document.createElement("style");T.id="user-theme-styles",T.textContent=C,document.head.appendChild(T)}}function y(){secureFetch("/api/v1/themes").then(function(x){return x.json()}).then(function(x){if(!(!x.success||!x.themes)){var B=x.themes,A=safeGetJSON(a,{});if(JSON.stringify(B)!==JSON.stringify(A)){safeSet(a,JSON.stringify(B)),l(B);var E=safeGet(c);E&&b.indexOf(E)!==-1&&k(E)}}}).catch(function(){})}function S(){var x=safeGetJSON(m);if(x){var B=x.name||"Custom",A=i(B),E={name:B};g.forEach(function($){x[$]&&(E[$]=x[$])});var C=safeGetJSON(a,{});C[A]=E,safeSet(a,JSON.stringify(C)),safeGet(c)==="custom"&&safeSet(c,A),safeRemove(m);var T={};g.forEach(function($){E[$]&&(T[$]=E[$])}),fetch("/api/v1/themes/"+A,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:T})}).catch(function(){})}}function k(x){document.documentElement.classList.add("theme-transitioning"),b.forEach(function(C){C!=="dark"&&document.documentElement.classList.remove(C)}),n(),x!=="dark"&&document.documentElement.classList.add(x),safeSet(c,x);var B=d[x],A=document.querySelector('meta[name="theme-color"]');A&&B&&A.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=s(B.bg)>.4),E?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}S(),l();var L=safeGet(c);L==="red"&&(L="black",safeSet(c,"black")),L&&L!=="dark"&&b.indexOf(L)===-1&&(L=null),k(L||u()),y(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(x){safeGet(c)||k(x.matches?"dark":"light")}),window.THEMES=b,window.BUILTIN_THEMES=h,window.THEME_COLORS=d,window.THEME_PROPS=g,window.BASE_PROPS=v,window.DERIVED_PROPS=p,window.USER_THEMES_KEY=a,window.applyTheme=k,window.clearCustomProperties=n,window.injectUserThemeStyles=l,window.syncThemesFromServer=y,window.slugifyThemeName=i,window.getActiveTheme=function(){return safeGet(c)||u()},window.deriveExtendedColors=t,window.hexToRgb=r,window.rgbToHex=f,window.blendColors=w})(),(function(){let c=null;async function a(){if(c)return c;try{const w=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!w.ok)throw new Error(`methods HTTP ${w.status}`);const s=await w.json();return c=Array.isArray(s.providers)?s.providers:[],c}catch(w){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",w),[]}}function m(w){const s=document.getElementById("totp-overlay");if(!s)return;const t=s.querySelector(".totp-card");if(!t)return;const e=t.innerHTML;t.dataset.originalBody||(t.dataset.originalBody=e);const o=w.map(u=>{const n=u.config&&(u.config.label||u.name)||u.name;return`<button class="provider-btn" data-provider="${u.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 ${i}
|
||||
Sign in with ${n}
|
||||
</button>`}).join(`
|
||||
`);g.innerHTML=`
|
||||
`);t.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;">${t}</div>
|
||||
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${o}</div>
|
||||
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
|
||||
`,o.classList.add("show"),g.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const i=e.dataset.provider,n=a.find(r=>r.name===i);b(n)})})}function h(){const a=document.getElementById("totp-overlay");if(!a)return;const o=a.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function b(a){const o=document.getElementById("totp-overlay");if(!o)return;const g=o.querySelector(".totp-card");if(g){if(a.name==="totp"){window.location.reload();return}if(a.name==="email"){g.innerHTML=`
|
||||
`,s.classList.add("show"),t.querySelectorAll(".provider-btn").forEach(u=>{u.addEventListener("click",()=>{const n=u.dataset.provider,i=w.find(l=>l.name===n);b(i)})})}function h(){const w=document.getElementById("totp-overlay");if(!w)return;const s=w.querySelector(".totp-card");!s||!s.dataset.originalBody||(s.innerHTML=s.dataset.originalBody,window.location.reload())}function b(w){const s=document.getElementById("totp-overlay");if(!s)return;const t=s.querySelector(".totp-card");if(t){if(w.name==="totp"){window.location.reload();return}if(w.name==="email"){t.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>
|
||||
`,o.classList.add("show");const d=g.querySelector("#auth-gate-email-input"),t=g.querySelector("#auth-gate-email-submit"),e=g.querySelector("#auth-gate-email-status"),i=g.querySelector("#auth-gate-back");t.addEventListener("click",async()=>{const n=(d.value||"").trim();if(!n||!n.includes("@")){e.textContent="Enter a valid email address.",e.style.color="var(--error, #d33)";return}t.disabled=!0,e.textContent="Sending\u2026",e.style.color="";try{const f=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})})).json();if(f.success){const p=f.deliveredVia||"email",w=f.maskedEmail||n;p==="dev-console"?e.innerHTML=`
|
||||
`,s.classList.add("show");const e=t.querySelector("#auth-gate-email-input"),o=t.querySelector("#auth-gate-email-submit"),u=t.querySelector("#auth-gate-email-status"),n=t.querySelector("#auth-gate-back");o.addEventListener("click",async()=>{const i=(e.value||"").trim();if(!i||!i.includes("@")){u.textContent="Enter a valid email address.",u.style.color="var(--error, #d33)";return}o.disabled=!0,u.textContent="Sending\u2026",u.style.color="";try{const y=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:i})})).json();if(y.success){const S=y.deliveredVia||"email",k=y.maskedEmail||i;S==="dev-console"?u.innerHTML=`
|
||||
Check the server logs for your one-time link.
|
||||
(Dev mode: no SMTP configured. In production this would email <strong>${w}</strong>.)`:e.innerHTML=`Sign-in link sent to <strong>${w}</strong>.
|
||||
Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=f.error||"Could not send link.",e.style.color="var(--error, #d33)",t.disabled=!1}catch{e.textContent="Connection error. Try again.",e.style.color="var(--error, #d33)",t.disabled=!1}}),d.addEventListener("keydown",n=>{n.key==="Enter"&&t.click()}),i.addEventListener("click",n=>{n.preventDefault(),m(providers)});return}console.warn("[auth-gate] unknown provider",a.name),window.location.reload()}}async function y(){if(!document.getElementById("totp-overlay"))return;const o=await s();if(o.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(o.length===1&&o[0].name==="totp"){v(o[0]);return}m(o)}function v(a){const o=c&&c.find(e=>e.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const g=document.getElementById("totp-overlay"),d=g.querySelector(".totp-card");if(!d)return;if(d.dataset.originalBody||(d.dataset.originalBody=d.innerHTML),!d.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"
|
||||
(Dev mode: no SMTP configured. In production this would email <strong>${k}</strong>.)`:u.innerHTML=`Sign-in link sent to <strong>${k}</strong>.
|
||||
Check your inbox (and spam folder).`,u.style.color="var(--success, #2a7)"}else u.textContent=y.error||"Could not send link.",u.style.color="var(--error, #d33)",o.disabled=!1}catch{u.textContent="Connection error. Try again.",u.style.color="var(--error, #d33)",o.disabled=!1}}),e.addEventListener("keydown",i=>{i.key==="Enter"&&o.click()}),n.addEventListener("click",i=>{i.preventDefault(),m(providers)});return}console.warn("[auth-gate] unknown provider",w.name),window.location.reload()}}async function g(){if(!document.getElementById("totp-overlay"))return;const s=await a();if(s.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(s.length===1&&s[0].name==="totp"){v(s[0]);return}m(s)}function v(w){const s=c&&c.find(u=>u.name==="email");if(!s){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const t=document.getElementById("totp-overlay"),e=t.querySelector(".totp-card");if(!e)return;if(e.dataset.originalBody||(e.dataset.originalBody=e.innerHTML),!e.querySelector("#auth-gate-email-alt")){const u=document.createElement("div");u.id="auth-gate-email-alt",u.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",u.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
|
||||
style="color: var(--accent); text-decoration: none;">
|
||||
Or sign in with email instead \u2192
|
||||
</a>`,d.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",i=>{i.preventDefault(),b(o)})}g.classList.add("show")}window.__dc_049_handled=!0;function u(a){try{const o=new URL(a,window.location.origin);if(!["http:","https:"].includes(o.protocol))return!1;if(o.origin===window.location.origin)return!0;if(o.protocol!=="https:")return!1;const g=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===g.slice(1)||o.hostname.endsWith(g)}catch{return!1}}const l=new URLSearchParams(window.location.search);if(l.get("auth")==="required"){const a=l.get("return");if(a&&u(a))try{sessionStorage.setItem("totp_redirect",a)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(y,0)}window._showAuthGate=y,window._authGateMethods=s})(),(function(){function c(){const u=document.querySelector(".totp-card");if(!u)return;const a=getComputedStyle(u).backgroundColor.match(/\d+/g);if(!a)return;const o=(.299*+a[0]+.587*+a[1]+.114*+a[2])/255,g=u.querySelector(".totp-logo-dark"),d=u.querySelector(".totp-logo-light");g&&(g.style.display=o>.5?"none":""),d&&(d.style.display=o>.5?"":"none")}function s(){const u=document.getElementById("totp-overlay");if(u){u.classList.add("show"),setTimeout(c,50);const l=u.querySelector(".totp-digits input");l&&setTimeout(()=>l.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function m(){const u=document.getElementById("totp-overlay");u&&u.classList.remove("show")}function h(u,l){const a=new URL(u,window.location.origin);if(a.origin===window.location.origin)return a.toString();const o=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,g=a.hostname===o.slice(1)||a.hostname.endsWith(o);if(a.protocol!=="https:"||!g)return null;if(!l)return a.toString();const d=`${a.pathname}${a.search}${a.hash}`;return a.pathname="/dashcaddy-sso",a.search="",a.hash="",a.searchParams.set("token",l),a.searchParams.set("return",d),a.toString()}const b=document.getElementById("totp-digits");if(b){const u=b.querySelectorAll("input");u.forEach((l,a)=>{l.addEventListener("input",o=>{const g=o.target.value.replace(/\D/g,"");o.target.value=g.slice(0,1),g&&a<u.length-1&&u[a+1].focus();const d=Array.from(u).map(t=>t.value).join("");d.length===6&&y(d)}),l.addEventListener("keydown",o=>{o.key==="Backspace"&&!o.target.value&&a>0&&(u[a-1].focus(),u[a-1].value="")}),l.addEventListener("paste",o=>{o.preventDefault();const g=(o.clipboardData.getData("text")||"").replace(/\D/g,"");g.length>=6&&(u.forEach((d,t)=>{d.value=g[t]||""}),u[5].focus(),y(g.slice(0,6)))})})}async function y(u){const l=document.getElementById("totp-error");l.textContent="Verifying...",l.className="totp-error verifying";try{const o=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:u})})).json();if(o.success){l.textContent="",o.csrfToken&&(csrfToken=o.csrfToken),m();const g=safeSessionGet("totp_redirect");if(g){try{sessionStorage.removeItem("totp_redirect")}catch{}const d=h(g,o.ssoToken);if(!d)return;window.location.href=d;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard(),window._dcAuthLost=!1,typeof window._sseResume=="function"?window._sseResume():typeof window._sseReconnect=="function"&&window._sseReconnect(),typeof window.refreshAll=="function"&&window.refreshAll()}else{l.textContent=o.error||"Invalid code",l.className="totp-error";const g=document.querySelectorAll("#totp-digits input");g.forEach(d=>{d.value=""}),g[0]?.focus()}}catch{l.textContent="Connection error",l.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const u=urlParams.get("return");if(u)try{const l=new URL(u,window.location.origin),a=l.hostname,o=l.origin===window.location.origin,g=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,d=a.endsWith(g)||a===g.substring(1);(o||d)&&safeSessionSet("totp_redirect",u)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=s})(),(function(){"use strict";async function c(){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 s(v){const u=document.getElementById("totp-recovery-link");u&&(u.style.display=v?"":"none")}function m(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="");const u=document.getElementById("totp-recovery-status"),l=document.getElementById("totp-recovery-import"),a=document.getElementById("totp-recovery-verify");l&&(l.style.display=""),a&&(a.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="",c().then(o=>{u.textContent=o.hint||"",o.status==="healthy"?u.style.borderColor="var(--ok-fg, #7ef2ff)":o.sLine truncated
|
||||
</a>`,e.appendChild(u),u.querySelector("#auth-gate-email-alt-link").addEventListener("click",n=>{n.preventDefault(),b(s)})}t.classList.add("show")}window.__dc_049_handled=!0;function p(w){try{const s=new URL(w,window.location.origin);if(!["http:","https:"].includes(s.protocol))return!1;if(s.origin===window.location.origin)return!0;if(s.protocol!=="https:")return!1;const t=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return s.hostname===t.slice(1)||s.hostname.endsWith(t)}catch{return!1}}function d(w,s){const t=new URL(w,window.location.origin);if(t.origin===window.location.origin)return t.toString();const e=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,o=t.hostname===e.slice(1)||t.hostname.endsWith(e);if(t.protocol!=="https:"||!o||!s)return null;const u=`${t.pathname}${t.search}${t.hash}`;return t.pathname="/dashcaddy-sso",t.search="",t.hash="",t.searchParams.set("token",s),t.searchParams.set("return",u),t.toString()}async function r(w){if(!w||!p(w))return!1;try{const s=new URL(w,window.location.origin),t=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,e=s.hostname.slice(0,-t.length);if(!/^[a-z0-9][a-z0-9-]*$/.test(e))return!1;const o=await fetch(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(e)}`,{credentials:"include",cache:"no-store"});if(!o.ok)return!1;const u=await o.json(),n=u.success&&d(w,u.ssoToken);if(!n)return!1;try{sessionStorage.removeItem("totp_redirect")}catch{}return window.location.replace(n),!0}catch{return!1}}const f=new URLSearchParams(window.location.search);if(f.get("auth")==="required"){const w=f.get("return");if(w&&p(w))try{sessionStorage.setItem("totp_redirect",w)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(async()=>{await r(w)||await g()},0)}window._showAuthGate=g,window._authGateMethods=a})(),(function(){function c(){const p=document.querySelector(".totp-card");if(!p)return;const r=getComputedStyle(p).backgroundColor.match(/\d+/g);if(!r)return;const f=(.299*+r[0]+.587*+r[1]+.114*+r[2])/255,w=p.querySelector(".totp-logo-dark"),s=p.querySelector(".totp-logo-light");w&&(w.style.display=f>.5?"none":""),s&&(s.style.display=f>.5?"":"none")}function a(){const p=document.getElementById("totp-overlay");if(p){p.classList.add("show"),setTimeout(c,50);const d=p.querySelector(".totp-digits input");d&&setTimeout(()=>d.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function m(){const p=document.getElementById("totp-overlay");p&&p.classList.remove("show")}function h(p,d){const r=new URL(p,window.location.origin);if(r.origin===window.location.origin)return r.toString();const f=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,w=r.hostname===f.slice(1)||r.hostname.endsWith(f);if(r.protocol!=="https:"||!w)return null;if(!d)return r.toString();const s=`${r.pathname}${r.search}${r.hash}`;return r.pathname="/dashcaddy-sso",r.search="",r.hash="",r.searchParams.set("token",d),r.searchParams.set("return",s),r.toString()}const b=document.getElementById("totp-digits");if(b){const p=b.querySelectorAll("input");p.forEach((d,r)=>{d.addEventListener("input",f=>{const w=f.target.value.replace(/\D/g,"");f.target.value=w.slice(0,1),w&&r<p.length-1&&p[r+1].focus();const s=Array.from(p).map(t=>t.value).join("");s.length===6&&g(s)}),d.addEventListener("keydown",f=>{f.key==="Backspace"&&!f.target.value&&r>0&&(p[r-1].focus(),p[r-1].value="")}),d.addEventListener("paste",f=>{f.preventDefault();const w=(f.clipboardData.getData("text")||"").replace(/\D/g,"");w.length>=6&&(p.forEach((s,t)=>{s.value=w[t]||""}),p[5].focus(),g(w.slice(0,6)))})})}async function g(p){const d=document.getElementById("totp-error");d.textContent="Verifying...",d.className="totp-error verifying";const r=safeSessionGet("totp_redirect");let f=null;if(r)try{const w=new URL(r,window.location.origin),s=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,t=w.hostname.slice(0,-s.length);w.hostname.endsWith(s)&&/^[a-z0-9][a-z0-9-]*$/.test(t)&&(f=t)}catch{}try{const s=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:p,serviceId:f})})).json();if(s.success){if(d.textContent="",s.csrfToken&&(csrfToken=s.csrfToken),m(),r){try{sessionStorage.removeItem("totp_redirect")}catch{}const t=h(r,s.ssoToken);if(!t)return;window.location.href=t;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard(),window._dcAuthLost=!1,typeof window._sseResume=="function"?window._sseResume():typeof window._sseReconnect=="function"&&window._sseReconnect(),typeof window.refreshAll=="function"&&window.refreshAll()}else{d.textContent=s.error||"Invalid code",d.className="totp-error";const t=document.querySelectorAll("#totp-digits input");t.forEach(e=>{e.value=""}),t[0]?.focus()}}catch{d.textContent="Connection error",d.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const p=urlParams.get("return");if(p)try{const d=new URL(p,window.location.origin)Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
|
||||
<h3>\u{1F4C2} Browse for Media Folders</h3>
|
||||
|
||||
@@ -110,8 +110,8 @@
|
||||
</div>
|
||||
</div>`),injectModal("service-creds-modal",`<div id="service-creds-modal">
|
||||
<div class="service-creds-content">
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||
@@ -169,7 +169,7 @@
|
||||
<!-- Buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||
Save
|
||||
Save to encrypted vault
|
||||
</button>
|
||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||
Clear
|
||||
@@ -179,7 +179,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const s=document.getElementById("service-creds-modal");let m=null;const h=["sonarr","radarr","prowlarr","overseerr"],b=["sonarr","radarr"];function y(o){return o.externalUrl||o.url||""}function v(o){const g=document.getElementById("svc-creds-error");g.textContent=o,g.style.display=""}function u(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){m=o,u();const g=document.getElementById("svc-creds-title"),d=document.getElementById("svc-creds-desc"),t=document.getElementById("svc-creds-seedhost"),e=document.getElementById("svc-creds-apikey"),i=document.getElementById("svc-creds-basic"),n=document.getElementById("svc-creds-quality");g.textContent=o.name+" Credentials";const r=!!o.isExternal,f=h.includes(o.id)||h.includes(o.appTemplate),p=b.includes(o.id)||b.includes(o.appTemplate);t.style.display=r?"":"none",e.style.display=f?"":"none",n.style.display=p?"":"none",i.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?(d.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):f?d.textContent="API key bypasses the app login screen automatically.":d.textContent="Credentials are injected automatically when accessing this service.",await l(o),s.classList.add("show")};async function l(o){const g=document.getElementById("svc-creds-dot"),d=document.getElementById("svc-creds-status"),t=document.getElementById("svc-creds-clear");let e=!1;if(o.isExternal){try{const r=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();r.success?(document.getElementById("svc-seedhost-user").value=r.username||"",r.hasCredentials&&(e=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const r=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();r.success&&(r.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",e=!0):document.getElementById("svc-apikey-input").value="",r.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=r.username||"",e=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const i=o.id||o.appTemplate;if(b.includes(i)&&await a(o),e){g.style.background="var(--ok-fg, #74dfc4)",d.style.color="var(--ok-fg, #74dfc4)",d.textContent="Credentials stored",t.style.display="";const n=document.getElementById(`creds-btn-${o.id}`);n&&n.classList.add("has-creds")}else g.style.background="var(--muted)",d.style.color="var(--muted)",d.textContent="No credentials stored",t.style.display="none"}async function a(o){const g=document.getElementById("svc-quality-select"),d=document.getElementById("svc-quality-status"),t=o.id||o.appTemplate,e=y(o);if(!e){g.innerHTML='<option value="">-- No service URL --</option>';return}g.innerHTML='<option value="">Loading...</option>',d.textContent="";try{const i=new URLSearchParams({service:t,url:e}),r=await(await fetch(`/api/v1/arr/quality-profiles?${i}`)).json();if(!r.success||!r.profiles?.length){g.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}g.innerHTML="";for(const f of r.profiles){const p=document.createElement("option");p.value=f.id,p.textContent=f.name,g.appendChild(p)}if(r.storedProfileId&&(g.value=String(r.storedProfileId)),!g.value){const f=r.profiles.find(p=>/720/i.test(p.name));f&&(g.value=String(f.id))}!g.value&&r.profiles.length&&(g.value=String(r.profiles[0].id)),d.innerHTML=`<span style="color: var(--ok-fg);">${r.profiles.length} profiles loaded</span>`}catch(i){g.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(!m)return;const o=m.id||m.appTemplate,g=y(m),t=document.getElementById("svc-apikey-input")?.value.trim(),e=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status");if(!g){i.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!t||t==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){i.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}e.innerHTML='<option value="">Fetching...</option>',i.textContent="";try{const n=new URLSearchParams({service:o,url:g,apiKey:t}),f=await(await fetch(`/api/v1/arr/quality-profiles?${n}`)).json();if(!f.success){e.innerHTML='<option value="">-- Error --</option>',i.innerHTML=`<span style="color: var(--error, #c62828);">${f.error||"Failed to feLine truncated
|
||||
</div>`);const a=document.getElementById("service-creds-modal");let m=null,h=null,b=!1;const g=["sonarr","radarr","prowlarr","overseerr"],v=["sonarr","radarr"];function p(e){return e.externalUrl||e.url||""}function d(e){const o=document.getElementById("svc-creds-error");o.textContent=e,o.style.display=""}function r(){const e=document.getElementById("svc-creds-error");e.textContent="",e.style.display="none"}async function f(e,o){if(!e)throw new Error(`${o} failed: no response`);let u;try{u=await e.json()}catch{throw new Error(`${o} failed: invalid server response`)}if(!e.ok||u?.success!==!0)throw new Error(u?.error||`${o} failed (${e.status||"unknown status"})`);return u}function w(e,o){return!!window.DCCredentialVault?.isAllowedReturnUrl(e,o)}window.openServiceCredsModal=async function(e,o={}){m=e,h=w(o.returnUrl,e.id)?o.returnUrl:null,b=!1,r();const u=document.getElementById("svc-creds-title"),n=document.getElementById("svc-creds-desc"),i=document.getElementById("svc-creds-seedhost"),l=document.getElementById("svc-creds-apikey"),y=document.getElementById("svc-creds-basic"),S=document.getElementById("svc-creds-quality");u.textContent=e.name+" \u2014 Encrypted Vault",document.getElementById("svc-creds-save").textContent=h?"Save to vault & open service":"Save to encrypted vault";const k=!!e.isExternal,L=g.includes(e.id)||g.includes(e.appTemplate),x=v.includes(e.id)||v.includes(e.appTemplate);i.style.display=k?"":"none",l.style.display=L?"":"none",S.style.display=x?"":"none",y.style.display=k?"none":"";const B=document.getElementById("svc-quality-select");B.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",k?(n.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${e.name}`):L?n.textContent="API key bypasses the app login screen automatically.":n.textContent="Credentials are injected automatically when accessing this service.",await s(e),a.classList.add("show")};async function s(e){const o=document.getElementById("svc-creds-dot"),u=document.getElementById("svc-creds-status"),n=document.getElementById("svc-creds-clear");let i=!1;if(e.isExternal){try{const S=await(await fetch(`/api/v1/seedhost-creds?serviceId=${e.id}`)).json();S.success?(document.getElementById("svc-seedhost-user").value=S.username||"",S.hasCredentials&&(i=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const S=await(await fetch(`/api/v1/services/${e.id}/credentials`)).json();S.success&&(S.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",i=!0):document.getElementById("svc-apikey-input").value="",S.hasBasicAuth&&!e.isExternal?(document.getElementById("svc-basic-user").value=S.username||"",i=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const l=e.id||e.appTemplate;if(v.includes(l)&&await t(e),i){b=!0,o.style.background="var(--ok-fg, #74dfc4)",u.style.color="var(--ok-fg, #74dfc4)",u.textContent="Credentials stored",n.style.display="";const y=document.getElementById(`creds-btn-${e.id}`);y&&y.classList.add("has-creds")}else o.style.background="var(--muted)",u.style.color="var(--muted)",u.textContent="No credentials stored",n.style.display="none"}async function t(e){const o=document.getElementById("svc-quality-select"),u=document.getElementById("svc-quality-status"),n=e.id||e.appTemplate,i=p(e);if(!i){o.innerHTML='<option value="">-- No service URL --</option>';return}o.innerHTML='<option value="">Loading...</option>',u.textContent="";try{const l=new URLSearchParams({service:n,url:i}),S=await(await fetch(`/api/v1/arr/quality-profiles?${l}`)).json();if(!S.success||!S.profiles?.length){o.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}o.innerHTML="";for(const k of S.profiles){const L=document.createElement("option");L.value=k.id,L.textContent=k.name,o.appendChild(L)}if(S.storedProfileId&&(o.value=String(S.storedProfileId)),!o.value){const k=S.profiles.find(L=>/720/i.test(L.name));k&&(o.value=String(k.id))}!o.value&&S.profiles.length&&(o.value=String(S.profiles[0].id)),u.innerHTML=`<span style="color: var(--ok-fg);">${S.profiles.length} profiles loaded</span>`}catch(l){o.innerHTML='<option value="">-- Failed to load --</option>',u.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${l.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!m)return;const e=m.id||m.appTemplate,o=p(m),n=document.getElementById("svc-apikey-input")?.value.trim(),i=document.getElementById("svc-quality-select"),l=document.getElementById("svc-quality-status");if(!o){l.innerHTML='<span style="color: var(--error, #c62828);">No servLine 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 s(){try{const v=await(await fetch("/api/v1/totp/config")).json();if(!v.success)return;const{enabled:u,sessionDuration:l,isSetUp:a}=v.config,o=document.getElementById("totp-status-dot"),g=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),t=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),i=document.getElementById("totp-duration-section"),n=document.getElementById("totp-disable-section");if(u&&a){o.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",g.textContent="TOTP is active",g.style.color="var(--ok-fg, #7ef2ff)",t.style.display="block";const r=document.getElementById("totp-setup-btn");r&&(r.textContent="Generate New Secret"),e.style.display="none",i.style.display="block",n.style.display="block",document.getElementById("totp-duration-select").value=l}else o.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",g.textContent="TOTP is not configured",g.style.color="var(--muted)",t.style.display="block",e.style.display="none",i.style.display="none",n.style.display="none";b(u&&a,l)}catch(y){console.warn("Failed to load TOTP settings:",y)}}const m={"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 h(y,v){const u=window.DCI18n;return u&&u.isLoaded()?u.t(y):v}function b(y,v){const u=document.getElementById("auth-card"),l=document.getElementById("auth-pill"),a=document.getElementById("auth-dot"),o=document.getElementById("auth-status-text");u&&(y?(u.setAttribute("data-status","on"),l.className="badge on",l.textContent=h("card.status.yes","YES"),a.className="dot ok at-bl",o.textContent="Session: "+(m[v]||v)):(u.setAttribute("data-status","off"),l.className="badge off",l.textContent=h("card.status.no","NO"),a.className="dot bad at-bl",o.textContent=h("card.auth.not_configured","Not configured")))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const v=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();v.success&&(document.getElementById("totp-qr-image").src=v.qrCode,document.getElementById("totp-manual-key").textContent=v.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(y){c.logError("[TOTP] Setup Failed",y,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const y=document.getElementById("totp-import-key").value.trim(),v=document.getElementById("totp-import-error");if(v.textContent="",!y){v.textContent="Paste a Base32 secret key first";return}try{const l=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:y})})).json();l.success?(v.textContent="",document.getElementById("totp-qr-image").src=l.qrCode,document.getElementById("totp-manual-key").textContent=l.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()):v.textContent=l.error||l.message||"Import failed"}catch{v.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const y=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(y).then(()=>{const v=document.getElementById("totp-copy-key");v.textContent="\u2705",setTimeout(()=>{v.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const y=document.getElementById("totp-manual-key").textContent.trim();if(!y)return;const v={service:"DashCaddy",type:"totp-secret",secret:y,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.'},u=new Blob([JSON.stringify(v,null,2)],{type:"application/json"}),l=URL.createObjectURL(u),a=document.createElement("a");a.href=l,a.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(l);const o=document.getElementById("totp-download-backup");o.textContent="\u2705 Saved",setTimeLine truncated
|
||||
</div>`);async function a(){try{const v=await(await fetch("/api/v1/totp/config")).json();if(!v.success)return;const{enabled:p,sessionDuration:d,isSetUp:r}=v.config,f=document.getElementById("totp-status-dot"),w=document.getElementById("totp-status-text"),s=document.getElementById("totp-status-banner"),t=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),o=document.getElementById("totp-duration-section"),u=document.getElementById("totp-disable-section");if(p&&r){f.style.background="var(--ok-fg, #7ef2ff)",s.style.borderColor="var(--ok-fg, #7ef2ff)",s.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",w.textContent="TOTP is active",w.style.color="var(--ok-fg, #7ef2ff)",t.style.display="block";const n=document.getElementById("totp-setup-btn");n&&(n.textContent="Generate New Secret"),e.style.display="none",o.style.display="block",u.style.display="block",document.getElementById("totp-duration-select").value=d}else f.style.background="var(--muted)",s.style.borderColor="var(--border)",s.style.background="transparent",w.textContent="TOTP is not configured",w.style.color="var(--muted)",t.style.display="block",e.style.display="none",o.style.display="none",u.style.display="none";b(p&&r,d)}catch(g){console.warn("Failed to load TOTP settings:",g)}}const m={"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 h(g,v){const p=window.DCI18n;return p&&p.isLoaded()?p.t(g):v}function b(g,v){const p=document.getElementById("auth-card"),d=document.getElementById("auth-pill"),r=document.getElementById("auth-dot"),f=document.getElementById("auth-status-text");p&&(g?(p.setAttribute("data-status","on"),d.className="badge on",d.textContent=h("card.status.yes","YES"),r.className="dot ok at-bl",f.textContent="Session: "+(m[v]||v)):(p.setAttribute("data-status","off"),d.className="badge off",d.textContent=h("card.status.no","NO"),r.className="dot bad at-bl",f.textContent=h("card.auth.not_configured","Not configured")))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const v=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();v.success&&(document.getElementById("totp-qr-image").src=v.qrCode,document.getElementById("totp-manual-key").textContent=v.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(g){c.logError("[TOTP] Setup Failed",g,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const g=document.getElementById("totp-import-key").value.trim(),v=document.getElementById("totp-import-error");if(v.textContent="",!g){v.textContent="Paste a Base32 secret key first";return}try{const d=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:g})})).json();d.success?(v.textContent="",document.getElementById("totp-qr-image").src=d.qrCode,document.getElementById("totp-manual-key").textContent=d.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()):v.textContent=d.error||d.message||"Import failed"}catch{v.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const g=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(g).then(()=>{const v=document.getElementById("totp-copy-key");v.textContent="\u2705",setTimeout(()=>{v.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const g=document.getElementById("totp-manual-key").textContent.trim();if(!g)return;const v={service:"DashCaddy",type:"totp-secret",secret:g,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.'},p=new Blob([JSON.stringify(v,null,2)],{type:"application/json"}),d=URL.createObjectURL(p),r=document.createElement("a");r.href=d,r.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(r),r.click(),document.body.removeChild(r),URL.revokeObjectURL(d);const f=document.getElementById("totp-download-backup");f.textContent="\u2705 Saved",setTimeLine 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,40 +306,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);function c(){return Object.keys(SITE.dnsServers||{})}function s(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function m(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const r=c();if(r.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const f of r)n.insertAdjacentHTML("beforeend",`
|
||||
`);function c(){return Object.keys(SITE.dnsServers||{})}function a(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}async function m(n,i){if(!n)throw new Error(`${i} failed: no response`);let l;try{l=await n.json()}catch{throw new Error(`${i} failed: invalid server response`)}if(!n.ok||l?.success!==!0)throw new Error(l?.error||`${i} failed (${n.status||"unknown status"})`);return l}function h(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const i=c();if(i.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const l of i)n.insertAdjacentHTML("beforeend",`
|
||||
<div class="token-section">
|
||||
<h4 class="token-section-title">${s(f)}</h4>
|
||||
<h4 class="token-section-title">${a(l)}</h4>
|
||||
<div class="token-grid">
|
||||
<div class="token-field">
|
||||
<label for="${f}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="${f}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<label for="${l}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="${l}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="${f}-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${f}-readonly-token">\u{1F441}</button>
|
||||
<input type="password" id="${l}-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${l}-readonly-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-field">
|
||||
<label for="${f}-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="${f}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<label for="${l}-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="${l}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="${f}-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${f}-admin-token">\u{1F441}</button>
|
||||
<input type="password" id="${l}-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${l}-admin-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-status" id="${f}-token-status"></div>
|
||||
<div class="token-status" id="${l}-token-status"></div>
|
||||
</div>
|
||||
`)}function h(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const r=safeGet("dashcaddy-encryption-key");if(r)return safeSessionSet("dashcaddy-encryption-key",r),safeRemove("dashcaddy-encryption-key"),r;const f=new Uint8Array(32);return crypto.getRandomValues(f),n=Array.from(f,p=>p.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const b=h();function y(n,r){if(!n)return"";const f=crypto.getRandomValues(new Uint8Array(8)),p=Array.from(f,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(r+p);let I="";for(let L=0;L<n.length;L++){const P=n.charCodeAt(L)^w[L%w.length]^f[L%f.length]^L*31+17&255;I+=String.fromCharCode(P)}return p+":"+btoa(I)}function v(n,r){if(!n)return"";try{if(n.indexOf(":")===16){const I=n.substring(0,16),L=new Uint8Array(I.match(/.{2}/g).map($=>parseInt($,16))),P=atob(n.substring(17)),C=new TextEncoder().encode(r+I);let B="";for(let $=0;$<P.length;$++){const E=P.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(E)}return B}const p=atob(n);let w="";for(let I=0;I<p.length;I++){const L=p.charCodeAt(I)^r.charCodeAt(I%r.length);w+=String.fromCharCode(L)}return w}catch{return""}}function u(n,r,f){const p=safeGet(`${n}-${r}-${f}-enc`);return v(p,b)}function l(n,r,f,p){const w=`${n}-${r}-${f}-enc`;p?safeSet(w,y(p,b)):safeRemove(w)}function a(n,r){return u(n,r,"token")}function o(n,r){return u(n,r,"username")}function g(n,r,f){l(n,r,"token",f)}function d(n,r,f){l(n,r,"username",f)}function t(){const n={};for(const r of c())n[r]={readonly:{username:o(r,"readonly"),token:a(r,"readonly")},admin:{username:o(r,"admin"),token:a(r,"admin")}};return n}function e(){c().forEach(n=>{["readonly","admin"].forEach(r=>{["token","username"].forEach(f=>{safeRemove(`${n}-${r}-${f}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function i(n){const r=a(n,"readonly"),f=o(n,"readonly"),p=a(n,"admin"),w=o(n,"admin"),I=v(safeGet(`${n}-token-enc`),b),L=v(safeGet(`${n}-username-enc`),b);return{username:w||f||L,token:p||r||I,readonlyToken:r||I,readonlyUsername:f||L,adminToken:p||I,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{m();const n=document.getElementById("token-management-modal"),r=t();c().forEach(f=>{const p=r[f];document.getElementById(`${f}-readonly-username`).value=p.readonly.username,document.getElementById(`${f}-readonly-token`).value=p.readonly.token,document.getElementById(`${f}-admin-username`).value=p.admin.username,document.getElementById(`${f}-admin-token`).value=p.admin.token,document.getElementById(`${f}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const r=n.target.closest(".token-toggle");if(r){const f=r.dataset.target,p=document.getElementById(f);p.type==="password"?(p.type="text",r.textContent="\u{1F648}"):(p.type="password",r.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=c();n.forEach(p=>{d(p,"readonly",document.getElementById(`${p}-readonly-username`).value.trim()),g(p,"readonly",document.getElementById(`${p}-readonly-token`).value.trim()),d(p,"admin",document.getElementById(`${p}-admin-username`).value.trim()),g(p,"admin",document.getElementById(`${p}-admin-token`).value.trim())});const r={};let f=!1;if(n.forEach(p=>{const w={},I=document.getElementById(`${p}-readonly-username`).value.trim(),L=document.getElementById(`${p}-readonly-token`).value.trim(),P=document.getElementById(`${p}-admin-username`).value.trim(),C=document.getElementById(`${p}-admin-token`).value.trim();I&&L&&(w.readonly={username:I,password:L},f=!0),P&&C&&(w.admin={username:P,password:C},f=!0),Object.keys(w).length>0&&(r[p]=w)}),f){n.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?n.forEach(I=>{const L=document.getElementById(`${I}-token-status`);if(!r[I]){L.textContent="";return}const P=w.results[I];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?n.forEach(I=>{r[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2713 Saved",document.getElementById(`${I}-token-status`).className="token-status success")}):n.forEach(I=>{r[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${I}-token-status`).className="token-status error")})}catch(p){console.erro
Line truncated
|
||||
`)}function b(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const i=safeGet("dashcaddy-encryption-key");if(i)return safeSessionSet("dashcaddy-encryption-key",i),safeRemove("dashcaddy-encryption-key"),i;const l=new Uint8Array(32);return crypto.getRandomValues(l),n=Array.from(l,y=>y.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const g=b();function v(n,i){if(!n)return"";const l=crypto.getRandomValues(new Uint8Array(8)),y=Array.from(l,L=>L.toString(16).padStart(2,"0")).join(""),S=new TextEncoder().encode(i+y);let k="";for(let L=0;L<n.length;L++){const x=n.charCodeAt(L)^S[L%S.length]^l[L%l.length]^L*31+17&255;k+=String.fromCharCode(x)}return y+":"+btoa(k)}function p(n,i){if(!n)return"";try{if(n.indexOf(":")===16){const k=n.substring(0,16),L=new Uint8Array(k.match(/.{2}/g).map(E=>parseInt(E,16))),x=atob(n.substring(17)),B=new TextEncoder().encode(i+k);let A="";for(let E=0;E<x.length;E++){const C=x.charCodeAt(E)^B[E%B.length]^L[E%L.length]^E*31+17&255;A+=String.fromCharCode(C)}return A}const y=atob(n);let S="";for(let k=0;k<y.length;k++){const L=y.charCodeAt(k)^i.charCodeAt(k%i.length);S+=String.fromCharCode(L)}return S}catch{return""}}function d(n,i,l){const y=safeGet(`${n}-${i}-${l}-enc`);return p(y,g)}function r(n,i,l,y){const S=`${n}-${i}-${l}-enc`;y?safeSet(S,v(y,g)):safeRemove(S)}function f(n,i){return d(n,i,"token")}function w(n,i){return d(n,i,"username")}function s(n,i,l){r(n,i,"token",l)}function t(n,i,l){r(n,i,"username",l)}function e(){const n={};for(const i of c())n[i]={readonly:{username:w(i,"readonly"),token:f(i,"readonly")},admin:{username:w(i,"admin"),token:f(i,"admin")}};return n}function o(){c().forEach(n=>{["readonly","admin"].forEach(i=>{["token","username"].forEach(l=>{safeRemove(`${n}-${i}-${l}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function u(n){const i=f(n,"readonly"),l=w(n,"readonly"),y=f(n,"admin"),S=w(n,"admin"),k=p(safeGet(`${n}-token-enc`),g),L=p(safeGet(`${n}-username-enc`),g);return{username:S||l||L,token:y||i||k,readonlyToken:i||k,readonlyUsername:l||L,adminToken:y||k,adminUsername:S||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{h();const n=document.getElementById("token-management-modal"),i=e();c().forEach(l=>{const y=i[l];document.getElementById(`${l}-readonly-username`).value=y.readonly.username,document.getElementById(`${l}-readonly-token`).value=y.readonly.token,document.getElementById(`${l}-admin-username`).value=y.admin.username,document.getElementById(`${l}-admin-token`).value=y.admin.token,document.getElementById(`${l}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const i=n.target.closest(".token-toggle");if(i){const l=i.dataset.target,y=document.getElementById(l);y.type==="password"?(y.type="text",i.textContent="\u{1F648}"):(y.type="password",i.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=c(),i={};let l=!1;if(n.forEach(y=>{const S={},k=document.getElementById(`${y}-readonly-username`).value.trim(),L=document.getElementById(`${y}-readonly-token`).value.trim(),x=document.getElementById(`${y}-admin-username`).value.trim(),B=document.getElementById(`${y}-admin-token`).value.trim();k&&L&&(S.readonly={username:k,password:L},l=!0),x&&B&&(S.admin={username:x,password:B},l=!0),Object.keys(S).length>0&&(i[y]=S)}),l){n.forEach(y=>{i[y]&&(document.getElementById(`${y}-token-status`).textContent="Verifying...",document.getElementById(`${y}-token-status`).className="token-status")});try{const y=await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:i})}),S=await m(y,"DNS credential save");if(S.results){const k=Object.keys(i).filter(L=>S.results[L]?.success!==!0);if(k.length){const L=k.map(x=>S.results[x]?.error||`${x} failed`).join("; ");throw new Error(L)}}n.forEach(k=>{t(k,"readonly",document.getElementById(`${k}-readonly-username`).value.trim()),s(k,"readonly",document.getElementById(`${k}-readonly-token`).value.trim()),t(k,"admin",document.getElementById(`${k}-admin-username`).value.trim()),s(k,"admin",document.getElementById(`${k}-admin-token`).value.trim())}),n.forEach(k=>{const L=document.getElementById(`${k}-token-status`);if(!i[k]){L.textContent="";return}const x=S.results?.[k];L.textContent=x?.partial?"\u2713 "+x.partial:"\u2713 Verified & saved",L.className="token-status success"})}catch(y){console.error("Failed to sync DNS credentials to backend:",y),n.forEach(S=>{i[S]&&(document.getElementById(`${S}-token-status`).textContent="\u2717 "+(y.message||"Save failed"),document.getElementById(`${S}-token-status`).className="token-status error")})}}else n.forEach(y=>{document.getElementById(`${y}-token-status`).textContent=""});setTimeout(()=>{
Line truncated
|
||||
|
||||
Current: ${d.currentVersion}
|
||||
New: ${d.updateVersion}
|
||||
Current: ${s.currentVersion}
|
||||
New: ${s.updateVersion}
|
||||
|
||||
`+(d.updateTitle?`${d.updateTitle}
|
||||
`+(s.updateTitle?`${s.updateTitle}
|
||||
|
||||
`:"")+`The DNS server will restart during the update.
|
||||
Proceed?`)){a.textContent=o,a.disabled=!1,a.title="Update DNS server";return}a.textContent="\u{1F504}",a.title="Updating...";const i=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(l)}`,{method:"POST"})).json();if(!i.success)throw new Error(i.error||"Update failed");if(i.manualUpdateRequired){a.textContent="\u2B06\uFE0F",a.title=`Update available: ${i.newVersion}`;const n=i.downloadLink?`
|
||||
Download: ${i.downloadLink}`:"",r=i.instructionsLink?`
|
||||
Instructions: ${i.instructionsLink}`:"";showNotification(`${u.toUpperCase()} update requires manual installation. Current: ${i.previousVersion} \u2192 ${i.newVersion}. Please update manually on the host machine.`,"warning",8e3),a.disabled=!1;return}a.textContent="\u2705",a.title="Updated successfully!",showNotification(`${u.toUpperCase()} updated successfully! ${i.previousVersion} \u2192 ${i.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{a.textContent=o,a.disabled=!1,a.title="Update DNS server",window.refreshAll()},1e4)}catch(g){console.error("DNS update error:",g),a.textContent="\u274C",a.title="Update failed",showNotification(`Failed to update ${u.toUpperCase()}: ${g.message}`,"error"),setTimeout(()=>{a.textContent=o,a.disabled=!1,a.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",u=>{const l=u.target.closest('[id$="-update"]');if(!l)return;const a=l.id.replace("-update","");SITE.dnsServers[a]&&s(a,SITE.dnsServers[a]?.ip)}),injectModal("dns-settings-modal",`
|
||||
Proceed?`)){r.textContent=f,r.disabled=!1,r.title="Update DNS server";return}r.textContent="\u{1F504}",r.title="Updating...";const o=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(d)}`,{method:"POST"})).json();if(!o.success)throw new Error(o.error||"Update failed");if(o.manualUpdateRequired){r.textContent="\u2B06\uFE0F",r.title=`Update available: ${o.newVersion}`;const u=o.downloadLink?`
|
||||
Download: ${o.downloadLink}`:"",n=o.instructionsLink?`
|
||||
Instructions: ${o.instructionsLink}`:"";showNotification(`${p.toUpperCase()} update requires manual installation. Current: ${o.previousVersion} \u2192 ${o.newVersion}. Please update manually on the host machine.`,"warning",8e3),r.disabled=!1;return}r.textContent="\u2705",r.title="Updated successfully!",showNotification(`${p.toUpperCase()} updated successfully! ${o.previousVersion} \u2192 ${o.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{r.textContent=f,r.disabled=!1,r.title="Update DNS server",window.refreshAll()},1e4)}catch(w){console.error("DNS update error:",w),r.textContent="\u274C",r.title="Update failed",showNotification(`Failed to update ${p.toUpperCase()}: ${w.message}`,"error"),setTimeout(()=>{r.textContent=f,r.disabled=!1,r.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",p=>{const d=p.target.closest('[id$="-update"]');if(!d)return;const r=d.id.replace("-update","");SITE.dnsServers[r]&&a(r,SITE.dnsServers[r]?.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(`${u.toUpperCase()} upd
|
||||
<button id="dns-settings-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let m=null;function h(u){m=u;const l=SITE.dnsServers[u]||{},a=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(l.name||u).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=l.ip||"",document.getElementById("dns-edit-port").value=l.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=l.name||"",a.classList.add("show")}async function b(){if(!m)return;const u=document.getElementById("dns-edit-ip").value.trim(),l=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,a=document.getElementById("dns-edit-name").value.trim();if(!u){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[m]={ip:u,port:String(l)},a&&(o.dnsServers[m].name=a);try{const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();d.success?(SITE.dnsServers[m]=o.dnsServers[m],showNotification(`${m.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(d.error||"Failed to save settings","error")}catch(g){showNotification("Failed to save: "+g.message,"error")}}async function y(){if(m&&confirm(`Remove ${m.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const l=await(await secureFetch("/api/v1/config")).json();l.dnsServers&&delete l.dnsServers[m];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:l.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[m];const g=document.querySelector(`.top [data-app="${m}"]`);g&&g.remove(),showNotification(`${m.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(o.error||"Failed to remove","error")}catch(u){showNotification("Failed to remove: "+u.message,"error")}}function v(){closeModal("dns-settings-modal"),m=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",b),document.getElementById("dns-settings-delete")?.addEventListener("click",y),document.getElementById("dns-settings-modal")?.addEventListener("click",u=>{u.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",u=>{const l=u.target.closest('[id$="-settings"]');if(!l)return;const a=l.id.replace("-settings","");SITE.dnsServers[a]&&(u.stopPropagation(),h(a))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
|
||||
</div>`);let m=null;function h(p){m=p;const d=SITE.dnsServers[p]||{},r=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(d.name||p).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=d.ip||"",document.getElementById("dns-edit-port").value=d.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=d.name||"",r.classList.add("show")}async function b(){if(!m)return;const p=document.getElementById("dns-edit-ip").value.trim(),d=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,r=document.getElementById("dns-edit-name").value.trim();if(!p){showNotification("Server IP is required","warning");return}const f={dnsServers:{}};f.dnsServers[m]={ip:p,port:String(d)},r&&(f.dnsServers[m].name=r);try{const s=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)})).json();s.success?(SITE.dnsServers[m]=f.dnsServers[m],showNotification(`${m.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(s.error||"Failed to save settings","error")}catch(w){showNotification("Failed to save: "+w.message,"error")}}async function g(){if(m&&confirm(`Remove ${m.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const d=await(await secureFetch("/api/v1/config")).json();d.dnsServers&&delete d.dnsServers[m];const f=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:d.dnsServers||{}})})).json();if(f.success){delete SITE.dnsServers[m];const w=document.querySelector(`.top [data-app="${m}"]`);w&&w.remove(),showNotification(`${m.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(f.error||"Failed to remove","error")}catch(p){showNotification("Failed to remove: "+p.message,"error")}}function v(){closeModal("dns-settings-modal"),m=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",b),document.getElementById("dns-settings-delete")?.addEventListener("click",g),document.getElementById("dns-settings-modal")?.addEventListener("click",p=>{p.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",p=>{const d=p.target.closest('[id$="-settings"]');if(!d)return;const r=d.id.replace("-settings","");SITE.dnsServers[r]&&(p.stopPropagation(),h(r))}),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,66 +390,66 @@ Instructions: ${i.instructionsLink}`:"";showNotification(`${u.toUpperCase()} upd
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let c=null,s=null,m=!1,h=null,b=null,y=!1,v=null,u=null,l=!1,a=null,o=!1;async function g(E,x=25){try{const S=getDnsServerAddr(E),T=await fetch(`/api/v1/dns/logs?server=${S}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,server:k.server}:{error:k.error||"Failed to fetch logs"}}else return T.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${T.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function d(E){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"}[E]||"var(--fg)"}function t(E){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;",E.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(E.raw)}</span>`,x;const S=d(E.rcode),T=E.rcode==="Refused"||E.rcode==="REFUSED";return x.innerHTML=`
|
||||
</div>`);let c=null,a=null,m=!1,h=null,b=null,g=!1,v=null,p=null,d=!1,r=null,f=!1;async function w(E,C=25){try{const T=getDnsServerAddr(E),$=await fetch(`/api/v1/dns/logs?server=${T}&limit=${C}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if($.ok){const I=await $.json();return I.success&&I.logs?{logs:I.logs,count:I.count,server:I.server}:{error:I.error||"Failed to fetch logs"}}else return $.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${$.status}`}}catch(T){return console.error("DNS logs fetch failed:",T),{error:T.message}}}function s(E){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"}[E]||"var(--fg)"}function t(E){const C=document.createElement("div");if(C.className="log-entry",C.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;",E.parsed===!1)return C.style.gridTemplateColumns="1fr",C.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(E.raw)}</span>`,C;const T=s(E.rcode),$=E.rcode==="Refused"||E.rcode==="REFUSED";return C.innerHTML=`
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.timestamp)}</span>
|
||||
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(E.client)}">${escapeHtml(E.client)}</span>
|
||||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${T?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(E.domain)}">${escapeHtml(E.domain)}</span>
|
||||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${$?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(E.domain)}">${escapeHtml(E.domain)}</span>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.type)}</span>
|
||||
<span style="color: ${S}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(E.rcode)}</span>
|
||||
`,x}async function e(){if(l){await B();return}if(y){await I();return}if(m||!c)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await g(c,E);if(S.error){x.innerHTML=`
|
||||
<span style="color: ${T}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(E.rcode)}</span>
|
||||
`,C}async function e(){if(d){await B();return}if(g){await S();return}if(m||!c)return;const E=parseInt(document.getElementById("log-lines").value),C=document.getElementById("logs-content");try{const T=await w(c,E);if(T.error){C.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(S.error)}</div>
|
||||
</div>`;return}x.innerHTML=`
|
||||
<div>${escapeHtml(T.error)}</div>
|
||||
</div>`;return}C.innerHTML=`
|
||||
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span>Time</span>
|
||||
<span>Client</span>
|
||||
<span>Domain</span>
|
||||
<span>Type</span>
|
||||
<span>Status</span>
|
||||
</div>`,S.logs&&S.logs.length>0?S.logs.forEach(T=>{const k=t(T);x.appendChild(k)}):x.innerHTML+=`
|
||||
</div>`,T.logs&&T.logs.length>0?T.logs.forEach($=>{const I=t($);C.appendChild(I)}):C.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No DNS queries logged yet
|
||||
</div>`}catch(S){x.innerHTML=`
|
||||
</div>`}catch(T){C.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(S.message)}
|
||||
</div>`}}function i(E){c=E,m=!1,y=!1;const x=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),T=document.getElementById("logs-pause"),k=document.getElementById("logs-stream");S.textContent=`${E.toUpperCase()} DNS Logs`,T.textContent="\u23F8\uFE0F Pause",T.classList.remove("paused"),k&&(k.style.display="none"),x.classList.add("show"),e(),s=setInterval(e,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),s&&(clearInterval(s),s=null),f(),c=null,y=!1,h=null,b=null,l=!1,v=null,u=null,m=!1}function r(E){a&&f();const x=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),T=document.getElementById("logs-content");s&&(clearInterval(s),s=null);try{a=new EventSource(`/api/v1/logs/stream/${E}`),o=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",S.style.display="none";const k=document.getElementById("logs-title");k.textContent.includes("\u{1F534}")||(k.innerHTML=k.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),a.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),f();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 _=(D.stream||"stdout")==="stderr",N=_?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${_?"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;">${_?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
|
||||
Failed to fetch logs: ${escapeHtml(T.message)}
|
||||
</div>`}}function o(E){c=E,m=!1,g=!1;const C=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),$=document.getElementById("logs-pause"),I=document.getElementById("logs-stream");T.textContent=`${E.toUpperCase()} DNS Logs`,$.textContent="\u23F8\uFE0F Pause",$.classList.remove("paused"),I&&(I.style.display="none"),C.classList.add("show"),e(),a=setInterval(e,DC.POLL.LOGS)}function u(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),i(),c=null,g=!1,h=null,b=null,d=!1,v=null,p=null,m=!1}function n(E){r&&i();const C=document.getElementById("logs-stream"),T=document.getElementById("logs-pause"),$=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{r=new EventSource(`/api/v1/logs/stream/${E}`),f=!0,C.classList.add("active"),C.textContent="\u{1F534} Live",C.title="Streaming - click to stop",T.style.display="none";const I=document.getElementById("logs-title");I.textContent.includes("\u{1F534}")||(I.innerHTML=I.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),r.onmessage=P=>{try{const D=JSON.parse(P.data);if(D.error){console.error("Stream error:",D.error),i();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 _=(D.stream||"stdout")==="stderr",R=_?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${_?"var(--bad-bg)":"var(--ok-bg)"}; color: ${R}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${_?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${M}</div>
|
||||
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
|
||||
`,T.appendChild(O),T.scrollTop=T.scrollHeight;T.children.length>500;)T.removeChild(T.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},a.onerror=A=>{console.error("EventSource error:",A),f()}}catch(k){console.error("Failed to start streaming:",k),f()}}function f(){a&&(a.close(),a=null),o=!1;const E=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),S=document.getElementById("logs-title");E&&(E.classList.remove("active"),E.textContent="\u{1F4E1} Live",E.title="Enable real-time streaming"),x&&(x.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),y&&h&&!s&&(s=setInterval(I,DC.POLL.LOGS))}async function p(E,x=100){try{const S=`/api/v1/logs/container/${E}?tail=${x}×tamps=true`,T=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,containerName:k.containerName,containerId:k.containerId}:{error:k.error||"Failed to fetch container logs"}}else return{error:`HTTP ${T.status}: ${T.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function w(E){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 S=E.stream==="stderr"?"var(--bad-fg)":"var(--fg)",T=E.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;">${T}</div>
|
||||
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(E.text)}</div>
|
||||
`,x}async function I(){if(m||!h||!y)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await p(h,E);if(S.error){x.innerHTML=`
|
||||
<div style="flex: 1; color: ${R}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
|
||||
`,$.appendChild(O),$.scrollTop=$.scrollHeight;$.children.length>500;)$.removeChild($.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},r.onerror=P=>{console.error("EventSource error:",P),i()}}catch(I){console.error("Failed to start streaming:",I),i()}}function i(){r&&(r.close(),r=null),f=!1;const E=document.getElementById("logs-stream"),C=document.getElementById("logs-pause"),T=document.getElementById("logs-title");E&&(E.classList.remove("active"),E.textContent="\u{1F4E1} Live",E.title="Enable real-time streaming"),C&&(C.style.display=""),T&&(T.textContent=T.textContent.replace(" \u{1F534}","")),g&&h&&!a&&(a=setInterval(S,DC.POLL.LOGS))}async function l(E,C=100){try{const T=`/api/v1/logs/container/${E}?tail=${C}×tamps=true`,$=await fetch(T,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if($.ok){const I=await $.json();return I.success&&I.logs?{logs:I.logs,count:I.count,containerName:I.containerName,containerId:I.containerId}:{error:I.error||"Failed to fetch container logs"}}else return{error:`HTTP ${$.status}: ${$.statusText}`}}catch(T){return console.error("Container logs fetch failed:",T),{error:T.message}}}function y(E){const C=document.createElement("div");C.className="log-entry",C.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 T=E.stream==="stderr"?"var(--bad-fg)":"var(--fg)",$=E.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 C.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${$}</div>
|
||||
<div style="flex: 1; color: ${T}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(E.text)}</div>
|
||||
`,C}async function S(){if(m||!h||!g)return;const E=parseInt(document.getElementById("log-lines").value),C=document.getElementById("logs-content");try{const T=await l(h,E);if(T.error){C.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(S.error)}</div>
|
||||
</div>`;return}x.innerHTML=`
|
||||
<div>${escapeHtml(T.error)}</div>
|
||||
</div>`;return}C.innerHTML=`
|
||||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span style="flex-shrink: 0; width: 80px;">Stream</span>
|
||||
<span style="flex: 1;">Log Output</span>
|
||||
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(T=>{const k=w(T);x.appendChild(k)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
|
||||
</div>`,T.logs&&T.logs.length>0?(T.logs.forEach($=>{const I=y($);C.appendChild(I)}),C.scrollTop=C.scrollHeight):C.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available for this container
|
||||
</div>`}catch(S){x.innerHTML=`
|
||||
</div>`}catch(T){C.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(S.message)}
|
||||
</div>`}}function L(E,x){h=E,b=x,y=!0,l=!1,m=!1,f();const S=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");T.textContent=`\u{1F4CB} ${x} - Container Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display=""),S.classList.add("show"),I(),s=setInterval(I,DC.POLL.LOGS)}async function P(E,x=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(E)}&tail=${x}`,T=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,logPath:k.logPath,totalLines:k.totalLines}:{error:k.error||"Failed to fetch file logs"}}else return{error:(await T.json().catch(()=>({}))).error||`HTTP ${T.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function C(E){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 S=E.text;let T="INFO",k="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(T="ERROR",k="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(T="WARN",k="#f39c12"):S.match(/DEBUG/i)&&(T="DEBUG",k="var(--muted)");const D=`<span style="background: ${k==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${k}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${T}</span>`;return x.innerHTML=`
|
||||
Failed to fetch logs: ${escapeHtml(T.message)}
|
||||
</div>`}}function k(E,C){h=E,b=C,g=!0,d=!1,m=!1,i();const T=document.getElementById("logs-modal"),$=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");$.textContent=`\u{1F4CB} ${C} - Container Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),P&&(P.style.display=""),T.classList.add("show"),S(),a=setInterval(S,DC.POLL.LOGS)}async function L(E,C=100){try{const T=`/api/v1/logs/file?path=${encodeURIComponent(E)}&tail=${C}`,$=await fetch(T,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if($.ok){const I=await $.json();return I.success&&I.logs?{logs:I.logs,count:I.count,logPath:I.logPath,totalLines:I.totalLines}:{error:I.error||"Failed to fetch file logs"}}else return{error:(await $.json().catch(()=>({}))).error||`HTTP ${$.status}`}}catch(T){return console.error("File logs fetch failed:",T),{error:T.message}}}function x(E){const C=document.createElement("div");C.className="log-entry",C.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 T=E.text;let $="INFO",I="var(--fg)";T.match(/ERROR|FATAL|CRITICAL/i)?($="ERROR",I="var(--bad-fg)"):T.match(/WARN|WARNING/i)?($="WARN",I="#f39c12"):T.match(/DEBUG/i)&&($="DEBUG",I="var(--muted)");const D=`<span style="background: ${I==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${I}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${$}</span>`;return C.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${D}</div>
|
||||
<div style="flex: 1; color: ${k}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(S)}</div>
|
||||
`,x}async function B(){if(m||!v||!l)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await P(v,E);if(S.error){x.innerHTML=`
|
||||
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(T)}</div>
|
||||
`,C}async function B(){if(m||!v||!d)return;const E=parseInt(document.getElementById("log-lines").value),C=document.getElementById("logs-content");try{const T=await L(v,E);if(T.error){C.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(S.error)}</div>
|
||||
</div>`;return}x.innerHTML=`
|
||||
<div>${escapeHtml(T.error)}</div>
|
||||
</div>`;return}C.innerHTML=`
|
||||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span style="flex: 1;">Log Output (${S.count} of ${S.totalLines} lines)</span>
|
||||
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(T=>{const k=C(T);x.appendChild(k)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
|
||||
<span style="flex: 1;">Log Output (${T.count} of ${T.totalLines} lines)</span>
|
||||
</div>`,T.logs&&T.logs.length>0?(T.logs.forEach($=>{const I=x($);C.appendChild(I)}),C.scrollTop=C.scrollHeight):C.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available in this file
|
||||
</div>`}catch(S){x.innerHTML=`
|
||||
</div>`}catch(T){C.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(S.message)}
|
||||
</div>`}}function $(E,x){v=E,u=x,l=!0,y=!1,m=!1;const S=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");T.textContent=`\u{1F4CB} ${x} - Application Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display="none"),S.classList.add("show"),B(),s=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",E=>{const x=E.target.closest('[id$="-logs"]');if(!x)return;const S=x.id.replace("-logs","");SITE.dnsServers[S]&&i(S)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{m=!m;const E=document.getElementById("logs-pause");m?(E.textContent="\u25B6\uFE0F Resume",E.classList.add("paused")):(E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),e())}),document.getElementById("log-lines")?.addEventListener("change",()=>{m||e()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!y||!h||(o?f():r(h))}),document.getElementById("logs-modal")?.addEventListener("click",E=>{E.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",E=>{E.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=i})(),(function(){injectModal("service-edit-modal",`
|
||||
Failed to fetch logs: ${escapeHtml(T.message)}
|
||||
</div>`}}function A(E,C){v=E,p=C,d=!0,g=!1,m=!1;const T=document.getElementById("logs-modal"),$=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");$.textContent=`\u{1F4CB} ${C} - Application Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),P&&(P.style.display="none"),T.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",E=>{const C=E.target.closest('[id$="-logs"]');if(!C)return;const T=C.id.replace("-logs","");SITE.dnsServers[T]&&o(T)}),document.getElementById("logs-close")?.addEventListener("click",u),document.getElementById("logs-pause")?.addEventListener("click",()=>{m=!m;const E=document.getElementById("logs-pause");m?(E.textContent="\u25B6\uFE0F Resume",E.classList.add("paused")):(E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),e())}),document.getElementById("log-lines")?.addEventListener("change",()=>{m||e()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!g||!h||(f?i():n(h))}),document.getElementById("logs-modal")?.addEventListener("click",E=>{E.target.id==="logs-modal"&&u()}),document.addEventListener("keydown",E=>{E.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&u()}),window.openContainerLogsModal=k,window.openFileLogsModal=A,window.openLogsModal=o})(),(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,45 +805,45 @@ Instructions: ${i.instructionsLink}`:"";showNotification(`${u.toUpperCase()} upd
|
||||
<button id="add-service-create" class="btn-accent">Create Service</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`)})(),(function(){async function c(y){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(y)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const u=await v.json();if(u.success){const l=document.getElementById("existing-ca-select");return l.innerHTML="",u.cas.length===0?l.innerHTML='<option value="">No CAs found in Caddyfile</option>':(l.innerHTML='<option value="">Select existing CA...</option>',u.cas.forEach(a=>{const o=document.createElement("option");typeof a=="object"?(o.value=a.id,o.textContent=a.displayName||a.name):(o.value=a,o.textContent=a),l.appendChild(o)})),u.data.cas}else throw new Error(u.message)}catch(v){console.error("Error loading CAs:",v);const u=document.getElementById("existing-ca-select");return u.innerHTML='<option value="">Error loading CAs</option>',[]}}function s(y){const{subdomain:v,port:u,ip:l,sslType:a,caName:o,existingCa:g,enableAuth:d,enableCors:t,customHeaders:e,upstreamPath:i,healthCheck:n,timeout:r,tailscaleOnly:f}=y;let p=`${buildDomain(v)} {
|
||||
`;switch(f&&(p+=` @blocked not remote_ip 100.64.0.0/10
|
||||
`,p+=` respond @blocked "Access denied. Tailscale connection required." 403
|
||||
`),a){case"letsencrypt":break;case"caddy-managed":p+=` tls internal
|
||||
`;break;case"existing-ca":g&&(p+=` tls {
|
||||
ca ${g}
|
||||
</div>`)})(),(function(){async function c(g){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(g)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const p=await v.json();if(p.success){const d=document.getElementById("existing-ca-select");return d.innerHTML="",p.cas.length===0?d.innerHTML='<option value="">No CAs found in Caddyfile</option>':(d.innerHTML='<option value="">Select existing CA...</option>',p.cas.forEach(r=>{const f=document.createElement("option");typeof r=="object"?(f.value=r.id,f.textContent=r.displayName||r.name):(f.value=r,f.textContent=r),d.appendChild(f)})),p.data.cas}else throw new Error(p.message)}catch(v){console.error("Error loading CAs:",v);const p=document.getElementById("existing-ca-select");return p.innerHTML='<option value="">Error loading CAs</option>',[]}}function a(g){const{subdomain:v,port:p,ip:d,sslType:r,caName:f,existingCa:w,enableAuth:s,enableCors:t,customHeaders:e,upstreamPath:o,healthCheck:u,timeout:n,tailscaleOnly:i}=g;let l=`${buildDomain(v)} {
|
||||
`;switch(i&&(l+=` @blocked not remote_ip 100.64.0.0/10
|
||||
`,l+=` respond @blocked "Access denied. Tailscale connection required." 403
|
||||
`),r){case"letsencrypt":break;case"caddy-managed":l+=` tls internal
|
||||
`;break;case"existing-ca":w&&(l+=` tls {
|
||||
ca ${w}
|
||||
}
|
||||
`);break;case"custom-ca":o&&(p+=` tls {
|
||||
ca ${o}
|
||||
`);break;case"custom-ca":f&&(l+=` tls {
|
||||
ca ${f}
|
||||
}
|
||||
`);break}if(d&&(p+=` basicauth {
|
||||
`);break}if(s&&(l+=` basicauth {
|
||||
admin $2a$14$hashed_password_here
|
||||
}
|
||||
`),t&&(p+=` header {
|
||||
`,p+=` Access-Control-Allow-Origin "*"
|
||||
`,p+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
`,p+=` Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
`,p+=` }
|
||||
`),e)try{const w=JSON.parse(e);p+=` header {
|
||||
`,Object.entries(w).forEach(([I,L])=>{p+=` ${I} "${L}"
|
||||
`}),p+=` }
|
||||
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(p+=` health_uri ${n}
|
||||
`),p+=` reverse_proxy ${l}:${u} {
|
||||
`,i&&i!=="/"&&(p+=` rewrite ${i}
|
||||
`),r&&r!==30&&(p+=` transport http {
|
||||
`,p+=` dial_timeout ${r}s
|
||||
`,p+=` response_header_timeout ${r}s
|
||||
`,p+=` }
|
||||
`),p+=` }
|
||||
`,p+=`}
|
||||
`,p}async function m(y,v,u=DC.DEFAULTS.TTL){const l=window.getToken(getPrimaryDnsId(),"admin");if(!l)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const a=buildDomain(y),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:a,ip:v,ttl:u,token:l,server:SITE.dnsIp})});if(!o.ok){const d=await o.text();throw new Error(`DNS API Error: ${o.status} - ${d}`)}const g=await o.json();if(!g.success)throw new Error(`DNS Error: ${g.error||"Unknown error"}`);return g}async function h(y){const v={id:y.subdomain,name:y.name,logo:y.logo||`/assets/${y.subdomain}.png`};y.category&&(v.category=y.category),y.containerId&&(v.containerId=y.containerId);try{const u=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!u.ok){const l=await u.json();throw new Error(l.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(u){throw console.error("Failed to add service to config:",u),u}}async function b(y){const v=document.getElementById("service-subdomain-input").value.trim(),u=document.getElementById("service-ip-input").value.trim()||"localhost",l=document.getElementById("service-port-input").value.trim()||"80",a=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${u}:${l}`,config:y})}),o=await a.json();if(!a.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${a.status}`);return o}window.loadExistingCAs=c,window.generateCaddyConfig=s,window.createDnsRecord=m,window.addServiceToConfig=h,window.addToCaddyfile=b})(),(function(){let c=null;function s(u){c=u;const l=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${u.name}`,document.getElementById("edit-service-name").value=u.name,document.getElementById("edit-service-url-display").textContent=u.url||buildServiceUrl(u.id),document.getElementById("edit-service-logo-preview").src=u.logo||`/assets/${u.id}.png`,document.getElementById("edit-subdomain").value=u.id,document.getElementById("edit-port").value=u.port||"",document.getElementById("edit-ip").value=u.ip||"localhost",document.getElementById("edit-tailscale-only").checked=u.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=u.logo||"";const a=document.getElementById("edit-service-category");a&&(a.dataset.current=u.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),l.classList.add("show")}function m(){closeModal("service-edit-modal"),c=null}async function h(){if(!c)return;const u=document.getElementById("edit-subdomain").value.trim().toLowerCase(),l=document.getElementById("edit-service-name").value.trim(),a=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",g=document.getElementById("edit-tailscale-only").checked,d=document.getElementById("edit-logo-url").value.trim(),t=document.getElementById("edit-service-category")?.value||"";if(!u){showNotification("Subdomain is required","warning");return}const e=c.id,i=[];if(u!==e&&i.push("subdomain"),l&&l!==c.name&&i.push("name"),a&&a!==String(c.port)&&i.push("port"),o!==c.ip&&i.push("ip"),g!==(c.tailscaleOnly||!1)&&i.push("tailscale"),d&&d!==c.logo&&i.push("logo"),t!==(c.category||"")&&i.push("category"),i.length===0){m();return}const n=document.getElementById("service-edit-save");n.textContent="Saving...",n.disabled=!0;try{const f=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:u,name:l||c.name,port:a||c.port,ip:o,tailscaleOnly:g,logo:d||void 0,category:t})})).json();if(!f.success)throw new Error(f.error||"Failed to update service");const p=window.APPS.findIndex(w=>w.id===e);p!==-1&&(window.APPS[p]={...window.APPS[p],id:u,name:l||window.APPS[p].name,port:a||window.APPS[p].port,ip:o,tailscaleOnly:g,logo:d||window.APPS[p].logo,category:t||void 0}),m(),window.buildGrid(),window.refreshAll()}catch(r){console.error("Error saving service changes:",r),showNotification(`Error saving changes: ${r.message}`,"error")}finally{n.textContent="Save Changes",n.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async u=>{const l=u.target.files[0];if(!l)return;if(!l.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=async o=>{const g=o.target.result;if(document.getElementById("edit-service-logo-preview").src=g,document.getElementById("edit-logo-url").value=g,c)try{const t=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${c.id}.png`,data:g})})).json();t.success&&t.path&&(document.getElementById("edit-logo-url").val
Line truncated
|
||||
`),t&&(l+=` header {
|
||||
`,l+=` Access-Control-Allow-Origin "*"
|
||||
`,l+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
`,l+=` Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
`,l+=` }
|
||||
`),e)try{const y=JSON.parse(e);l+=` header {
|
||||
`,Object.entries(y).forEach(([S,k])=>{l+=` ${S} "${k}"
|
||||
`}),l+=` }
|
||||
`}catch{console.warn("Invalid JSON in custom headers")}return u&&(l+=` health_uri ${u}
|
||||
`),l+=` reverse_proxy ${d}:${p} {
|
||||
`,o&&o!=="/"&&(l+=` rewrite ${o}
|
||||
`),n&&n!==30&&(l+=` transport http {
|
||||
`,l+=` dial_timeout ${n}s
|
||||
`,l+=` response_header_timeout ${n}s
|
||||
`,l+=` }
|
||||
`),l+=` }
|
||||
`,l+=`}
|
||||
`,l}async function m(g,v,p=DC.DEFAULTS.TTL){const d=window.getToken(getPrimaryDnsId(),"admin");if(!d)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const r=buildDomain(g),f=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:r,ip:v,ttl:p,token:d,server:SITE.dnsIp})});if(!f.ok){const s=await f.text();throw new Error(`DNS API Error: ${f.status} - ${s}`)}const w=await f.json();if(!w.success)throw new Error(`DNS Error: ${w.error||"Unknown error"}`);return w}async function h(g){const v={id:g.subdomain,name:g.name,logo:g.logo||`/assets/${g.subdomain}.png`};g.category&&(v.category=g.category),g.containerId&&(v.containerId=g.containerId);try{const p=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!p.ok){const d=await p.json();throw new Error(d.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(p){throw console.error("Failed to add service to config:",p),p}}async function b(g){const v=document.getElementById("service-subdomain-input").value.trim(),p=document.getElementById("service-ip-input").value.trim()||"localhost",d=document.getElementById("service-port-input").value.trim()||"80",r=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${p}:${d}`,config:g})}),f=await r.json();if(!r.ok||!f.success)throw new Error(f.error||`Caddy API Error: ${r.status}`);return f}window.loadExistingCAs=c,window.generateCaddyConfig=a,window.createDnsRecord=m,window.addServiceToConfig=h,window.addToCaddyfile=b})(),(function(){let c=null;function a(p){c=p;const d=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${p.name}`,document.getElementById("edit-service-name").value=p.name,document.getElementById("edit-service-url-display").textContent=p.url||buildServiceUrl(p.id),document.getElementById("edit-service-logo-preview").src=p.logo||`/assets/${p.id}.png`,document.getElementById("edit-subdomain").value=p.id,document.getElementById("edit-port").value=p.port||"",document.getElementById("edit-ip").value=p.ip||"localhost",document.getElementById("edit-tailscale-only").checked=p.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=p.logo||"";const r=document.getElementById("edit-service-category");r&&(r.dataset.current=p.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),d.classList.add("show")}function m(){closeModal("service-edit-modal"),c=null}async function h(){if(!c)return;const p=document.getElementById("edit-subdomain").value.trim().toLowerCase(),d=document.getElementById("edit-service-name").value.trim(),r=document.getElementById("edit-port").value.trim(),f=document.getElementById("edit-ip").value.trim()||"localhost",w=document.getElementById("edit-tailscale-only").checked,s=document.getElementById("edit-logo-url").value.trim(),t=document.getElementById("edit-service-category")?.value||"";if(!p){showNotification("Subdomain is required","warning");return}const e=c.id,o=[];if(p!==e&&o.push("subdomain"),d&&d!==c.name&&o.push("name"),r&&r!==String(c.port)&&o.push("port"),f!==c.ip&&o.push("ip"),w!==(c.tailscaleOnly||!1)&&o.push("tailscale"),s&&s!==c.logo&&o.push("logo"),t!==(c.category||"")&&o.push("category"),o.length===0){m();return}const u=document.getElementById("service-edit-save");u.textContent="Saving...",u.disabled=!0;try{const i=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:p,name:d||c.name,port:r||c.port,ip:f,tailscaleOnly:w,logo:s||void 0,category:t})})).json();if(!i.success)throw new Error(i.error||"Failed to update service");const l=window.APPS.findIndex(y=>y.id===e);l!==-1&&(window.APPS[l]={...window.APPS[l],id:p,name:d||window.APPS[l].name,port:r||window.APPS[l].port,ip:f,tailscaleOnly:w,logo:s||window.APPS[l].logo,category:t||void 0}),m(),window.buildGrid(),window.refreshAll()}catch(n){console.error("Error saving service changes:",n),showNotification(`Error saving changes: ${n.message}`,"error")}finally{u.textContent="Save Changes",u.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async p=>{const d=p.target.files[0];if(!d)return;if(!d.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const r=new FileReader;r.onload=async f=>{const w=f.target.result;if(document.getElementById("edit-service-logo-preview").src=w,document.getElementById("edit-logo-url").value=w,c)try{const t=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${c.id}.png`,data:w})})).json();t.success&&t.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{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const t=await(await secureFetch(`/api/v1/containers/${u}/update`,{method:"POST"})).json();if(t.success){const e=window.APPS.find(i=>i.id===a);e&&t.newContainerId&&(e.containerId=t.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=g,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${l} updated successfully!`,"success")}else throw new Error(t.error||"Update failed")}catch(d){console.error("Update error:",d),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=g,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${l}: ${d.message}`,"error")}}async function v(u,l){const a=window.APPS.find(p=>p.id===u),o=a?buildDomain(a.id):null,g=a?.containerId,d=await b(l||u,g,a?.containerId);if(d===null)return;let t={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(d&&g)try{const p=new URLSearchParams({containerId:a.containerId,subdomain:a.id,ip:a.ip||"localhost",deleteContainer:"true"}),I=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(a.id)}?${p.toString()}`,{method:"DELETE"})).json();I.success?t={...t,...I.results,dashboard:!1}:console.error("App removal failed:",I.error)}catch(p){console.error("App removal error:",p)}else if(d&&o){try{const p=a?.ip||"localhost",I=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(p)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();t.dns=I.success?"deleted":I.error||"failed"}catch(p){t.dns=p.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();t.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(p){t.caddy=p.message}}const e=window.APPS.findIndex(p=>p.id===u);e>-1&&(window.APPS.splice(e,1),t.dashboard=!0);try{const p=safeGetJSON("custom-apps",[]),w=p.findIndex(I=>I.id===u);w>-1&&(p.splice(w,1),safeSet("custom-apps",JSON.stringify(p)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(u)}`,{method:"DELETE"})).json();t.service=w.success?"removed":w.error||"failed"}catch(p){t.service=p.message}window.buildGrid(),window.refreshAll();let i=!1,n=[];t.dashboard||(i=!0,n.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"],f=p=>!p||r.some(w=>p.toLowerCase().includes(w.toLowerCase()));t.container&&!f(t.container)&&(i=!0,n.push(`\u26A0 Container: ${t.container}`)),t.dns&&!f(t.dns)&&(i=!0,n.push(`\u26A0 DNS Record: ${t.dns}`)),t.caddy&&!f(t.caddy)&&(i=!0,n.push(`\u26A0 Caddy Config: ${t.caddy}`)),t.service&&!f(t.service)&&(i=!0,n.push(`\u26A0 Service File: ${t.service}`)),i&&showNotification(`Error deleting "${l||u}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=s,window.showDeleteModal=b,window.updateContainer=y,window.deleteService=v})(),(function(){function c(t){return t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function s(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function m(){const t=document.getElementById("service-subdomain-input").value||"subdomain",e=document.getElementById("service-ip-input").value||h.lan||"localhost",i=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,r=document.getElementById("ca-name-input").value||"sami-ca",f=document.getElementById("existing-ca-select").value,p=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,I=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(t)} \u2192 ${e}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(t));const E={subdomain:t,port:i,ip:e,sslType:n,caName:r,existingCa:f,enableAuth:p,enableCors:w,customHeaders:I,upstreamPath:L,healthCheck:P,timeout:C},x=window.generateCaddyConfig(E),S=document.getElementById("caddy-config-preview");S&&(S.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function b(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const r=await n.json();r.lan&&(h.lan=r.lan),r.tailscale&&(h.tailscale=r.tailscale)}}catch{}const t=document.getElementById("quick-ip-lan"),e=document.getElementById("quick-ip-tailscale");t&&(h.lan?(t.dataset.ip=h.lan,t.textContent=`LAN (${h.lan})`,t.title=`LAN IP: ${h.lan}`):t
Line truncated
|
||||
The service will be briefly unavailable.`))try{f&&(f.textContent="\u{1F504}",f.disabled=!0,f.title="Updating...");const t=await(await secureFetch(`/api/v1/containers/${p}/update`,{method:"POST"})).json();if(t.success){const e=window.APPS.find(o=>o.id===r);e&&t.newContainerId&&(e.containerId=t.newContainerId),f&&(f.textContent="\u2705",f.title="Updated successfully!",setTimeout(()=>{f.textContent=w,f.disabled=!1,f.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${d} updated successfully!`,"success")}else throw new Error(t.error||"Update failed")}catch(s){console.error("Update error:",s),f&&(f.textContent="\u274C",f.title="Update failed",setTimeout(()=>{f.textContent=w,f.disabled=!1,f.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${d}: ${s.message}`,"error")}}async function v(p,d){const r=window.APPS.find(l=>l.id===p),f=r?buildDomain(r.id):null,w=r?.containerId,s=await b(d||p,w,r?.containerId);if(s===null)return;let t={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(s&&w)try{const l=new URLSearchParams({containerId:r.containerId,subdomain:r.id,ip:r.ip||"localhost",deleteContainer:"true"}),S=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(r.id)}?${l.toString()}`,{method:"DELETE"})).json();S.success?t={...t,...S.results,dashboard:!1}:console.error("App removal failed:",S.error)}catch(l){console.error("App removal error:",l)}else if(s&&f){try{const l=r?.ip||"localhost",S=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(f)}&type=A&ipAddress=${encodeURIComponent(l)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();t.dns=S.success?"deleted":S.error||"failed"}catch(l){t.dns=l.message}try{const y=await(await secureFetch(`/api/v1/site/${encodeURIComponent(f)}`,{method:"DELETE"})).json();t.caddy=y.success||y.error&&y.error.includes("not found")?"removed":y.error||"failed"}catch(l){t.caddy=l.message}}const e=window.APPS.findIndex(l=>l.id===p);e>-1&&(window.APPS.splice(e,1),t.dashboard=!0);try{const l=safeGetJSON("custom-apps",[]),y=l.findIndex(S=>S.id===p);y>-1&&(l.splice(y,1),safeSet("custom-apps",JSON.stringify(l)))}catch{}try{const y=await(await secureFetch(`/api/v1/services/${encodeURIComponent(p)}`,{method:"DELETE"})).json();t.service=y.success?"removed":y.error||"failed"}catch(l){t.service=l.message}window.buildGrid(),window.refreshAll();let o=!1,u=[];t.dashboard||(o=!0,u.push("\u2717 Failed to remove from dashboard"));const n=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],i=l=>!l||n.some(y=>l.toLowerCase().includes(y.toLowerCase()));t.container&&!i(t.container)&&(o=!0,u.push(`\u26A0 Container: ${t.container}`)),t.dns&&!i(t.dns)&&(o=!0,u.push(`\u26A0 DNS Record: ${t.dns}`)),t.caddy&&!i(t.caddy)&&(o=!0,u.push(`\u26A0 Caddy Config: ${t.caddy}`)),t.service&&!i(t.service)&&(o=!0,u.push(`\u26A0 Service File: ${t.service}`)),o&&showNotification(`Error deleting "${d||p}": ${u.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=b,window.updateContainer=g,window.deleteService=v})(),(function(){function c(t){return t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function a(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function m(){const t=document.getElementById("service-subdomain-input").value||"subdomain",e=document.getElementById("service-ip-input").value||h.lan||"localhost",o=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,u=document.getElementById("ssl-type-select").value,n=document.getElementById("ca-name-input").value||"sami-ca",i=document.getElementById("existing-ca-select").value,l=document.getElementById("enable-auth").checked,y=document.getElementById("enable-cors").checked,S=document.getElementById("custom-headers-input").value,k=document.getElementById("upstream-path-input").value||"/",L=document.getElementById("health-check-input").value,x=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(t)} \u2192 ${e}`);const A=document.getElementById("url-preview");A&&(A.textContent=buildServiceUrl(t));const E={subdomain:t,port:o,ip:e,sslType:u,caName:n,existingCa:i,enableAuth:l,enableCors:y,customHeaders:S,upstreamPath:k,healthCheck:L,timeout:x},C=window.generateCaddyConfig(E),T=document.getElementById("caddy-config-preview");T&&(T.value=C)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function b(){try{const u=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(u.ok){const n=await u.json();n.lan&&(h.lan=n.lan),n.tailscale&&(h.tailscale=n.tailscale)}}catch{}const t=document.getElementById("quick-ip-lan"),e=document.getElementById("quick-ip-tailscale");t&&(h.lan?(t.dataset.ip=h.lan,t.textContent=`LAN (${h.lan})`,t.title=`LAN IP: ${h.lan}`):t
Line truncated
|
||||
<span style="color: #4caf50;">\u2713 Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 6px;">${w.self?.hostname} (${w.self?.ip})</span>
|
||||
`,f.disabled=!1):w.installed?(r.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',f.disabled=!0):(r.innerHTML='<span style="color: var(--muted);">Not available</span>',f.disabled=!0)}catch{r.innerHTML='<span style="color: var(--muted);">Could not check</span>',f.disabled=!0}f.checked=!1,m()}function u(){const t=document.getElementById("service-type-local"),e=document.getElementById("service-type-external"),i=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),r=document.getElementById("tab-local"),f=document.getElementById("tab-external");function p(){t.checked?(i.style.display="grid",n.style.display="none",r&&(r.style.background="var(--accent)",r.style.color="var(--bg)"),f&&(f.style.background="transparent",f.style.color="var(--muted)")):(i.style.display="none",n.style.display="block",f&&(f.style.background="var(--accent)",f.style.color="var(--bg)"),r&&(r.style.background="transparent",r.style.color="var(--muted)"))}t?.addEventListener("change",p),e?.addEventListener("change",p)}function l(){const t=document.getElementById("service-name-input"),e=document.getElementById("service-subdomain-input"),i=document.getElementById("subdomain-preview");let n=!1;t?.addEventListener("input",()=>{const L=c(t.value);!n&&e&&(e.value=L),i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),m()}),e?.addEventListener("input",()=>{n=e.value!==c(t?.value||"");const L=e.value.trim()||c(t?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),m()});const r=document.getElementById("external-service-name"),f=document.getElementById("external-service-subdomain"),p=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let I=!1;r?.addEventListener("input",()=>{const L=c(r.value);!I&&f&&(f.value=L);const P=f?.value||L;p&&(p.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),f?.addEventListener("input",()=>{I=f.value!==c(r?.value||"");const L=f.value.trim()||c(r?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function a(){const t=document.getElementById("external-service-name").value.trim(),e=document.getElementById("external-service-url").value.trim(),i=(document.getElementById("external-service-subdomain").value.trim()||c(t)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),r=document.getElementById("external-service-icon").value.trim(),f=document.getElementById("external-create-dns").checked,p=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",I=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!t||!e){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(!e.startsWith("http://")&&!e.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(i);try{const B={dns:null,caddy:null,dashboard:!1};if(f)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:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(k){B.dns=k.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(p)try{const T={subdomain:i,externalUrl:e,preserveHost:I,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(T)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(T){B.caddy=T.message}const $={id:i,name:t,url:`https://${C}`,externalUrl:e,logo:n||r||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const E=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(T=>!E.includes(T.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(T){console.warn("Failed to save to services.json:",T)}window.buildGrid(),window.refreshAll(),o();const S=[`External service "${t}" added!`];f&&S.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),p&&S.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),S.push(`Access at: https://${C}`),showNotification(S.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI
Line truncated
|
||||
<span style="color: var(--muted); margin-left: 6px;">${y.self?.hostname} (${y.self?.ip})</span>
|
||||
`,i.disabled=!1):y.installed?(n.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',i.disabled=!0):(n.innerHTML='<span style="color: var(--muted);">Not available</span>',i.disabled=!0)}catch{n.innerHTML='<span style="color: var(--muted);">Could not check</span>',i.disabled=!0}i.checked=!1,m()}function p(){const t=document.getElementById("service-type-local"),e=document.getElementById("service-type-external"),o=document.getElementById("local-service-config"),u=document.getElementById("external-service-config"),n=document.getElementById("tab-local"),i=document.getElementById("tab-external");function l(){t.checked?(o.style.display="grid",u.style.display="none",n&&(n.style.background="var(--accent)",n.style.color="var(--bg)"),i&&(i.style.background="transparent",i.style.color="var(--muted)")):(o.style.display="none",u.style.display="block",i&&(i.style.background="var(--accent)",i.style.color="var(--bg)"),n&&(n.style.background="transparent",n.style.color="var(--muted)"))}t?.addEventListener("change",l),e?.addEventListener("change",l)}function d(){const t=document.getElementById("service-name-input"),e=document.getElementById("service-subdomain-input"),o=document.getElementById("subdomain-preview");let u=!1;t?.addEventListener("input",()=>{const k=c(t.value);!u&&e&&(e.value=k),o&&(o.textContent=k?`\u2192 ${buildDomain(k)}`:""),m()}),e?.addEventListener("input",()=>{u=e.value!==c(t?.value||"");const k=e.value.trim()||c(t?.value||"");o&&(o.textContent=k?`\u2192 ${buildDomain(k)}`:""),m()});const n=document.getElementById("external-service-name"),i=document.getElementById("external-service-subdomain"),l=document.getElementById("external-subdomain-preview"),y=document.getElementById("external-domain-preview");let S=!1;n?.addEventListener("input",()=>{const k=c(n.value);!S&&i&&(i.value=k);const L=i?.value||k;l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),y&&(y.textContent=L?buildDomain(L):"")}),i?.addEventListener("input",()=>{S=i.value!==c(n?.value||"");const k=i.value.trim()||c(n?.value||"");l&&(l.textContent=k?`\u2192 ${buildDomain(k)}`:""),y&&(y.textContent=k?buildDomain(k):"")})}async function r(){const t=document.getElementById("external-service-name").value.trim(),e=document.getElementById("external-service-url").value.trim(),o=(document.getElementById("external-service-subdomain").value.trim()||c(t)).toLowerCase(),u=document.getElementById("external-service-logo").value.trim(),n=document.getElementById("external-service-icon").value.trim(),i=document.getElementById("external-create-dns").checked,l=document.getElementById("external-create-caddy").checked,y=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",S=document.getElementById("external-preserve-host").checked,k=document.getElementById("external-follow-redirects").checked,L=document.getElementById("external-service-category")?.value||"";if(!t||!e){showNotification("Please fill in Name and External URL","warning");return}if(!o){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!e.startsWith("http://")&&!e.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const x=buildDomain(o);try{const B={dns:null,caddy:null,dashboard:!1};if(i)if(window.getToken(getPrimaryDnsId(),"admin"))try{const P=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:x,ip:y,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=P.success?"created":P.error||"failed"}catch(I){B.dns=I.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(l)try{const $={subdomain:o,externalUrl:e,preserveHost:S,followRedirects:k,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},P=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify($)})).json();B.caddy=P.success?"created":P.error||"failed"}catch($){B.caddy=$.message}const A={id:o,name:t,url:`https://${x}`,externalUrl:e,logo:u||n||"\u{1F310}",isExternal:!0,isCustom:!0};L&&(A.category=L),window.APPS.push(A),B.dashboard=!0;const E=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],C=window.APPS.filter($=>!E.includes($.id));safeSet("custom-services",JSON.stringify(C));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch($){console.warn("Failed to save to services.json:",$)}window.buildGrid(),window.refreshAll(),f();const T=[`External service "${t}" added!`];i&&T.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),l&&T.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),T.push(`Access at: https://${x}`),showNotification(T.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI
Line truncated
|
||||
Vendored
+7
-7
@@ -1,4 +1,4 @@
|
||||
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(n){console.warn("Failed to load custom services:",n)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((n,e)=>{n.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(n=>n.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(n=>{console.warn("[init] Service worker registration failed:",n)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(()=>{window._dcAuthLost||window.refreshAll()},DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();n.success&&window._updateAuthCard(n.config.enabled&&n.config.isSetUp,n.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(i){console.warn("[init] AdminPanel attachTrigger failed:",i)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/driver.min.css",document.head.appendChild(n)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/onboarding.css",document.head.appendChild(n)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let n={};try{n=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(n[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(a&&a.tourCompleted)}catch{}const n=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!n)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=i?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},n.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const n=await i.json();n&&n.categories&&(window.DC_CATEGORIES=n.categories,typeof DC<"u"&&(DC.CATEGORIES=n.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(n=>{const e=n.dataset.current||"",a=n.querySelector('option[value=""]');if(n.innerHTML="",a)n.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",n.appendChild(c)}Object.entries(i).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),n.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-stoLine truncated
|
||||
(function(){function v(){const n=safeGet("custom-services");if(n)try{JSON.parse(n).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(i){console.warn("Failed to load custom services:",i)}}v();function k(){const n=document.querySelectorAll(".top .card");n.forEach((i,e)=>{i.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{n.forEach(i=>i.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const n=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(i=>{console.warn("[init] Service worker registration failed:",i)})};document.readyState==="complete"?n():window.addEventListener("load",n,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),typeof window.openRequestedCredentialForm=="function"&&window.openRequestedCredentialForm(),k(),window.refreshAll(),setInterval(()=>{window._dcAuthLost||window.refreshAll()},DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();i.success&&window._updateAuthCard(i.config.enabled&&i.config.isSetUp,i.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(n){console.warn("[init] AdminPanel attachTrigger failed:",n)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const n=document.createElement("script");if(n.src="/dist/onboarding.js",n.defer=!0,document.head.appendChild(n),!document.querySelector('link[href="/css/driver.min.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/driver.min.css",document.head.appendChild(i)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/onboarding.css",document.head.appendChild(i)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const n=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!n||!n.tourCompleted&&n.currentStep===0}catch{return!0}}function E(){const n=document.querySelectorAll(".tools-section");if(!n.length)return;let i={};try{i=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}n.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(i[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let n=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));n=n||!!(a&&a.tourCompleted)}catch{}const i=n?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!i)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=n?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},i.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const n=await fetch("/api/v1/templates",{cache:"no-store"});if(!n.ok)return;const i=await n.json();i&&i.categories&&(window.DC_CATEGORIES=i.categories,typeof DC<"u"&&(DC.CATEGORIES=i.categories),q())}catch(n){console.warn("[init] Failed to load template categories:",n)}}function q(){const n=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;n&&document.querySelectorAll('select[data-role="service-category"]').forEach(i=>{const e=i.dataset.current||"",a=i.querySelector('option[value=""]');if(i.innerHTML="",a)i.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",i.appendChild(c)}Object.entries(n).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),i.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplaLine truncated
|
||||
.dc-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
@@ -109,7 +109,7 @@
|
||||
<div class="dc-monitor-value" id="dc-monitor-health">\u2014</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-health-sub">\u2014</div>
|
||||
</div>
|
||||
`,k.parentNode.insertBefore(u,k);function h(e,a){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(a)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const a=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<a.length-1;)e/=1024,c++;return e.toFixed(1)+" "+a[c]}function C(){const e=(window.APPS||[]).length;let a=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&a++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${a} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${a} online \xB7 ${e-a} offline`)}function E(e){const a=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!a)return;if(!e||e.summary==null){a.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;a.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const a=await e.json();return a&&a.stats?a.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const a=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){a&&(a.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){a&&(a.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(a&&(a.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let i=!1;async function n(){if(!i){i=!0;try{C();const[e,a]=await Promise.all([S(),y()]);q(e),E(a);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{i=!1}}}window.refreshMonitoringWidgets=n,setInterval(n,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(n,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
|
||||
`,k.parentNode.insertBefore(u,k);function h(e,a){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(a)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const a=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<a.length-1;)e/=1024,c++;return e.toFixed(1)+" "+a[c]}function C(){const e=(window.APPS||[]).length;let a=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&a++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${a} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${a} online \xB7 ${e-a} offline`)}function E(e){const a=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!a)return;if(!e||e.summary==null){a.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;a.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const a=await e.json();return a&&a.stats?a.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const a=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){a&&(a.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){a&&(a.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(a&&(a.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let n=!1;async function i(){if(!n){n=!0;try{C();const[e,a]=await Promise.all([S(),y()]);q(e),E(a);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{n=!1}}}window.refreshMonitoringWidgets=i,setInterval(i,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(i,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
|
||||
<div class="quick-search-content">
|
||||
<div class="quick-search-input-wrapper">
|
||||
<span class="quick-search-icon">\u{1F50D}</span>
|
||||
@@ -271,7 +271,7 @@
|
||||
font-family: monospace;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),i()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function i(){const t=`
|
||||
`,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),n()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function n(){const t=`
|
||||
<div class="quick-search-category">Quick Actions</div>
|
||||
<div class="quick-search-item" data-action="refresh">
|
||||
<span class="quick-search-item-icon">\u{1F504}</span>
|
||||
@@ -303,8 +303,8 @@
|
||||
</div>
|
||||
|
||||
<div class="quick-search-category">Services</div>
|
||||
${n()}
|
||||
`;f.innerHTML=t,s()}function n(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=`
|
||||
${i()}
|
||||
`;f.innerHTML=t,s()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${w}">
|
||||
<span class="quick-search-item-icon">${d==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
|
||||
<div class="quick-search-item-content">
|
||||
@@ -313,7 +313,7 @@
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${d.toUpperCase()}</span>
|
||||
</div>
|
||||
`)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){i();return}const r=a(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function a(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=`
|
||||
`)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){n();return}const r=a(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function a(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=`
|
||||
<div class="quick-search-item" data-action="${r.id}">
|
||||
<span class="quick-search-item-icon">${r.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
@@ -329,4 +329,4 @@
|
||||
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span>
|
||||
</div>
|
||||
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBhbmltYXRlVG9wQ2FyZHMoKTtcbiAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIHNldEludGVydmFsKCgpID0+IHtcbiAgICAgIC8vIFN0b3AgcG9sbGluZyBpZiB0aGUgc2Vzc2lvbiBoYXMgYmVlbiBpbnZhbGlkYXRlZCAoZS5nLiBUT1RQIGdhdGVcbiAgICAgIC8vIG5vdyBhY3RpdmUsIG9yIHVzZXIgbG9nZ2VkIG91dCkuIEF2b2lkcyByZWxlbnRsZXNzIDQwMS80MDMgbm9pc2UuXG4gICAgICBpZiAod2luZG93Ll9kY0F1dGhMb3N0KSByZXR1cm47XG4gICAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIH0sIERDLlBPTEwuREFTSEJPQVJEKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoQ3JlZHNCdXR0b25zID09PSAnZnVuY3Rpb24nKSB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucygpO1xuICAgIGlmICh0eXBlb2Ygd2luZG93LnJlZnJlc2hNb25pdG9yaW5nV2lkZ2V0cyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hNb25pdG9yaW5nV2lkZ2V0cygpO1xuICAgIC8vIFVwZGF0ZSBhdXRoIGNhcmQgKG1heSBoYXZlIGFscmVhZHkgYmVlbiB1cGRhdGVkIGJ5IHRoZSBhdXRvLWxvYWQgSUlGRSBidXQgZW5zdXJlIGl0J3MgY29ycmVjdClcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQgPT09ICdmdW5jdGlvbicpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHIgPSBhd2FpdCBmZXRjaCgnL2FwaS92MS90b3RwL2NvbmZpZycsIHsgY2FjaGU6ICduby1zdG9yZScgfSk7XG4gICAgICAgIGNvbnN0IGQgPSBhd2FpdCByLmpzb24oKTtcbiAgICAgICAgaWYgKGQuc3VjY2Vzcykgd2luZG93Ll91cGRhdGVBdXRoQ2FyZChkLmNvbmZpZy5lbmFibGVkICYmIGQuY29uZmlnLmlzU2V0VXAsIGQuY29uZmlnLnNlc3Npb25EdXJhdGlvbik7XG4gICAgICB9IGNhdGNoIChlKSB7IC8qIGlnbm9yZSAqLyB9XG4gICAgfVxuICAgIGlmICh3aW5kb3cuX19kYXNoY2FkZHlTaXRlQ29uZmlnTG9hZGVkKSB7XG4gICAgICB0cnkge1xuICAgICAgICBhd2FpdCB3aW5kb3cuX19kYXNoY2FkZHlTaXRlQ29uZmlnTG9hZGVkO1xuICAgICAgfSBjYXRjaCAoXykgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICAvLyBMYXp5LWxvYWQgb25ib2FyZGluZyBvbmx5IG9uY2UgcGVyIGluc3RhbGwsIG90aGVyd2lzZSBqdXN0IGFkZCB0aGUgdG91ciBidXR0b25cbiAgICBhZGRUb3VyQnV0dG9uKCk7XG4gICAgaWYgKHNob3VsZExvYWRPbmJvYXJkaW5nKCkpIHtcbiAgICAgIGxvYWRPbmJvYXJkaW5nKCk7XG4gICAgfVxuXG4gICAgLy8gREMtMDQ4OiBpbmplY3QgdGhlIFwiQWRtaW5cIiB0cmlnZ2VyIGJ1dHRvbiBpbnRvIHRoZSB0b3AgYmFyLiBUaGVcbiAgICAvLyBidXR0b24gb25seSByZW5kZXJzIHdoZW4gL21lIHJldHVybnMgaXNBZG1pbj10cnVlOyB0aGUgbW9kdWxlXG4gICAgLy8gcmUtY2hlY2tzIGV2ZXJ5IDYwcyBzbyBhI
Line truncated
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5vcGVuUmVxdWVzdGVkQ3JlZGVudGlhbEZvcm0gPT09ICdmdW5jdGlvbicpIHtcbiAgICAgIHdpbmRvdy5vcGVuUmVxdWVzdGVkQ3JlZGVudGlhbEZvcm0oKTtcbiAgICB9XG4gICAgYW5pbWF0ZVRvcENhcmRzKCk7XG4gICAgd2luZG93LnJlZnJlc2hBbGwoKTtcbiAgICBzZXRJbnRlcnZhbCgoKSA9PiB7XG4gICAgICAvLyBTdG9wIHBvbGxpbmcgaWYgdGhlIHNlc3Npb24gaGFzIGJlZW4gaW52YWxpZGF0ZWQgKGUuZy4gVE9UUCBnYXRlXG4gICAgICAvLyBub3cgYWN0aXZlLCBvciB1c2VyIGxvZ2dlZCBvdXQpLiBBdm9pZHMgcmVsZW50bGVzcyA0MDEvNDAzIG5vaXNlLlxuICAgICAgaWYgKHdpbmRvdy5fZGNBdXRoTG9zdCkgcmV0dXJuO1xuICAgICAgd2luZG93LnJlZnJlc2hBbGwoKTtcbiAgICB9LCBEQy5QT0xMLkRBU0hCT0FSRCk7XG4gICAgaWYgKHR5cGVvZiB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hDcmVkc0J1dHRvbnMoKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMgPT09ICdmdW5jdGlvbicpIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMoKTtcbiAgICAvLyBVcGRhdGUgYXV0aCBjYXJkIChtYXkgaGF2ZSBhbHJlYWR5IGJlZW4gdXBkYXRlZCBieSB0aGUgYXV0by1sb2FkIElJRkUgYnV0IGVuc3VyZSBpdCdzIGNvcnJlY3QpXG4gICAgaWYgKHR5cGVvZiB3aW5kb3cuX3VwZGF0ZUF1dGhDYXJkID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB0cnkge1xuICAgICAgICBjb25zdCByID0gYXdhaXQgZmV0Y2goJy9hcGkvdjEvdG90cC9jb25maWcnLCB7IGNhY2hlOiAnbm8tc3RvcmUnIH0pO1xuICAgICAgICBjb25zdCBkID0gYXdhaXQgci5qc29uKCk7XG4gICAgICAgIGlmIChkLnN1Y2Nlc3MpIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQoZC5jb25maWcuZW5hYmxlZCAmJiBkLmNvbmZpZy5pc1NldFVwLCBkLmNvbmZpZy5zZXNzaW9uRHVyYXRpb24pO1xuICAgICAgfSBjYXRjaCAoZSkgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICBpZiAod2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZCkge1xuICAgICAgdHJ5IHtcbiAgICAgICAgYXdhaXQgd2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZDtcbiAgICAgIH0gY2F0Y2ggKF8pIHsgLyogaWdub3JlICovIH1cbiAgICB9XG4gICAgLy8gTGF6eS1sb2FkIG9uYm9hcmRpbmcgb25seSBvbmNlIHBlciBpbnN0YWxsLCBvdGhlcndpc2UganVzdCBhZGQgdGhlIHRvdXIgYnV0dG9uXG4gICAgYWRkVG91ckJ1dHRvbigpO1xuICAgIGlmIChzaG91bGRMb2FkT25ib2FyZGluZygpKSB7XG4gICAgICBsb2FkT25ib2FyZGluZygpO1xuICAgIH1cblxuICAgIC8vIERDLTA0ODogaW5qZWN0IHRoZSBcIkFkbWluXCIgdHJpZ2dlciBidXR0b24gaW50b
Line truncated
|
||||
+65
-4
@@ -216,8 +216,8 @@
|
||||
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
||||
));
|
||||
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
||||
_el('input', { name: 'sendEmail', type: 'checkbox', checked: true }),
|
||||
_el('span', { text: 'Send email' }),
|
||||
_el('input', { name: 'sendEmail', type: 'checkbox', checked: false }),
|
||||
_el('span', { text: 'Also send via email (optional)' }),
|
||||
));
|
||||
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
||||
container.appendChild(form);
|
||||
@@ -297,16 +297,77 @@
|
||||
},
|
||||
});
|
||||
banner.appendChild(copyBtn);
|
||||
if (invite.deliveredVia === 'dev-console') {
|
||||
|
||||
// DC-085: pre-formatted message for one-tap paste into iMessage / WhatsApp /
|
||||
// Telegram / SMS / Signal / Discord / paste-into-email. The operator can
|
||||
// copy this as a sentence instead of dealing with the raw URL.
|
||||
if (invite.shareText) {
|
||||
const shareBlock = _el('div', { style: 'margin-top:12px' });
|
||||
shareBlock.appendChild(_el('div', {
|
||||
style: 'font-size:0.8rem;color:#86efac;margin-bottom:4px',
|
||||
text: 'Share this message:',
|
||||
}));
|
||||
shareBlock.appendChild(_el('div', {
|
||||
style: 'padding:8px;background:#000;border-radius:4px;color:#d1fae5;white-space:pre-wrap',
|
||||
text: invite.shareText,
|
||||
}));
|
||||
const shareActions = _el('div', { style: 'margin-top:6px;display:flex;gap:6px;flex-wrap:wrap' });
|
||||
const copyTextBtn = _el('button', {
|
||||
class: 'btn-sm', style: 'padding:4px 10px',
|
||||
text: 'Copy message',
|
||||
onclick: async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.shareText);
|
||||
copyTextBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyTextBtn.textContent = 'Copy message'; }, 2000);
|
||||
} catch (e) {
|
||||
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the text manually.');
|
||||
}
|
||||
},
|
||||
});
|
||||
shareActions.appendChild(copyTextBtn);
|
||||
// Native share sheet on mobile / supported browsers. Falls back silently
|
||||
// (the copy buttons cover the same intent).
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||
const nativeShareBtn = _el('button', {
|
||||
class: 'btn-sm', style: 'padding:4px 10px',
|
||||
text: 'Share via…',
|
||||
onclick: async () => {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'DashCaddy invite',
|
||||
text: invite.shareText,
|
||||
url: invite.acceptUrl,
|
||||
});
|
||||
} catch (e) {
|
||||
// User-cancelled throws AbortError — that's fine, just stay quiet.
|
||||
if (e && e.name && e.name !== 'AbortError') {
|
||||
window.errorHandler && window.errorHandler.show('Share failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
shareActions.appendChild(nativeShareBtn);
|
||||
}
|
||||
shareBlock.appendChild(shareActions);
|
||||
banner.appendChild(shareBlock);
|
||||
}
|
||||
|
||||
if (invite.deliveredVia === 'failed') {
|
||||
banner.appendChild(_el('p', {
|
||||
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
||||
text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
|
||||
text: 'Email could not be sent (SMTP not configured). Share the link above instead — it works the same way.',
|
||||
}));
|
||||
} else if (invite.deliveredVia === 'email') {
|
||||
banner.appendChild(_el('p', {
|
||||
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||
text: 'Email sent to ' + invite.email + '.',
|
||||
}));
|
||||
} else if (invite.deliveredVia === 'manual') {
|
||||
banner.appendChild(_el('p', {
|
||||
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||
text: 'Share the link above via text, chat, or any messenger.',
|
||||
}));
|
||||
}
|
||||
parent.appendChild(banner);
|
||||
}
|
||||
|
||||
+46
-2
@@ -267,6 +267,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
function buildSsoHandoffTarget(returnUrl, token) {
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) return parsed.toString();
|
||||
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
|
||||
if (parsed.protocol !== 'https:' || !isPrivateHost || !token) return null;
|
||||
|
||||
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
parsed.pathname = '/dashcaddy-sso';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
parsed.searchParams.set('token', token);
|
||||
parsed.searchParams.set('return', returnPath);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function resumeExistingSession(returnUrl) {
|
||||
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
|
||||
try {
|
||||
const parsedReturn = new URL(returnUrl, window.location.origin);
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
const serviceId = parsedReturn.hostname.slice(0, -suffix.length);
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) return false;
|
||||
const res = await fetch(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(serviceId)}`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
const target = data.success && buildSsoHandoffTarget(returnUrl, data.ssoToken);
|
||||
if (!target) return false;
|
||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||
window.location.replace(target);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('auth') === 'required') {
|
||||
// Preserve the gated service destination so submitTotpCode() can append
|
||||
@@ -277,8 +317,12 @@
|
||||
}
|
||||
// Clean URL — happens after we've captured the redirect
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
// Show on next tick so the DOM (the .totp-card) is ready
|
||||
setTimeout(show, 0);
|
||||
// Reuse the valid status.sami session first. Only show the TOTP/provider
|
||||
// challenge when that session is genuinely absent or expired.
|
||||
setTimeout(async () => {
|
||||
if (await resumeExistingSession(returnUrl)) return;
|
||||
await show();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Expose for hot-trigger from other modules (e.g. logout)
|
||||
|
||||
@@ -33,6 +33,20 @@
|
||||
return server?.name || dnsId.toUpperCase();
|
||||
}
|
||||
|
||||
async function requireSuccessfulDnsMutation(response, label) {
|
||||
if (!response) throw new Error(`${label} failed: no response`);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_) {
|
||||
throw new Error(`${label} failed: invalid server response`);
|
||||
}
|
||||
if (!response.ok || data?.success !== true) {
|
||||
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Build per-server credential form sections from SITE.dnsServers */
|
||||
function buildCredentialSections() {
|
||||
const container = document.getElementById('dns-cred-sections');
|
||||
@@ -258,14 +272,6 @@
|
||||
document.getElementById('token-save')?.addEventListener('click', async () => {
|
||||
const dnsIds = getDnsIds();
|
||||
|
||||
// Save all to localStorage
|
||||
dnsIds.forEach(dnsId => {
|
||||
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
||||
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
||||
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
||||
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
||||
});
|
||||
|
||||
// Build per-server credentials payload for backend sync
|
||||
const servers = {};
|
||||
let hasAnyCreds = false;
|
||||
@@ -304,45 +310,36 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ servers })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
|
||||
if (data.results) {
|
||||
dnsIds.forEach(dnsId => {
|
||||
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
||||
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
||||
const result = data.results[dnsId];
|
||||
if (result?.success) {
|
||||
statusEl.textContent = '\u2713 Verified & saved';
|
||||
statusEl.className = 'token-status success';
|
||||
} else if (result?.partial) {
|
||||
statusEl.textContent = '\u2713 ' + result.partial;
|
||||
statusEl.className = 'token-status success';
|
||||
} else {
|
||||
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
|
||||
statusEl.className = 'token-status error';
|
||||
}
|
||||
});
|
||||
} else if (data.success) {
|
||||
dnsIds.forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
dnsIds.forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||
}
|
||||
});
|
||||
const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
|
||||
if (failed.length) {
|
||||
const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
|
||||
throw new Error(details);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache locally only after the encrypted server vault confirms success.
|
||||
dnsIds.forEach(dnsId => {
|
||||
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
||||
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
||||
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
||||
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
||||
});
|
||||
|
||||
dnsIds.forEach(dnsId => {
|
||||
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
||||
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
||||
const result = data.results?.[dnsId];
|
||||
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
|
||||
statusEl.className = 'token-status success';
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to sync DNS credentials to backend:', e);
|
||||
dnsIds.forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -368,18 +365,24 @@
|
||||
|
||||
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
||||
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
||||
clearAllCredentials();
|
||||
getDnsIds().forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
||||
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-username`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-token`).value = '';
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||
});
|
||||
try {
|
||||
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
||||
} catch (_) {}
|
||||
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
||||
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
|
||||
clearAllCredentials();
|
||||
getDnsIds().forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
||||
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-username`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-token`).value = '';
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||
});
|
||||
} catch (e) {
|
||||
getDnsIds().forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
await window.loadServices();
|
||||
await loadTemplateCategories();
|
||||
window.buildGrid();
|
||||
if (typeof window.openRequestedCredentialForm === 'function') {
|
||||
window.openRequestedCredentialForm();
|
||||
}
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(() => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// ===== ENCRYPTED VAULT -> SERVICE SSO HANDOFF =====
|
||||
(function() {
|
||||
function isAllowedReturnUrl(returnUrl, expectedServiceId) {
|
||||
if (!returnUrl || !expectedServiceId || !/^[a-z0-9][a-z0-9-]*$/.test(expectedServiceId)) return false;
|
||||
try {
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
const expectedHost = `${expectedServiceId}${suffix}`;
|
||||
return parsed.protocol === 'https:' && parsed.hostname === expectedHost;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHandoffTarget(returnUrl, token, expectedServiceId) {
|
||||
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return null;
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
if (!token) return null;
|
||||
|
||||
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
// The shared (dashcaddy_auth) Caddy snippet installs this public landing
|
||||
// route on every protected host. It rewrites to /api/v1/auth/sso-exchange.
|
||||
parsed.pathname = '/dashcaddy-sso';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
parsed.searchParams.set('token', token);
|
||||
parsed.searchParams.set('return', returnPath);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function resume(returnUrl, expectedServiceId, runtime = {}) {
|
||||
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return false;
|
||||
const fetchFn = runtime.fetch || window.fetch.bind(window);
|
||||
const locationObj = runtime.location || window.location;
|
||||
try {
|
||||
const response = await fetchFn(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(expectedServiceId)}`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
const target = data.success && buildHandoffTarget(returnUrl, data.ssoToken, expectedServiceId);
|
||||
if (!target) return false;
|
||||
locationObj.replace(target);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
window.DCCredentialVault = { isAllowedReturnUrl, buildHandoffTarget, resume };
|
||||
})();
|
||||
@@ -32,8 +32,8 @@
|
||||
|
||||
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
||||
<div class="service-creds-content">
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||
@@ -91,7 +91,7 @@
|
||||
<!-- Buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||
Save
|
||||
Save to encrypted vault
|
||||
</button>
|
||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||
Clear
|
||||
@@ -105,6 +105,8 @@
|
||||
|
||||
const modal = document.getElementById('service-creds-modal');
|
||||
let currentService = null;
|
||||
let credentialReturnUrl = null;
|
||||
let currentServiceHadCreds = false;
|
||||
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
||||
const qualityProfileServices = ['sonarr', 'radarr'];
|
||||
|
||||
@@ -124,8 +126,28 @@
|
||||
el.style.display = 'none';
|
||||
}
|
||||
|
||||
window.openServiceCredsModal = async function(service) {
|
||||
async function requireSuccessfulWrite(response, label) {
|
||||
if (!response) throw new Error(`${label} failed: no response`);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_) {
|
||||
throw new Error(`${label} failed: invalid server response`);
|
||||
}
|
||||
if (!response.ok || data?.success !== true) {
|
||||
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function isAllowedCredentialReturnUrl(returnUrl, serviceId) {
|
||||
return !!window.DCCredentialVault?.isAllowedReturnUrl(returnUrl, serviceId);
|
||||
}
|
||||
|
||||
window.openServiceCredsModal = async function(service, options = {}) {
|
||||
currentService = service;
|
||||
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
|
||||
currentServiceHadCreds = false;
|
||||
hideError();
|
||||
const title = document.getElementById('svc-creds-title');
|
||||
const desc = document.getElementById('svc-creds-desc');
|
||||
@@ -134,7 +156,10 @@
|
||||
const basicSection = document.getElementById('svc-creds-basic');
|
||||
const qualitySection = document.getElementById('svc-creds-quality');
|
||||
|
||||
title.textContent = service.name + ' Credentials';
|
||||
title.textContent = service.name + ' — Encrypted Vault';
|
||||
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
|
||||
? 'Save to vault & open service'
|
||||
: 'Save to encrypted vault';
|
||||
// Determine which sections to show
|
||||
const isExt = !!service.isExternal;
|
||||
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
||||
@@ -214,6 +239,7 @@
|
||||
}
|
||||
|
||||
if (hasCreds) {
|
||||
currentServiceHadCreds = true;
|
||||
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
||||
status.style.color = 'var(--ok-fg, #74dfc4)';
|
||||
status.textContent = 'Credentials stored';
|
||||
@@ -352,16 +378,35 @@
|
||||
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
||||
const svcId = currentService.id || currentService.appTemplate;
|
||||
|
||||
if (credentialReturnUrl && !currentServiceHadCreds) {
|
||||
const externalUser = document.getElementById('svc-seedhost-user').value.trim();
|
||||
const externalPass = document.getElementById('svc-seedhost-pass').value;
|
||||
const apiKeyInput = document.getElementById('svc-apikey-input');
|
||||
const requestedApiKey = apiKeyInput?.value.trim();
|
||||
const basicUser = document.getElementById('svc-basic-user').value.trim();
|
||||
const basicPass = document.getElementById('svc-basic-pass').value;
|
||||
const hasExternalLogin = currentService.isExternal && externalUser && externalPass;
|
||||
const hasApiKey = isArr && requestedApiKey && requestedApiKey !== '••••••••';
|
||||
const hasBasicLogin = !currentService.isExternal && basicUser && basicPass;
|
||||
if (!hasExternalLogin && !hasApiKey && !hasBasicLogin) {
|
||||
showError('Enter the login or API key DashCaddy should store for this service.');
|
||||
saveBtn.textContent = 'Save to vault & open service';
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save seedhost creds (shared username + per-service password)
|
||||
if (currentService.isExternal) {
|
||||
const user = document.getElementById('svc-seedhost-user').value.trim();
|
||||
const pass = document.getElementById('svc-seedhost-pass').value;
|
||||
if (user) {
|
||||
await secureFetch('/api/v1/seedhost-creds', {
|
||||
const response = await secureFetch('/api/v1/seedhost-creds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Seedhost credential save');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,23 +432,18 @@
|
||||
qualityProfileName: qualityProfileName || undefined
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
showError(data.error || 'Failed to save API key');
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const data = await requireSuccessfulWrite(res, 'ARR credential save');
|
||||
if (data.connectionTest && !data.connectionTest.success) {
|
||||
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
||||
}
|
||||
} else {
|
||||
// Non-arr services use the generic endpoint
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'API key save');
|
||||
}
|
||||
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
||||
// API key unchanged but user may have changed quality profile — save profile only
|
||||
@@ -411,11 +451,12 @@
|
||||
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
||||
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
||||
if (qualityProfileId) {
|
||||
await secureFetch('/api/v1/arr/quality-profiles', {
|
||||
const response = await secureFetch('/api/v1/arr/quality-profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Quality profile save');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,20 +465,28 @@
|
||||
const user = document.getElementById('svc-basic-user').value.trim();
|
||||
const pass = document.getElementById('svc-basic-pass').value;
|
||||
if (user && pass) {
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Service credential save');
|
||||
}
|
||||
}
|
||||
|
||||
await loadServiceCreds(currentService);
|
||||
if (credentialReturnUrl) {
|
||||
const returnUrl = credentialReturnUrl;
|
||||
const resumed = await window.DCCredentialVault?.resume(returnUrl, currentService.id);
|
||||
if (!resumed) throw new Error('Credential saved, but the secure service handoff failed. Try opening the service again.');
|
||||
credentialReturnUrl = null;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
||||
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
@@ -450,12 +499,15 @@
|
||||
const svcId = currentService.id || currentService.appTemplate;
|
||||
const isArr = arrServices.includes(svcId);
|
||||
if (currentService.isExternal) {
|
||||
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||
const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(response, 'Seedhost credential removal');
|
||||
}
|
||||
// Delete from both namespaces
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(response, 'Service credential removal');
|
||||
if (isArr) {
|
||||
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||
const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
|
||||
}
|
||||
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
||||
if (btn) btn.classList.remove('has-creds');
|
||||
@@ -470,11 +522,13 @@
|
||||
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
currentService = null;
|
||||
credentialReturnUrl = null;
|
||||
});
|
||||
modal?.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.remove('show');
|
||||
currentService = null;
|
||||
credentialReturnUrl = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -501,4 +555,18 @@
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Protected service login pages send missing credentials here. Reuse the
|
||||
// normal vault form, then resume through the existing one-time SSO handoff.
|
||||
window.openRequestedCredentialForm = function() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const serviceId = params.get('credentials');
|
||||
if (!serviceId) return false;
|
||||
const service = (window.APPS || []).find(app => app.id === serviceId || app.appTemplate === serviceId);
|
||||
if (!service) return false;
|
||||
const returnUrl = params.get('return');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
window.openServiceCredsModal(service, { returnUrl });
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
+12
-2
@@ -90,11 +90,22 @@
|
||||
errorEl.textContent = 'Verifying...';
|
||||
errorEl.className = 'totp-error verifying';
|
||||
|
||||
const redirect = safeSessionGet('totp_redirect');
|
||||
let serviceId = null;
|
||||
if (redirect) {
|
||||
try {
|
||||
const parsed = new URL(redirect, window.location.origin);
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
const candidate = parsed.hostname.slice(0, -suffix.length);
|
||||
if (parsed.hostname.endsWith(suffix) && /^[a-z0-9][a-z0-9-]*$/.test(candidate)) serviceId = candidate;
|
||||
} catch (_) { /* invalid redirect is handled by the normal auth flow */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/totp/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
body: JSON.stringify({ code, serviceId })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -106,7 +117,6 @@
|
||||
}
|
||||
hideTotpOverlay();
|
||||
// Check if redirected here from another service
|
||||
const redirect = safeSessionGet('totp_redirect');
|
||||
if (redirect) {
|
||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||
// .sami is an unregistered TLD, so browsers silently drop the
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-a24ef15882';
|
||||
const CACHE = 'dashcaddy-shell-497e1f671c';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
@@ -93,3 +93,41 @@ test('same-origin and tokenless destinations keep their direct URL', () => {
|
||||
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
||||
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
||||
});
|
||||
|
||||
test('an existing status.sami session returns to a service without another TOTP prompt', async () => {
|
||||
const query = new URLSearchParams({ auth: 'required', return: 'https://plex.sami/web/' });
|
||||
let scheduled;
|
||||
let redirected;
|
||||
const location = {
|
||||
origin: 'https://status.sami',
|
||||
pathname: '/',
|
||||
search: `?${query.toString()}`,
|
||||
replace(value) { redirected = value; },
|
||||
};
|
||||
const context = {
|
||||
URL,
|
||||
URLSearchParams,
|
||||
SITE: { tld: '.sami' },
|
||||
sessionStorage: { setItem() {} },
|
||||
document: { getElementById() { return null; } },
|
||||
setTimeout(fn) { scheduled = fn; },
|
||||
console,
|
||||
fetch: async (url) => {
|
||||
assert.equal(url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
||||
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
|
||||
},
|
||||
window: {
|
||||
location,
|
||||
history: { replaceState() {} },
|
||||
},
|
||||
};
|
||||
context.window.window = context.window;
|
||||
vm.runInNewContext(source, context, { filename: 'auth-gate.js' });
|
||||
|
||||
assert.equal(typeof scheduled, 'function');
|
||||
await scheduled();
|
||||
assert.equal(
|
||||
redirected,
|
||||
'https://plex.sami/dashcaddy-sso?token=existing-session-token&return=%2Fweb%2F',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { JSDOM } = require('jsdom');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const handoffSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'credential-vault-handoff.js'), 'utf8');
|
||||
const formSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'service-credentials.js'), 'utf8');
|
||||
const initSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'init.js'), 'utf8');
|
||||
|
||||
function loadVault() {
|
||||
const window = { location: { origin: 'https://status.sami' } };
|
||||
const context = vm.createContext({ window, SITE: { tld: '.sami' }, URL });
|
||||
vm.runInContext(handoffSource, context);
|
||||
return window.DCCredentialVault;
|
||||
}
|
||||
|
||||
async function exerciseFailedModalWrite({
|
||||
service,
|
||||
fetchJson,
|
||||
setupInputs,
|
||||
expectedEndpoint,
|
||||
writeResponse,
|
||||
expectedError = /vault write rejected/,
|
||||
}) {
|
||||
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||
url: 'https://status.sami/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const { window } = dom;
|
||||
const writeUrls = [];
|
||||
let resumeCalls = 0;
|
||||
window.ErrorHandler = class { logError() {} };
|
||||
window.SITE = { tld: '.sami' };
|
||||
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||
window.fetch = async (url) => ({ ok: true, json: async () => fetchJson(url) });
|
||||
window.secureFetch = async (url) => {
|
||||
writeUrls.push(url);
|
||||
return writeResponse || {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ success: false, error: 'vault write rejected' }),
|
||||
};
|
||||
};
|
||||
window.DCCredentialVault = {
|
||||
isAllowedReturnUrl: () => true,
|
||||
resume: async () => { resumeCalls++; return true; },
|
||||
};
|
||||
window.confirm = () => true;
|
||||
window.eval(formSource);
|
||||
|
||||
await window.openServiceCredsModal(service, { returnUrl: `https://${service.id}.sami/` });
|
||||
setupInputs(window.document);
|
||||
window.document.getElementById('svc-creds-save').click();
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
assert.equal(writeUrls[0], expectedEndpoint);
|
||||
assert.equal(resumeCalls, 0);
|
||||
assert.match(window.document.getElementById('svc-creds-error').textContent, expectedError);
|
||||
}
|
||||
|
||||
test('existing dashboard session mints a one-time token and resumes on the target host', async () => {
|
||||
const vault = loadVault();
|
||||
const calls = [];
|
||||
const replacements = [];
|
||||
const resumed = await vault.resume('https://plex.sami/web/?direct=1#home', 'plex', {
|
||||
fetch: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ success: true, ssoToken: 'one-time-token' }),
|
||||
};
|
||||
},
|
||||
location: { replace: (target) => replacements.push(target) },
|
||||
});
|
||||
|
||||
assert.equal(resumed, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
||||
assert.equal(calls[0].options.credentials, 'include');
|
||||
assert.equal(calls[0].options.cache, 'no-store');
|
||||
assert.equal(
|
||||
replacements[0],
|
||||
'https://plex.sami/dashcaddy-sso?token=one-time-token&return=%2Fweb%2F%3Fdirect%3D1%23home',
|
||||
);
|
||||
});
|
||||
|
||||
test('vault handoff rejects an external return URL before minting a token', async () => {
|
||||
const vault = loadVault();
|
||||
let fetchCalled = false;
|
||||
const resumed = await vault.resume('https://plex.sami.evil.example/phish', 'plex', {
|
||||
fetch: async () => { fetchCalled = true; },
|
||||
location: { replace: () => assert.fail('must not navigate') },
|
||||
});
|
||||
|
||||
assert.equal(resumed, false);
|
||||
assert.equal(fetchCalled, false);
|
||||
});
|
||||
|
||||
test('credential request opens the form and save path calls the tested handoff helper', () => {
|
||||
assert.match(formSource, /params\.get\('credentials'\)/);
|
||||
assert.match(formSource, /openServiceCredsModal\(service, \{ returnUrl \}\)/);
|
||||
assert.match(formSource, /DCCredentialVault\?\.resume\(returnUrl, currentService\.id\)/);
|
||||
assert.match(initSource, /openRequestedCredentialForm\(\)/);
|
||||
assert.match(formSource, /Save to vault & open service/);
|
||||
});
|
||||
|
||||
test('actual vault modal save handler stores credentials then resumes the handoff', async () => {
|
||||
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||
url: 'https://status.sami/?credentials=plex&return=https%3A%2F%2Fplex.sami%2Fweb%2F',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const { window } = dom;
|
||||
let stored = false;
|
||||
const writes = [];
|
||||
const resumed = [];
|
||||
window.ErrorHandler = class { logError() {} };
|
||||
window.SITE = { tld: '.sami' };
|
||||
window.APPS = [{ id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' }];
|
||||
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||
window.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
hasApiKey: false,
|
||||
hasBasicAuth: stored,
|
||||
username: stored ? 'vault-user' : null,
|
||||
}),
|
||||
});
|
||||
window.secureFetch = async (url, options) => {
|
||||
writes.push({ url, body: JSON.parse(options.body) });
|
||||
stored = true;
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
};
|
||||
window.DCCredentialVault = {
|
||||
isAllowedReturnUrl: () => true,
|
||||
resume: async (returnUrl, serviceId) => { resumed.push({ returnUrl, serviceId }); return true; },
|
||||
};
|
||||
window.confirm = () => true;
|
||||
window.eval(formSource);
|
||||
|
||||
await window.openServiceCredsModal(window.APPS[0], { returnUrl: 'https://plex.sami/web/' });
|
||||
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
||||
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
||||
window.document.getElementById('svc-creds-save').click();
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
assert.deepEqual(writes, [{
|
||||
url: '/api/v1/services/plex/credentials',
|
||||
body: { username: 'vault-user', password: 'vault-password' },
|
||||
}]);
|
||||
assert.deepEqual(resumed, [{ returnUrl: 'https://plex.sami/web/', serviceId: 'plex' }]);
|
||||
});
|
||||
|
||||
test('failed credential write does not mint a handoff or navigate', async () => {
|
||||
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||
url: 'https://status.sami/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const { window } = dom;
|
||||
let resumeCalls = 0;
|
||||
window.ErrorHandler = class { logError() {} };
|
||||
window.SITE = { tld: '.sami' };
|
||||
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||
window.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||
});
|
||||
window.secureFetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ success: false, error: 'vault write rejected' }),
|
||||
});
|
||||
window.DCCredentialVault = {
|
||||
isAllowedReturnUrl: () => true,
|
||||
resume: async () => { resumeCalls++; return true; },
|
||||
};
|
||||
window.confirm = () => true;
|
||||
window.eval(formSource);
|
||||
|
||||
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
||||
await window.openServiceCredsModal(service, { returnUrl: 'https://plex.sami/web/' });
|
||||
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
||||
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
||||
window.document.getElementById('svc-creds-save').click();
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
assert.equal(resumeCalls, 0);
|
||||
assert.match(window.document.getElementById('svc-creds-error').textContent, /vault write rejected/);
|
||||
});
|
||||
|
||||
test('failed ARR credential write does not mint a handoff or navigate', async () => {
|
||||
await exerciseFailedModalWrite({
|
||||
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
||||
fetchJson: (url) => url.includes('/services/')
|
||||
? { success: true, hasApiKey: false, hasBasicAuth: false, username: null }
|
||||
: { success: true, profiles: [] },
|
||||
setupInputs: (document) => { document.getElementById('svc-apikey-input').value = 'arr-key'; },
|
||||
expectedEndpoint: '/api/v1/arr/credentials',
|
||||
});
|
||||
});
|
||||
|
||||
test('failed ARR quality-profile write does not mint a handoff or navigate', async () => {
|
||||
await exerciseFailedModalWrite({
|
||||
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
||||
fetchJson: (url) => url.includes('/services/')
|
||||
? { success: true, hasApiKey: true, hasBasicAuth: false, username: null }
|
||||
: { success: true, profiles: [{ id: 1, name: 'Default' }], storedProfileId: 1 },
|
||||
setupInputs: () => {},
|
||||
expectedEndpoint: '/api/v1/arr/quality-profiles',
|
||||
});
|
||||
});
|
||||
|
||||
test('failed seedhost write does not mint a handoff or navigate', async () => {
|
||||
await exerciseFailedModalWrite({
|
||||
service: { id: 'torrent', name: 'qBittorrent', isExternal: true, externalUrl: 'https://torrent.sami' },
|
||||
fetchJson: (url) => url.includes('/seedhost-creds')
|
||||
? { success: true, hasCredentials: false, username: null }
|
||||
: { success: true, hasApiKey: false, hasBasicAuth: false, username: null },
|
||||
setupInputs: (document) => {
|
||||
document.getElementById('svc-seedhost-user').value = 'seed-user';
|
||||
document.getElementById('svc-seedhost-pass').value = 'seed-password';
|
||||
},
|
||||
expectedEndpoint: '/api/v1/seedhost-creds',
|
||||
});
|
||||
});
|
||||
|
||||
test('failed generic API-key write does not mint a handoff or navigate', async () => {
|
||||
await exerciseFailedModalWrite({
|
||||
service: { id: 'custom', name: 'Custom', url: 'https://custom.sami' },
|
||||
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||
setupInputs: (document) => {
|
||||
document.getElementById('svc-apikey-input').value = 'custom-key';
|
||||
document.getElementById('svc-basic-user').value = 'user';
|
||||
document.getElementById('svc-basic-pass').value = 'password';
|
||||
},
|
||||
expectedEndpoint: '/api/v1/services/custom/credentials',
|
||||
});
|
||||
});
|
||||
|
||||
test('HTTP 2xx with malformed JSON does not mint a handoff or navigate', async () => {
|
||||
await exerciseFailedModalWrite({
|
||||
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
||||
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||
setupInputs: (document) => {
|
||||
document.getElementById('svc-basic-user').value = 'user';
|
||||
document.getElementById('svc-basic-pass').value = 'password';
|
||||
},
|
||||
expectedEndpoint: '/api/v1/services/plex/credentials',
|
||||
writeResponse: { ok: true, status: 200, json: async () => { throw new Error('bad json'); } },
|
||||
expectedError: /invalid server response/,
|
||||
});
|
||||
});
|
||||
|
||||
test('HTTP 2xx without success:true does not mint a handoff or navigate', async () => {
|
||||
await exerciseFailedModalWrite({
|
||||
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
||||
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||
setupInputs: (document) => {
|
||||
document.getElementById('svc-basic-user').value = 'user';
|
||||
document.getElementById('svc-basic-pass').value = 'password';
|
||||
},
|
||||
expectedEndpoint: '/api/v1/services/plex/credentials',
|
||||
writeResponse: { ok: true, status: 200, json: async () => ({ message: 'ambiguous' }) },
|
||||
expectedError: /failed \(200\)/,
|
||||
});
|
||||
});
|
||||
|
||||
test('failed credential clear remains visibly failed and keeps stored-state UI', async () => {
|
||||
const dom = new JSDOM('<!doctype html><body><button id="creds-btn-plex" class="has-creds"></button></body>', {
|
||||
url: 'https://status.sami/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const { window } = dom;
|
||||
window.ErrorHandler = class { logError() {} };
|
||||
window.SITE = { tld: '.sami' };
|
||||
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||
window.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: true, username: 'vault-user' }),
|
||||
});
|
||||
window.secureFetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ success: false, error: 'clear rejected' }),
|
||||
});
|
||||
window.DCCredentialVault = { isAllowedReturnUrl: () => false };
|
||||
window.confirm = () => true;
|
||||
window.eval(formSource);
|
||||
|
||||
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
||||
await window.openServiceCredsModal(service);
|
||||
window.document.getElementById('svc-creds-clear').click();
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
assert.match(window.document.getElementById('svc-creds-error').textContent, /clear rejected/);
|
||||
assert.equal(window.document.getElementById('creds-btn-plex').classList.contains('has-creds'), true);
|
||||
});
|
||||
|
||||
test('handoff rejects a private-TLD host that is not the requested protected service', async () => {
|
||||
const vault = loadVault();
|
||||
let fetchCalled = false;
|
||||
const resumed = await vault.resume('https://dns1.sami/', 'plex', {
|
||||
fetch: async () => { fetchCalled = true; },
|
||||
location: { replace: () => assert.fail('must not navigate') },
|
||||
});
|
||||
|
||||
assert.equal(resumed, false);
|
||||
assert.equal(fetchCalled, false);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { JSDOM } = require('jsdom');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'credentials.js'), 'utf8');
|
||||
|
||||
function buildDnsCredentialUi() {
|
||||
const dom = new JSDOM('<!doctype html><body><button id="manage-tokens"></button></body>', {
|
||||
url: 'https://status.sami/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const { window } = dom;
|
||||
const local = new Map();
|
||||
const session = new Map();
|
||||
window.SITE = { dnsServers: { dns1: { name: 'Primary DNS' } } };
|
||||
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||
window.safeGet = key => local.get(key) || null;
|
||||
window.safeSet = (key, value) => local.set(key, value);
|
||||
window.safeRemove = key => local.delete(key);
|
||||
window.safeSessionGet = key => session.get(key) || null;
|
||||
window.safeSessionSet = (key, value) => session.set(key, value);
|
||||
window.closeModal = () => {};
|
||||
window.confirm = () => true;
|
||||
window.TextEncoder = TextEncoder;
|
||||
window.setTimeout = () => 1;
|
||||
window.eval(source);
|
||||
window.document.getElementById('manage-tokens').click();
|
||||
return { window, local };
|
||||
}
|
||||
|
||||
test('failed DNS credential save never populates browser cache or success UI', async () => {
|
||||
const { window, local } = buildDnsCredentialUi();
|
||||
window.secureFetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ success: false, error: 'DNS vault rejected' }),
|
||||
});
|
||||
window.document.getElementById('dns1-admin-username').value = 'dns-admin';
|
||||
window.document.getElementById('dns1-admin-token').value = 'dns-password';
|
||||
window.document.getElementById('token-save').click();
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
assert.equal(local.has('dns1-admin-username-enc'), false);
|
||||
assert.equal(local.has('dns1-admin-token-enc'), false);
|
||||
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS vault rejected/);
|
||||
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
|
||||
});
|
||||
|
||||
test('failed DNS credential clear preserves cached state and shows error', async () => {
|
||||
const { window, local } = buildDnsCredentialUi();
|
||||
local.set('dns1-admin-username-enc', 'existing-user');
|
||||
local.set('dns1-admin-token-enc', 'existing-password');
|
||||
window.secureFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ message: 'ambiguous response' }),
|
||||
});
|
||||
window.document.getElementById('token-clear-all').click();
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
assert.equal(local.has('dns1-admin-username-enc'), true);
|
||||
assert.equal(local.has('dns1-admin-token-enc'), true);
|
||||
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS credential removal failed/);
|
||||
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
|
||||
});
|
||||
Reference in new issue
Block a user