diff --git a/DESLOPIFICATION-ROADMAP.md b/DESLOPIFICATION-ROADMAP.md new file mode 100644 index 0000000..da60a6b --- /dev/null +++ b/DESLOPIFICATION-ROADMAP.md @@ -0,0 +1,388 @@ +# DashCaddy API Deslopification Roadmap + +**Audited:** 2026-03-22 +**Version:** 1.1.0 +**Total Lines:** ~26,000 (API), ~10,000 (dashboard) +**Priority:** API-first (make backend powerful, clean dashboard follows naturally) + +--- + +## Executive Summary + +The DashCaddy API is **feature-complete and security-hardened**, but the codebase shows signs of rapid evolution. While functionally robust, it would significantly benefit from architectural refactoring to improve maintainability, testability, and long-term scalability. + +### Key Strengths +✅ Comprehensive feature set (76+ app templates, Docker/Caddy/DNS management) +✅ Security-conscious (TOTP auth, AES-256-GCM credentials, CSRF protection, audit logging) +✅ Recent test coverage additions (auth, credentials, Docker security) +✅ Modular route organization (routes/ subdirectories) +✅ Shared context pattern for dependency injection + +### Core Issues +❌ **Monolithic `server.js`** (1960 lines) — initialization, middleware, utilities, business logic all in one file +❌ **God object `ctx`** — 50+ properties/methods across multiple domains with hidden dependencies +❌ **Inconsistent patterns** — routes use classes, factory functions, or flat modules with no standard +❌ **No code standards** — ESLint installed but no config, no formatting rules +❌ **Mixed concerns** — HTTP handlers, business logic, validation intertwined in route files + +--- + +## Current Architecture + +``` +dashcaddy-api/ +├── server.js (1960 lines) ← MAIN PROBLEM +│ ├── 89 require() statements +│ ├── 131 top-level declarations +│ ├── Middleware setup +│ ├── Context (`ctx`) assembly (50+ properties) +│ ├── Route mounting +│ ├── Error handlers +│ └── Server startup +├── routes/ +│ ├── auth/ (5 files, modular) ✅ +│ ├── config/ (4 files, modular) ✅ +│ ├── apps/ (6 files, helpers pattern) ⚠️ +│ ├── arr/ (4 files, helpers pattern) ⚠️ +│ ├── recipes/ (3 files) ⚠️ +│ └── *.js (19 flat route files) ❌ +├── Managers (clean, well-separated) +│ ├── auth-manager.js (307 lines) ✅ +│ ├── credential-manager.js (395 lines) ✅ +│ ├── state-manager.js (237 lines) ✅ +│ ├── backup-manager.js (835 lines) ⚠️ +│ ├── health-checker.js (591 lines) ⚠️ +│ └── update-manager.js (911 lines) ⚠️ +├── Utilities +│ ├── input-validator.js (606 lines) ⚠️ +│ ├── crypto-utils.js (340 lines) ✅ +│ ├── middleware.js (430 lines) ⚠️ +│ └── constants.js ✅ +└── Templates + ├── app-templates.js (2496 lines) ⚠️ + └── recipe-templates.js (339 lines) ✅ +``` + +**Legend:** +✅ Good structure +⚠️ Works but could be cleaner +❌ Needs refactoring + +--- + +## Deslopification Phases + +### Phase 1: Foundation & Standards (IMMEDIATE) +**Goal:** Establish code quality baseline before refactoring +**Effort:** 2-4 hours +**Risk:** Low (tooling only, no code changes) + +#### 1.1 Code Standards Setup +- [ ] Create `.eslintrc.js` with recommended rules +- [ ] Add Prettier config (`.prettierrc`) +- [ ] Add npm scripts: `lint`, `lint:fix`, `format` +- [ ] Run `npm run lint:fix` and commit baseline cleanup +- [ ] Add pre-commit hooks (optional) + +**Why first:** Establish formatting/style consistency before making structural changes. Prevents "should I refactor this while I'm here?" scope creep. + +#### 1.2 Dependency Graph Documentation +- [ ] Map `ctx` properties → which routes actually use them +- [ ] Identify circular dependencies (if any) +- [ ] Document shared utilities used across routes + +**Deliverable:** `DEPENDENCIES.md` — reference for refactoring decisions + +--- + +### Phase 2: Extract & Organize (HIGH PRIORITY) +**Goal:** Break `server.js` into logical modules +**Effort:** 1-2 days +**Risk:** Medium (requires testing at each step) + +#### 2.1 Split `server.js` into Layers +**Before:** 1960-line monolith +**After:** Clean initialization flow + +Create new structure: +``` +src/ +├── app.js ← Express app setup (middleware, routes) +├── server.js ← Entry point (load config, start server) +├── config/ +│ ├── index.js ← Load all config (env, files, constants) +│ ├── env.js ← Environment variable validation +│ └── paths.js ← Platform-specific paths +├── context/ +│ ├── index.js ← Assemble context (DI container) +│ ├── docker.js ← Docker-related context properties +│ ├── caddy.js ← Caddy-related context properties +│ ├── dns.js ← DNS context +│ ├── session.js ← Session context +│ └── notification.js ← Notification context +├── middleware/ +│ ├── index.js ← Export all middleware +│ ├── auth.js ← Move from middleware.js +│ ├── error.js ← Error handlers +│ └── security.js ← Helmet, CORS, CSRF +└── routes/ + └── (existing structure) +``` + +**Migration Steps:** +1. Create `src/config/` — extract all config loading from `server.js` +2. Create `src/context/` — split god object into domain modules +3. Create `src/middleware/` — break up `middleware.js` (430 lines) +4. Create `src/app.js` — Express setup + route mounting +5. Slim `server.js` → minimal entry point (~50 lines) + +**Tests:** Ensure existing test suite still passes after each step + +--- + +### Phase 3: Route Standardization (MEDIUM PRIORITY) +**Goal:** Consistent route module pattern across entire API +**Effort:** 2-3 days +**Risk:** Medium (touching business logic) + +#### 3.1 Establish Route Pattern +**Chosen Pattern:** Factory function with explicit dependencies + +```javascript +// routes/services.js (before) +module.exports = (ctx) => { + const router = express.Router(); + // ... uses ctx.docker, ctx.servicesStateManager, ctx.log, etc. + return router; +}; + +// routes/services.js (after) +module.exports = ({ docker, servicesStateManager, log, asyncHandler }) => { + const router = express.Router(); + // ... explicitly passed dependencies + return router; +}; +``` + +**Benefits:** +- Self-documenting (you see what each route needs) +- Easier testing (mock only what's used) +- No hidden dependencies via god object + +#### 3.2 Refactor Routes by Priority +**Order:** Most-used routes first + +1. **High-traffic routes:** + - `routes/services.js` (467 lines) — core service management + - `routes/containers.js` (246 lines) — Docker operations + - `routes/health.js` (297 lines) — health checks + - `routes/dns.js` (632 lines) — DNS management + +2. **Auth routes** (already modular, just align pattern): + - `routes/auth/*` + +3. **Feature routes:** + - `routes/apps/*` + - `routes/arr/*` + - `routes/recipes/*` + +4. **Utility routes:** + - `routes/logs.js` + - `routes/backups.js` + - `routes/ca.js` + - etc. + +**Per-route checklist:** +- [ ] Extract dependencies from `ctx` → explicit parameters +- [ ] Move business logic to service layer (if complex) +- [ ] Validate inputs at route boundary +- [ ] Return consistent error format +- [ ] Add route-level tests + +--- + +### Phase 4: Service Layer Introduction (LOWER PRIORITY) +**Goal:** Separate business logic from HTTP handlers +**Effort:** 3-5 days +**Risk:** Medium-High (significant refactor) + +**Problem:** Routes currently mix HTTP concerns with business logic: +```javascript +// Current: Everything in route handler +router.post('/deploy', async (req, res) => { + // 1. Parse request + // 2. Validate inputs + // 3. Business logic (complex Docker operations) + // 4. Error handling + // 5. Format response +}); +``` + +**Solution:** Service layer pattern +```javascript +// routes/apps/deploy.js +router.post('/deploy', async (req, res) => { + const result = await appDeployService.deploy(req.body); + res.json({ success: true, data: result }); +}); + +// services/app-deploy-service.js +class AppDeployService { + async deploy({ templateId, config }) { + // Pure business logic, no HTTP awareness + } +} +``` + +**Candidates for service extraction:** +- `services/docker-service.js` — container lifecycle, networking +- `services/caddy-service.js` — Caddyfile manipulation, reload +- `services/dns-service.js` — record management, zone operations +- `services/app-deploy-service.js` — template-based deployment +- `services/backup-service.js` — backup/restore workflows + +**Benefits:** +- Routes become thin HTTP adapters (easy to test) +- Business logic testable without HTTP mocking +- Reusable across routes (e.g., CLI tools, cron jobs) + +--- + +### Phase 5: Manager Cleanup (ONGOING) +**Goal:** Refine existing manager modules +**Effort:** 1-2 days (parallel to other phases) + +#### Issues to Address +1. **`backup-manager.js` (835 lines)** — too large, split backup vs restore logic +2. **`update-manager.js` (911 lines)** — complex state machine, extract version comparison utilities +3. **`health-checker.js` (591 lines)** — separate health check logic from notification daemon +4. **`input-validator.js` (606 lines)** — split by domain (docker, caddy, dns validators) + +**Approach:** Incremental splitting, preserve existing API + +--- + +### Phase 6: Template Organization (LOW PRIORITY) +**Goal:** Make templates maintainable and extensible +**Effort:** 1 day + +**Problem:** `app-templates.js` is 2496 lines (76 templates in one file) + +**Solution:** +``` +templates/ +├── index.js ← Export TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS +├── apps/ +│ ├── media/ +│ │ ├── plex.js +│ │ ├── jellyfin.js +│ │ └── ... +│ ├── automation/ +│ └── ... +└── recipes/ + ├── arr-stack.js + └── ... +``` + +**Benefits:** +- Easier to find/edit specific templates +- Contributors can add templates without merge conflicts +- Templates can import shared snippets (e.g., common env vars) + +--- + +## Metrics & Success Criteria + +### Code Quality Metrics (Before → After) + +| Metric | Before | Target | How to Measure | +|--------|--------|--------|----------------| +| `server.js` lines | 1960 | <200 | `wc -l server.js` | +| Avg route file size | ~300 | <150 | `find routes -name '*.js' -exec wc -l {} + \| awk '{sum+=$1; n++} END {print sum/n}'` | +| `ctx` properties | 50+ | 0 (removed) | Manual count | +| ESLint errors | Unknown | 0 | `npm run lint` | +| Test coverage | ~30% | >60% | `npm run test:coverage` | +| Files >500 lines | 8 | <3 | `find . -name '*.js' -exec wc -l {} + \| awk '$1 > 500'` | + +### Developer Experience Improvements +- **Onboarding:** New contributor should understand route structure in <10 minutes +- **Testing:** Mock only what you use (no god object sprawl) +- **Changes:** Touching one domain shouldn't require understanding entire codebase +- **Deployment:** Confidence that refactor didn't break anything (test suite) + +--- + +## Risk Mitigation + +### How to Refactor Safely + +1. **Test suite first** — before touching code: + - Run existing tests: `npm test` + - Identify untested critical paths → add tests + - Establish coverage baseline + +2. **Incremental changes**: + - Each phase = separate branch + - Each phase passes full test suite + - Deploy to test environment (Contabo) before merging + +3. **Preserve API contract**: + - Frontend expects same endpoints/responses + - Dashboard shouldn't need changes during API refactor + - Version routes if breaking changes needed + +4. **Rollback plan**: + - Git tags before each phase merge + - Keep old code in `legacy/` until confidence is high + - Document what changed in each PR + +--- + +## Recommended Order of Execution + +**Week 1: Foundation** +- Day 1-2: Phase 1 (ESLint, Prettier, dependency mapping) +- Day 3-5: Phase 2.1 (split `server.js`) + +**Week 2: Routes** +- Day 1-3: Phase 3.1 (standardize top 5 routes) +- Day 4-5: Phase 3.2 (remaining routes) + +**Week 3: Refinement** +- Day 1-3: Phase 4 (service layer for complex routes) +- Day 4-5: Phase 5 (manager cleanup) + +**Week 4: Polish** +- Day 1-2: Phase 6 (template organization) +- Day 3-5: Documentation, final testing, deployment + +**Total:** ~4 weeks part-time or ~2 weeks full-time + +--- + +## Questions for Sami + +Before starting, clarify: + +1. **Testing strategy:** Current test coverage is partial. Should we: + - Write tests BEFORE refactoring (safer, slower)? + - Refactor with existing tests, add coverage later (faster, riskier)? + +2. **Breaking changes:** Can we introduce backwards-incompatible API changes if we version routes (`/api/v2/...`)? + +3. **Deployment cadence:** Should each phase deploy to production, or batch into one big release? + +4. **Priority tweaks:** Does this roadmap align with "deslopify → market → sell" timeline, or should we focus only on the most visible pain points first? + +--- + +## Next Steps + +**If approved:** +1. Create feature branch: `refactor/deslopification-phase-1` +2. Add ESLint + Prettier configs +3. Run `npm run lint:fix` and commit baseline +4. Create `DEPENDENCIES.md` (ctx usage map) +5. Review with Sami before proceeding to Phase 2 + +**Estimated time to first visible improvement:** 1 week (server.js split + linting) diff --git a/dashcaddy-api/.env.example b/dashcaddy-api/.env.example deleted file mode 100644 index b4b52c8..0000000 --- a/dashcaddy-api/.env.example +++ /dev/null @@ -1,36 +0,0 @@ -# DashCaddy API Environment Variables -# Copy this file to .env and fill in your actual values -# NEVER commit .env to git! - -# JWT Secret (auto-generated if not set) -# JWT_SECRET=your-secret-key-here - -# Credential Storage -# CREDENTIALS_FILE=./credentials.json - -# Docker Configuration -# DOCKER_SOCKET=/var/run/docker.sock - -# Caddy Admin API -# CADDY_ADMIN_URL=http://localhost:2019 - -# DNS Configuration (Technitium) -# DNS_API_URL=http://localhost:5380 -# DNS_TOKEN=your-dns-token-here - -# Port Configuration -# PORT=3001 - -# Environment -# NODE_ENV=production - -# Notification Providers (optional) -# DISCORD_WEBHOOK_URL= -# TELEGRAM_BOT_TOKEN= -# TELEGRAM_CHAT_ID= -# NTFY_SERVER_URL=https://ntfy.sh -# NTFY_TOPIC= - -# Tailscale OAuth (optional) -# TAILSCALE_CLIENT_ID= -# TAILSCALE_CLIENT_SECRET= diff --git a/dashcaddy-api/BUFFER_SECURITY.md b/dashcaddy-api/BUFFER_SECURITY.md deleted file mode 100644 index e69de29..0000000 diff --git a/dashcaddy-api/DOMAIN_STRATEGY.md b/dashcaddy-api/DOMAIN_STRATEGY.md deleted file mode 100644 index e69de29..0000000 diff --git a/dashcaddy-api/__tests__/api-endpoints.test.js b/dashcaddy-api/__tests__/api-endpoints.test.js deleted file mode 100644 index 4a5598f..0000000 --- a/dashcaddy-api/__tests__/api-endpoints.test.js +++ /dev/null @@ -1,423 +0,0 @@ -/** - * API Endpoint Tests - * - * Comprehensive tests for critical DashCaddy API endpoints - * Tests the migrated StateManager integration and core functionality - */ - -const request = require('supertest'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// Create a test instance of the app -// Note: We need to mock the service file to avoid affecting production -const testServicesFile = path.join(os.tmpdir(), `test-services-${Date.now()}.json`); -const testConfigFile = path.join(os.tmpdir(), `test-config-${Date.now()}.json`); - -// Set test environment -process.env.SERVICES_FILE = testServicesFile; -process.env.CONFIG_FILE = testConfigFile; -process.env.CADDYFILE_PATH = path.join(os.tmpdir(), 'test-Caddyfile'); -process.env.CADDY_ADMIN_URL = 'http://localhost:2019'; -process.env.ENABLE_HEALTH_CHECKER = 'false'; // Disable to avoid background processes -process.env.NODE_ENV = 'test'; - -// Initialize test files -fs.writeFileSync(testServicesFile, '[]', 'utf8'); -fs.writeFileSync(testConfigFile, '{}', 'utf8'); -fs.writeFileSync(process.env.CADDYFILE_PATH, '# Test Caddyfile', 'utf8'); - -// Now require the app (after env setup) -const app = require('../server'); - -describe('API Endpoints', () => { - - // Clean up before each test - beforeEach(() => { - fs.writeFileSync(testServicesFile, '[]', 'utf8'); - }); - - // Clean up after all tests - afterAll(() => { - try { - fs.unlinkSync(testServicesFile); - fs.unlinkSync(testConfigFile); - fs.unlinkSync(process.env.CADDYFILE_PATH); - } catch (e) { - // Ignore cleanup errors - } - }); - - describe('GET /api/health', () => { - test('should return healthy status', async () => { - const res = await request(app).get('/api/health'); - - expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty('status', 'ok'); - expect(res.body).toHaveProperty('timestamp'); - }); - }); - - describe('GET /api/services', () => { - test('should return empty array initially', async () => { - const res = await request(app).get('/api/services'); - - expect(res.statusCode).toBe(200); - expect(Array.isArray(res.body)).toBe(true); - expect(res.body.length).toBe(0); - }); - - test('should return services after adding', async () => { - // Add a service first - await request(app) - .post('/api/services') - .send({ - id: 'test-service', - name: 'Test Service', - logo: '/assets/test.png', - ip: 'localhost', - tailscaleOnly: false - }); - - // Now get services - const res = await request(app).get('/api/services'); - - expect(res.statusCode).toBe(200); - expect(res.body.length).toBe(1); - expect(res.body[0]).toMatchObject({ - id: 'test-service', - name: 'Test Service' - }); - }); - - test('should use StateManager (thread-safe)', async () => { - // This test verifies StateManager is being used - // by checking that the file is read correctly - - // Manually write to file - const testData = [{ id: 'manual', name: 'Manual Service' }]; - fs.writeFileSync(testServicesFile, JSON.stringify(testData, null, 2)); - - const res = await request(app).get('/api/services'); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual(testData); - }); - }); - - describe('POST /api/services', () => { - test('should add a new service', async () => { - const newService = { - id: 'plex', - name: 'Plex', - logo: '/assets/plex.png', - ip: 'localhost', - tailscaleOnly: false - }; - - const res = await request(app) - .post('/api/services') - .send(newService); - - expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty('success', true); - - // Verify service was added - const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8')); - expect(services.length).toBe(1); - expect(services[0].id).toBe(newService.id); - expect(services[0].name).toBe(newService.name); - expect(services[0].logo).toBe(newService.logo); - }); - - test('should reject duplicate service IDs', async () => { - const service = { - id: 'duplicate', - name: 'Duplicate Service' - }; - - // Add first time - await request(app).post('/api/services').send(service); - - // Try to add again - const res = await request(app).post('/api/services').send(service); - - expect(res.statusCode).toBe(409); // Conflict is the correct status code - expect(res.body).toHaveProperty('success', false); - expect(res.body.error).toContain('already exists'); - }); - - test('should validate required fields', async () => { - const res = await request(app) - .post('/api/services') - .send({ - // Missing 'id' and 'name' - logo: '/assets/test.png' - }); - - expect(res.statusCode).toBe(400); - expect(res.body).toHaveProperty('success', false); - }); - - test('should sanitize user input (XSS protection)', async () => { - const maliciousService = { - id: 'test', - name: '', - logo: '/assets/test.png' - }; - - const res = await request(app) - .post('/api/services') - .send(maliciousService); - - // Input should be sanitized or rejected - const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8')); - - // If the service was added, script tags should be removed or escaped - if (services.length > 0) { - expect(services[0].id).not.toContain('', upstream: 'localhost:8080' }); - - expect(res.statusCode).toBe(400); - expect(res.body.error).toContain('DC-301'); - }); - - test('POST /api/site should reject invalid upstream format', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'test.sami', upstream: 'not-valid' }); - - expect(res.statusCode).toBe(400); - expect(res.body.error).toContain('upstream'); - }); - - test('POST /api/site/external should reject URLs with Caddyfile injection chars', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ - subdomain: 'test', - externalUrl: 'https://evil.com/path{inject}' - }); - - // Should be rejected — either 400 (our validation) or 500 (URL constructor throws on {}) - expect([400, 500]).toContain(res.statusCode); - // Must never succeed - expect(res.statusCode).not.toBe(200); - }); - - test('POST /api/site/external should reject URLs with newlines', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ - subdomain: 'test', - externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234' - }); - - expect(res.statusCode).toBe(400); - }); - - test('POST /api/site/external should reject missing fields', async () => { - const res = await request(app) - .post('/api/site/external') - .send({}); - - expect(res.statusCode).toBe(400); - }); - - test('POST /api/site/external should reject invalid subdomain', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ - subdomain: '../etc/passwd', - externalUrl: 'https://example.com' - }); - - expect(res.statusCode).toBe(400); - }); -}); - -// ============================================================ -// ERROR LOGS — No stack trace leak -// ============================================================ -describe('Error Logs — No Stack Trace Leak', () => { - beforeAll(async () => { - // Write a fake error log with stack traces - const logContent = [ - '[2026-03-07 12:00:00] server: Something failed', - 'Error: Internal failure', - ' at Object. (/app/server.js:123:45)', - ' at Module._compile (node:internal/modules/cjs/loader:1234:14)', - '================================================================================', - '[2026-03-07 12:01:00] dns: DNS timeout', - 'Error: connect ECONNREFUSED 192.168.1.1:5380', - ' at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1234:16)', - '================================================================================' - ].join('\n'); - // Write to the server's error log file location - // The server uses ctx.ERROR_LOG_FILE — we need to check what that resolves to - await fsp.writeFile(testErrorLogFile, logContent); - }); - - test('GET /api/error-logs should not include details/stack traces', async () => { - const res = await request(app).get('/api/error-logs'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - - // If there are logs, verify none contain 'details' field - if (res.body.logs.length > 0) { - for (const log of res.body.logs) { - expect(log).not.toHaveProperty('details'); - // Verify it has the safe fields - if (log.timestamp) { - expect(log).toHaveProperty('timestamp'); - expect(log).toHaveProperty('context'); - expect(log).toHaveProperty('error'); - } - } - } - }); -}); - -// ============================================================ -// CONTAINERS — ID validation -// ============================================================ -describe('Container ID Validation', () => { - test('GET /api/containers/:id/check-update should 404 for nonexistent container', async () => { - const res = await request(app).get('/api/containers/nonexistent123/check-update'); - - // Should return 404 (not found) not 500 (unhandled error) - expect([404]).toContain(res.statusCode); - }); - - test('POST /api/containers/:id/update should 404 for nonexistent container', async () => { - const res = await request(app).post('/api/containers/nonexistent123/update'); - - expect([404]).toContain(res.statusCode); - }); - - test('GET /api/logs/container/:id should 404 for nonexistent container', async () => { - const res = await request(app).get('/api/logs/container/nonexistent123'); - - expect([404]).toContain(res.statusCode); - }); - - test('GET /api/logs/stream/:id should 404 for nonexistent container', async () => { - const res = await request(app).get('/api/logs/stream/nonexistent123'); - - expect([404]).toContain(res.statusCode); - }); -}); - -// ============================================================ -// LOG FILE — Path traversal prevention -// ============================================================ -describe('Log File Path Traversal', () => { - test('GET /api/logs/file should reject missing path', async () => { - const res = await request(app).get('/api/logs/file'); - - expect(res.statusCode).toBe(400); - }); - - test('GET /api/logs/file should reject traversal paths', async () => { - const res = await request(app) - .get('/api/logs/file') - .query({ path: '/etc/shadow' }); - - // Should be 403 (not allowed) or 404 (not found), never 200 - expect([403, 404]).toContain(res.statusCode); - }); - - test('GET /api/logs/file should reject Windows system paths', async () => { - const res = await request(app) - .get('/api/logs/file') - .query({ path: 'C:\\Windows\\System32\\config\\SAM' }); - - expect([403, 404]).toContain(res.statusCode); - }); - - test('GET /api/logs/file should reject parent directory traversal', async () => { - const res = await request(app) - .get('/api/logs/file') - .query({ path: '/var/log/../../etc/passwd' }); - - expect([403, 404]).toContain(res.statusCode); - }); -}); - -// ============================================================ -// BACKUP — No encryption key in export, TOTP re-auth for restore -// ============================================================ -describe('Backup Security', () => { - test('GET /api/backup/export should not include encryption key', async () => { - const res = await request(app).get('/api/backup/export'); - - if (res.statusCode === 200 && res.body.backup) { - const backup = res.body.backup; - // Verify encryptionKey is NOT in the backup files - expect(backup.files).not.toHaveProperty('encryptionKey'); - // Verify TOTP backup doesn't include manualKey - if (backup.totp) { - expect(backup.totp).not.toHaveProperty('manualKey'); - } - } - }); - - test('POST /api/backup/restore should reject invalid backup format', async () => { - const res = await request(app) - .post('/api/backup/restore') - .send({ backup: { invalid: true } }); - - expect(res.statusCode).toBe(400); - }); - - test('POST /api/backup/restore should not restore encryptionKey even if provided', async () => { - const res = await request(app) - .post('/api/backup/restore') - .send({ - backup: { - version: '1.0', - files: { - encryptionKey: { - type: 'text', - content: 'malicious-key-data' - } - } - } - }); - - // The encryptionKey should be skipped (not in fileMapping) - if (res.statusCode === 200) { - // If it succeeded, verify encryptionKey was skipped - expect(res.body.results.restored).not.toContain('encryptionKey'); - } - }); -}); - -// ============================================================ -// SESSION COOKIE — Secure flag -// ============================================================ -describe('Session Cookie Security', () => { - test('session cookies should include Secure flag', async () => { - // TOTP verify would set a session cookie on success - // We can check the middleware by looking at any response that sets cookies - const res = await request(app) - .post('/api/totp/verify') - .send({ code: '123456' }); - - // Even though verify fails, check cookie format if any cookies are set - const cookies = res.headers['set-cookie']; - if (cookies) { - for (const cookie of Array.isArray(cookies) ? cookies : [cookies]) { - if (cookie.includes('dashcaddy_session')) { - expect(cookie.toLowerCase()).toContain('secure'); - expect(cookie.toLowerCase()).toContain('httponly'); - expect(cookie.toLowerCase()).toContain('samesite'); - } - } - } - }); -}); - -// ============================================================ -// CUSTOM VOLUME — Host path validation -// ============================================================ -describe('Custom Volume Path Validation', () => { - // This tests the processTemplateVariables function indirectly - // The helpers.js validates custom volume hostPath against allowed roots - - test('should not allow arbitrary host paths in volume overrides', async () => { - // Deploy endpoint would use processTemplateVariables - // Sending a custom volume with a dangerous path - const res = await request(app) - .post('/api/apps/deploy') - .send({ - appId: 'plex', - subdomain: 'test-plex', - ip: '192.168.1.100', - port: '32400', - customVolumes: [{ - containerPath: '/config', - hostPath: '/etc/shadow' - }] - }); - - // The deploy will likely fail for other reasons (no Docker, etc.) - // But if it reaches volume processing, the dangerous path should be rejected - // The key check: it shouldn't return 200 with /etc/shadow mounted - if (res.statusCode === 200) { - // If somehow succeeded, verify the dangerous path wasn't used - expect(JSON.stringify(res.body)).not.toContain('/etc/shadow'); - } - }); -}); - -// ============================================================ -// LOGO DELETE — Path traversal prevention -// ============================================================ -describe('Logo Delete Path Traversal', () => { - test('DELETE /api/logo should safely handle config with traversal paths', async () => { - // Write config with a malicious logo path - const configWithMaliciousLogo = { - customLogo: '/assets/../../etc/passwd', - customLogoDark: '/assets/../../../root/.ssh/id_rsa' - }; - await fsp.writeFile(testConfigFile, JSON.stringify(configWithMaliciousLogo), 'utf8'); - - const res = await request(app).delete('/api/logo'); - - // Should succeed (reset branding) without deleting files outside assets dir - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - - // Reset config for other tests - await fsp.writeFile(testConfigFile, '{}', 'utf8'); - }); -}); - -// ============================================================ -// DNS — SSRF prevention (server parameter validation) -// ============================================================ -describe('DNS Server SSRF Prevention', () => { - test('DELETE /api/dns/record should not succeed with arbitrary server IPs', async () => { - const res = await request(app) - .delete('/api/dns/record') - .query({ - domain: 'test.sami', - type: 'A', - server: '169.254.169.254' // AWS metadata endpoint - }); - - // Must never succeed — 400 (server rejected), 401 (no token), or 500 (dns not configured in test) - expect(res.statusCode).not.toBe(200); - }); - - test('POST /api/dns/record should not succeed with arbitrary server IPs', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ - domain: 'test.sami', - ipAddress: '192.168.1.1', - server: '10.0.0.1' // Not a configured DNS server - }); - - expect(res.statusCode).not.toBe(200); - }); - - test('GET /api/dns/resolve should not succeed with arbitrary server IPs', async () => { - const res = await request(app) - .get('/api/dns/resolve') - .query({ - domain: 'test.sami', - server: '127.0.0.1' - }); - - expect(res.statusCode).not.toBe(200); - }); - - test('GET /api/dns/logs should reject arbitrary server IPs', async () => { - const res = await request(app) - .get('/api/dns/logs') - .query({ server: '192.168.1.1' }); - - expect([400]).toContain(res.statusCode); - }); - - test('GET /api/dns/check-update should reject arbitrary server IPs', async () => { - const res = await request(app) - .get('/api/dns/check-update') - .query({ server: '8.8.8.8' }); - - expect([400]).toContain(res.statusCode); - }); - - test('POST /api/dns/update should reject arbitrary server IPs', async () => { - const res = await request(app) - .post('/api/dns/update') - .query({ server: '1.1.1.1' }); - - expect([400]).toContain(res.statusCode); - }); -}); - -// ============================================================ -// _httpFetch — Response size limit -// ============================================================ -describe('HTTP Fetch Response Size Limit', () => { - // This is tested indirectly — the _httpFetch function has a 10MB limit - // We can verify the constant exists by checking the server module - test('server should define MAX_RESPONSE_SIZE constant', () => { - // Read server.js and verify the limit is defined - const serverSource = fs.readFileSync( - path.join(__dirname, '..', 'server.js'), 'utf8' - ); - expect(serverSource).toContain('MAX_RESPONSE_SIZE'); - expect(serverSource).toContain('10 * 1024 * 1024'); - }); -}); - -// ============================================================ -// MIDDLEWARE — Session cookie format -// ============================================================ -describe('Middleware Security', () => { - test('middleware should set Secure flag on cookies', () => { - const middlewareSource = fs.readFileSync( - path.join(__dirname, '..', 'middleware.js'), 'utf8' - ); - // Verify the Set-Cookie string includes Secure - expect(middlewareSource).toContain('; Secure;'); - }); -}); - -// ============================================================ -// SAVECONFIG — Atomic operations -// ============================================================ -describe('Config Save Atomicity', () => { - test('saveConfig should use state manager for locking', () => { - const serverSource = fs.readFileSync( - path.join(__dirname, '..', 'server.js'), 'utf8' - ); - // Verify saveConfig uses configStateManager.update (not raw fs.writeFile) - expect(serverSource).toContain('configStateManager.update'); - }); -}); - -// ============================================================ -// SITES — External URL validation -// ============================================================ -describe('External URL Security', () => { - test('sites.js should validate URL components for unsafe chars', () => { - const sitesSource = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'sites.js'), 'utf8' - ); - // Verify the unsafe character regex exists - expect(sitesSource).toContain('unsafeCaddyChars'); - expect(sitesSource).toMatch(/[{}\\n\\r]/); - }); -}); - -// ============================================================ -// CREDENTIAL MANAGER — Locking -// ============================================================ -describe('Credential Manager File Locking', () => { - test('credential-manager should use proper-lockfile', () => { - const cmSource = fs.readFileSync( - path.join(__dirname, '..', 'credential-manager.js'), 'utf8' - ); - expect(cmSource).toContain('proper-lockfile'); - expect(cmSource).toContain('_lockedUpdate'); - }); -}); - -// ============================================================ -// TOTP CONFIG — No plaintext secret in file -// ============================================================ -describe('TOTP Config File Security', () => { - test('loadTotpConfig should delete secret from file data', () => { - const serverSource = fs.readFileSync( - path.join(__dirname, '..', 'server.js'), 'utf8' - ); - // Verify the secret deletion exists in loadTotpConfig - expect(serverSource).toContain('delete loaded.secret'); - }); - - test('totp verify-setup should not write secret to config file', () => { - const totpSource = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8' - ); - // Verify totpConfig.secret assignment is NOT present - expect(totpSource).not.toContain('totpConfig.secret = pendingSecret'); - expect(totpSource).not.toContain('totpConfig.secret ='); - }); -}); - -// ============================================================ -// HELPERS — Volume path validation -// ============================================================ -describe('Helpers — Volume Security', () => { - test('helpers.js should validate hostPath against allowed roots', () => { - const helpersSource = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'apps', 'helpers.js'), 'utf8' - ); - expect(helpersSource).toContain('allowedRoots'); - expect(helpersSource).toContain('platformPaths.dockerData'); - expect(helpersSource).toContain('Custom volume host path rejected'); - }); -}); - -// ============================================================ -// ERROR LOGS — No details field -// ============================================================ -describe('Error Logs — Response Format', () => { - test('errorlogs.js should not include details field', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8' - ); - // The parsed log object should only have timestamp, context, error - // NOT details (which contains stack traces) - const returnBlock = source.match(/return \{[\s\S]*?\}/); - if (returnBlock) { - expect(returnBlock[0]).not.toContain('details'); - } - }); -}); - -// ============================================================ -// ASSETS — path.basename for logo deletion -// ============================================================ -describe('Assets — Logo Path Safety', () => { - test('assets.js should use path.basename for logo filename extraction', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'assets.js'), 'utf8' - ); - expect(source).toContain('path.basename(logoPath)'); - // Should NOT use string replace for path extraction - expect(source).not.toContain("logoPath.replace('/assets/', '')"); - }); -}); - -// ============================================================ -// BACKUP — encryptionKey excluded -// ============================================================ -describe('Backup — Encryption Key Exclusion', () => { - test('backup.js should not include encryptionKey in filesToBackup', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' - ); - // Should have a comment about deliberate exclusion - expect(source).toContain('encryptionKey deliberately excluded'); - // Should NOT have encryptionKey as a key in filesToBackup array - expect(source).not.toMatch(/\{\s*key:\s*'encryptionKey'/); - }); - - test('backup.js restore fileMapping should not include encryptionKey', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' - ); - // The RESTORE route's fileMapping (after "encryptionKey excluded" comment) must not have it - // The preview route's fileMapping is allowed to have it (informational only) - const restoreSection = source.substring(source.indexOf('encryptionKey excluded')); - const restoreMapping = restoreSection.match(/const fileMapping = \{[\s\S]*?\};/); - if (restoreMapping) { - expect(restoreMapping[0]).not.toContain('encryptionKey:'); - } - }); - - test('backup.js should require TOTP for sensitive restores', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' - ); - expect(source).toContain('sensitiveKeys'); - expect(source).toContain('totpCode'); - expect(source).toContain('TOTP code required'); - }); -}); - -// ============================================================ -// DNS — validateDnsServer function -// ============================================================ -describe('DNS — Server Validation Function', () => { - test('dns.js should define validateDnsServer', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8' - ); - expect(source).toContain('function validateDnsServer'); - expect(source).toContain('configuredIps'); - expect(source).toContain('validatorLib.isIP'); - }); -}); - -// ============================================================ -// CONTAINERS — getVerifiedContainer usage -// ============================================================ -describe('Containers — Verified Container Access', () => { - test('containers.js update route should use getVerifiedContainer', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8' - ); - // update and check-update should both use getVerifiedContainer - const updateSection = source.substring(source.indexOf("'/:id/update'")); - expect(updateSection).toContain('getVerifiedContainer'); - - const checkUpdateSection = source.substring(source.indexOf("'/:id/check-update'")); - expect(checkUpdateSection).toContain('getVerifiedContainer'); - }); -}); - -// ============================================================ -// LOGS — Symlink resolution -// ============================================================ -describe('Logs — Symlink Resolution', () => { - test('logs.js should use realpath for symlink resolution', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8' - ); - expect(source).toContain('fsp.realpath'); - expect(source).toContain('path.sep'); - }); - - test('logs.js container routes should verify container exists', () => { - const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8' - ); - // Both container/:id and stream/:id should have inspect + NotFoundError - expect(source).toContain('container.inspect()'); - expect(source).toContain('NotFoundError'); - }); -}); diff --git a/dashcaddy-api/__tests__/server-validation.test.js b/dashcaddy-api/__tests__/server-validation.test.js deleted file mode 100644 index 8bb667f..0000000 --- a/dashcaddy-api/__tests__/server-validation.test.js +++ /dev/null @@ -1,417 +0,0 @@ -/** - * Integration tests for server.js input validation - * Tests that routes properly reject invalid input before reaching business logic - */ - -const request = require('supertest'); -const app = require('../server'); - -describe('POST /api/assets/upload - directory traversal prevention', () => { - test('rejects filename with path separators', async () => { - const res = await request(app) - .post('/api/assets/upload') - .send({ filename: '../../../etc/passwd', data: 'data:image/png;base64,iVBOR' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/path separator/i); - }); - - test('rejects filename with backslash', async () => { - const res = await request(app) - .post('/api/assets/upload') - .send({ filename: '..\\..\\config.json', data: 'data:image/png;base64,iVBOR' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/path separator/i); - }); - - test('rejects filename with dot-dot', async () => { - const res = await request(app) - .post('/api/assets/upload') - .send({ filename: '..evil.png', data: 'data:image/png;base64,iVBOR' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/path separator/i); - }); - - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/assets/upload') - .send({ filename: 'test.png' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); -}); - -describe('POST /api/site - Caddyfile injection prevention', () => { - test('rejects invalid domain format', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'evil;rm -rf /', upstream: '127.0.0.1:8080' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects domain with spaces', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'evil domain', upstream: '127.0.0.1:8080' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects invalid upstream format', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'test.sami', upstream: 'not a valid upstream' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid upstream/i); - }); - - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/site') - .send({ domain: 'test.sami' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); -}); - -describe('POST /api/site/external - URL and subdomain validation', () => { - test('rejects invalid subdomain', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ subdomain: '-invalid', externalUrl: 'https://example.com' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid subdomain/i); - }); - - test('rejects subdomain with special chars', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ subdomain: 'test;evil', externalUrl: 'https://example.com' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid subdomain/i); - }); - - test('rejects invalid URL', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ subdomain: 'myapp', externalUrl: 'not-a-url' }); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); - - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ subdomain: 'myapp' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); -}); - -// DNS routes require a token to bypass the 401 token check and reach validation -const FAKE_TOKEN = 'aaaa1111bbbb2222cccc3333dddd4444'; - -describe('POST /api/dns/record - DNS injection prevention', () => { - test('rejects invalid domain format', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'evil;command', ip: '10.0.0.1', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects invalid IP address', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', ip: 'not-an-ip', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid ip/i); - }); - - test('rejects TTL out of range (too low)', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 5, token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/ttl/i); - }); - - test('rejects TTL out of range (too high)', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 100000, token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/ttl/i); - }); - - test('rejects invalid server IP', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', ip: '10.0.0.1', server: 'not-an-ip', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid dns server/i); - }); - - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/dns/record') - .send({ domain: 'test.sami', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - }); -}); - -describe('DELETE /api/dns/record - DNS injection prevention', () => { - test('rejects invalid domain', async () => { - const res = await request(app) - .delete('/api/dns/record') - .query({ domain: 'evil;drop table', token: 'abc123def456' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects invalid record type', async () => { - const res = await request(app) - .delete('/api/dns/record') - .query({ domain: 'test.sami', type: 'INVALID', token: 'abc123def456' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid dns record type/i); - }); - - test('rejects invalid ipAddress', async () => { - const res = await request(app) - .delete('/api/dns/record') - .query({ domain: 'test.sami', ipAddress: 'not-ip', token: 'abc123def456' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid ip/i); - }); -}); - -describe('GET /api/dns/resolve - DNS injection prevention', () => { - test('rejects invalid domain', async () => { - const res = await request(app) - .get('/api/dns/resolve') - .query({ domain: 'evil;command', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid domain/i); - }); - - test('rejects invalid server IP', async () => { - const res = await request(app) - .get('/api/dns/resolve') - .query({ domain: 'test.sami', server: 'not-an-ip', token: FAKE_TOKEN }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid dns server/i); - }); -}); - -describe('POST /api/apps/deploy - deployment validation', () => { - test('rejects invalid subdomain', async () => { - const res = await request(app) - .post('/api/apps/deploy') - .send({ appId: 'plex', config: { subdomain: '-bad-sub' } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid subdomain/i); - }); - - test('rejects invalid port', async () => { - const res = await request(app) - .post('/api/apps/deploy') - .send({ appId: 'plex', config: { subdomain: 'test', port: 99999 } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid port/i); - }); - - test('rejects invalid IP', async () => { - const res = await request(app) - .post('/api/apps/deploy') - .send({ appId: 'plex', config: { subdomain: 'test', ip: 'not-ip' } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid ip/i); - }); - - test('rejects unknown template', async () => { - const res = await request(app) - .post('/api/apps/deploy') - .send({ appId: 'nonexistent-app-xyz', config: { subdomain: 'test' } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid app template/i); - }); -}); - -describe('POST /api/dns/credentials - credential validation', () => { - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'admin' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); - - test('rejects username exceeding max length', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'a'.repeat(101), password: 'secret' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/maximum length/i); - }); - - test('rejects username with injection chars', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'admin;rm -rf /', password: 'secret' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid characters/i); - }); - - test('rejects username with pipe', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'admin|evil', password: 'secret' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid characters/i); - }); - - test('rejects invalid server IP', async () => { - const res = await request(app) - .post('/api/dns/credentials') - .send({ username: 'admin', password: 'secret', server: 'not-ip' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid dns server/i); - }); -}); - -describe('POST /api/services - service config validation', () => { - test('rejects missing fields', async () => { - const res = await request(app) - .post('/api/services') - .send({ id: 'test' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); - - test('rejects invalid service id format', async () => { - const res = await request(app) - .post('/api/services') - .send({ id: 'invalid id with spaces!', name: 'Test' }); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); -}); - -describe('PUT /api/services - bulk import validation', () => { - test('rejects non-array body', async () => { - const res = await request(app) - .put('/api/services') - .send({ id: 'test', name: 'Test' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/array/i); - }); - - test('rejects service with invalid id', async () => { - const res = await request(app) - .put('/api/services') - .send([{ id: 'invalid id!', name: 'Test' }]); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); -}); - -describe('POST /api/services/update - service update validation', () => { - test('rejects missing subdomains', async () => { - const res = await request(app) - .post('/api/services/update') - .send({ oldSubdomain: 'test' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/required/i); - }); - - test('rejects invalid subdomain format', async () => { - const res = await request(app) - .post('/api/services/update') - .send({ oldSubdomain: '-bad', newSubdomain: 'good' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid subdomain/i); - }); - - test('rejects invalid port', async () => { - const res = await request(app) - .post('/api/services/update') - .send({ oldSubdomain: 'old', newSubdomain: 'new', port: 70000 }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid port/i); - }); - - test('rejects invalid IP', async () => { - const res = await request(app) - .post('/api/services/update') - .send({ oldSubdomain: 'old', newSubdomain: 'new', ip: 'not-ip' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid ip/i); - }); -}); - -describe('POST /api/arr/test-connection - SSRF prevention', () => { - test('rejects invalid URL', async () => { - const res = await request(app) - .post('/api/arr/test-connection') - .send({ service: 'radarr', url: 'not-a-url', apiKey: 'abc123def456' }); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); - - test('rejects invalid API key format', async () => { - const res = await request(app) - .post('/api/arr/test-connection') - .send({ service: 'radarr', url: 'http://localhost:7878', apiKey: 'a;b' }); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); -}); - -describe('POST /api/notifications/config - notification provider validation', () => { - test('rejects invalid Discord webhook URL', async () => { - const res = await request(app) - .post('/api/notifications/config') - .send({ providers: { discord: { webhookUrl: 'not-a-url' } } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/discord webhook/i); - }); - - test('rejects invalid ntfy server URL', async () => { - const res = await request(app) - .post('/api/notifications/config') - .send({ providers: { ntfy: { serverUrl: 'ftp://bad' } } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/ntfy server/i); - }); - - test('rejects invalid ntfy topic', async () => { - const res = await request(app) - .post('/api/notifications/config') - .send({ providers: { ntfy: { topic: 'has spaces and $pecial!' } } }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/ntfy topic/i); - }); - - test('accepts valid config', async () => { - const res = await request(app) - .post('/api/notifications/config') - .send({ enabled: true }); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); -}); - -describe('Rate limiting headers', () => { - test('returns rate limit headers on API responses', async () => { - const res = await request(app).get('/api/health'); - // Health endpoint is skipped by rate limiter, but general endpoints should have headers - expect(res.status).toBe(200); - }); - - test('general API endpoint has rate limiting configured', async () => { - const res = await request(app).get('/api/services'); - // Rate limiting is skipped in test env, so verify the endpoint is accessible - expect(res.status).toBe(200); - }); -}); diff --git a/dashcaddy-api/__tests__/sites.test.js b/dashcaddy-api/__tests__/sites.test.js deleted file mode 100644 index d6742e2..0000000 --- a/dashcaddy-api/__tests__/sites.test.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Sites Route Tests - * - * Tests Caddyfile management, site configuration, and external site endpoints - */ - -const request = require('supertest'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const testServicesFile = path.join(os.tmpdir(), `sites-services-${Date.now()}.json`); -const testConfigFile = path.join(os.tmpdir(), `sites-config-${Date.now()}.json`); -const testCaddyfile = path.join(os.tmpdir(), `sites-Caddyfile-${Date.now()}`); - -process.env.SERVICES_FILE = testServicesFile; -process.env.CONFIG_FILE = testConfigFile; -process.env.CADDYFILE_PATH = testCaddyfile; -process.env.ENABLE_HEALTH_CHECKER = 'false'; -process.env.NODE_ENV = 'test'; - -fs.writeFileSync(testServicesFile, '[]', 'utf8'); -fs.writeFileSync(testConfigFile, '{}', 'utf8'); -fs.writeFileSync(testCaddyfile, '# Test Caddyfile', 'utf8'); - -const app = require('../server'); - -describe('Sites Routes', () => { - afterAll(() => { - for (const f of [testServicesFile, testConfigFile, testCaddyfile]) { - try { fs.unlinkSync(f); } catch (e) { /* ignore */ } - } - }); - - describe('GET /api/caddyfile', () => { - test('should return Caddyfile contents', async () => { - const res = await request(app).get('/api/caddyfile'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.content).toContain('Test Caddyfile'); - }); - }); - - describe('GET /api/apps/templates', () => { - test('should return all templates with categories', async () => { - const res = await request(app).get('/api/apps/templates'); - - expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty('templates'); - expect(res.body).toHaveProperty('categories'); - expect(Object.keys(res.body.templates).length).toBeGreaterThan(50); - }); - }); - - describe('GET /api/apps/templates/:appId', () => { - test('should return specific template', async () => { - const res = await request(app).get('/api/apps/templates/plex'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.template.name).toBe('Plex'); - expect(res.body.template.docker).toBeDefined(); - }); - - test('should return 404 for unknown template', async () => { - const res = await request(app).get('/api/apps/templates/nonexistent'); - - expect(res.statusCode).toBe(404); - }); - }); - - describe('POST /api/site/external', () => { - test('should reject missing required fields', async () => { - const res = await request(app) - .post('/api/site/external') - .send({}); - - expect(res.statusCode).toBe(400); - }); - - test('should reject invalid subdomain', async () => { - const res = await request(app) - .post('/api/site/external') - .send({ - subdomain: 'INVALID SUBDOMAIN!', - targetUrl: 'https://example.com', - name: 'Test' - }); - - expect(res.statusCode).toBe(400); - }); - }); - - describe('GET /api/caddy/cas', () => { - test('should return CA list from Caddyfile', async () => { - const res = await request(app).get('/api/caddy/cas'); - - expect(res.statusCode).toBe(200); - expect(res.body.status).toBe('success'); - expect(Array.isArray(res.body.data.cas)).toBe(true); - }); - }); -}); diff --git a/dashcaddy-api/__tests__/state-manager.test.js b/dashcaddy-api/__tests__/state-manager.test.js deleted file mode 100644 index a007b86..0000000 --- a/dashcaddy-api/__tests__/state-manager.test.js +++ /dev/null @@ -1,249 +0,0 @@ -/** - * State Manager Tests - * - * Tests the thread-safe state management with file locking - */ - -const StateManager = require('../state-manager'); -const fs = require('fs').promises; -const path = require('path'); -const os = require('os'); - -// Dedicated temp subdirectory avoids cross-test file collisions -const testDir = path.join(os.tmpdir(), `state-manager-test-${Date.now()}`); -const testFile = path.join(testDir, 'test-state.json'); - -describe('StateManager', () => { - let stateManager; - - beforeAll(async () => { - await fs.mkdir(testDir, { recursive: true }); - }); - - beforeEach(async () => { - // Clean up test file + stale lockfiles - for (const f of [testFile, `${testFile}.lock`]) { - try { await fs.unlink(f); } catch (e) { /* ignore */ } - } - - stateManager = new StateManager(testFile, { - lockRetries: 20, - lockRetryInterval: 50, - lockTimeout: 15000 - }); - }); - - afterEach(async () => { - for (const f of [testFile, `${testFile}.lock`]) { - try { await fs.unlink(f); } catch (e) { /* ignore */ } - } - }); - - afterAll(async () => { - try { await fs.rm(testDir, { recursive: true }); } catch (e) { /* ignore */ } - }); - - describe('Basic Operations', () => { - test('creates file with empty array if not exists', async () => { - const data = await stateManager.read(); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(0); - }); - - test('write and read roundtrip', async () => { - const testData = [ - { id: '1', name: 'Test Service 1' }, - { id: '2', name: 'Test Service 2' } - ]; - - await stateManager.write(testData); - const data = await stateManager.read(); - - expect(data).toEqual(testData); - }); - - test('update with callback function', async () => { - await stateManager.write([{ id: '1', name: 'Service 1' }]); - - const updated = await stateManager.update(items => { - items.push({ id: '2', name: 'Service 2' }); - return items; - }); - - expect(updated.length).toBe(2); - expect(updated[1].name).toBe('Service 2'); - }); - }); - - describe('Convenience Methods', () => { - test('addItem adds to array', async () => { - await stateManager.addItem({ id: '1', name: 'Service 1' }); - await stateManager.addItem({ id: '2', name: 'Service 2' }); - - const items = await stateManager.read(); - expect(items.length).toBe(2); - }); - - test('removeItem removes by ID', async () => { - await stateManager.write([ - { id: '1', name: 'Service 1' }, - { id: '2', name: 'Service 2' }, - { id: '3', name: 'Service 3' } - ]); - - await stateManager.removeItem('2'); - - const items = await stateManager.read(); - expect(items.length).toBe(2); - expect(items.find(i => i.id === '2')).toBeUndefined(); - }); - - test('updateItem updates by ID', async () => { - await stateManager.write([ - { id: '1', name: 'Service 1', status: 'offline' } - ]); - - await stateManager.updateItem('1', { status: 'online' }); - - const item = await stateManager.findItem('1'); - expect(item.status).toBe('online'); - expect(item.name).toBe('Service 1'); // Unchanged - }); - - test('findItem returns null for non-existent ID', async () => { - await stateManager.write([{ id: '1', name: 'Service 1' }]); - - const item = await stateManager.findItem('999'); - expect(item).toBeNull(); - }); - }); - - describe('Concurrent Access', () => { - test('concurrent writes do not corrupt data', async () => { - // Start with empty array - await stateManager.write([]); - - // Simulate 10 concurrent writes - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push( - stateManager.update(items => { - items.push({ id: `service-${i}`, name: `Service ${i}` }); - return items; - }) - ); - } - - await Promise.all(promises); - - // Verify all items were added - const items = await stateManager.read(); - expect(items.length).toBe(10); - - // Verify JSON is valid (not corrupted) - const fileContent = await fs.readFile(testFile, 'utf8'); - expect(() => JSON.parse(fileContent)).not.toThrow(); - }); - - test('concurrent reads while writing', async () => { - await stateManager.write([{ id: '1', name: 'Initial' }]); - - const writePromise = stateManager.update(async items => { - // Simulate slow operation - await new Promise(resolve => setTimeout(resolve, 100)); - items.push({ id: '2', name: 'New' }); - return items; - }); - - const readPromises = []; - for (let i = 0; i < 5; i++) { - readPromises.push(stateManager.read()); - } - - await Promise.all([writePromise, ...readPromises]); - - // Should complete without errors - const final = await stateManager.read(); - expect(final.length).toBe(2); - }); - }); - - describe('Error Handling', () => { - test('throws error on invalid JSON', async () => { - // Write invalid JSON directly - await fs.writeFile(testFile, '{invalid json', 'utf8'); - - await expect(stateManager.read()).rejects.toThrow(); - }); - - test('handles missing file gracefully', async () => { - await fs.unlink(testFile); - - const data = await stateManager.read(); - expect(Array.isArray(data)).toBe(true); - }); - - test('update callback errors are caught', async () => { - await expect( - stateManager.update(() => { - throw new Error('Test error'); - }) - ).rejects.toThrow('Test error'); - }); - }); - - describe('Lock Management', () => { - test('isLocked detects locked state', async () => { - const lockfile = require('proper-lockfile'); - - // Manually lock the file - const release = await lockfile.lock(testFile); - - const locked = await stateManager.isLocked(); - expect(locked).toBe(true); - - await release(); - - const unlocked = await stateManager.isLocked(); - expect(unlocked).toBe(false); - }); - - test('forceUnlock removes stuck lock', async () => { - const lockfile = require('proper-lockfile'); - - // Create a stuck lock - await lockfile.lock(testFile); - - await stateManager.forceUnlock(); - - // Should be able to write now - await expect(stateManager.write([])).resolves.not.toThrow(); - }); - }); - - describe('Performance', () => { - test('handles large datasets efficiently', async () => { - const largeDataset = []; - for (let i = 0; i < 1000; i++) { - largeDataset.push({ - id: `service-${i}`, - name: `Service ${i}`, - url: `https://service-${i}.example.com`, - status: 'online' - }); - } - - const startTime = Date.now(); - await stateManager.write(largeDataset); - const writeTime = Date.now() - startTime; - - const readStart = Date.now(); - const data = await stateManager.read(); - const readTime = Date.now() - readStart; - - expect(data.length).toBe(1000); - expect(writeTime).toBeLessThan(1000); // Should write in <1s - expect(readTime).toBeLessThan(100); // Should read in <100ms - }); - }); -}); diff --git a/dashcaddy-api/__tests__/tailscale.test.js b/dashcaddy-api/__tests__/tailscale.test.js deleted file mode 100644 index 6055139..0000000 --- a/dashcaddy-api/__tests__/tailscale.test.js +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Tailscale Route Tests - * - * Tests Tailscale status, configuration, and connection-checking endpoints. - * The Tailscale routes are mounted at /api/tailscale/ on the API router: - * - GET /api/tailscale/status — Tailscale status - * - POST /api/tailscale/config — Update Tailscale configuration - * - GET /api/tailscale/check-connection — Check if request comes from Tailscale IP - * - GET /api/tailscale/devices — List Tailscale devices - * - POST /api/tailscale/protect-service — Toggle Tailscale-only for a service - * - POST /api/tailscale/oauth-config — OAuth credential setup (requires live API) - * - GET /api/tailscale/api-devices — Enriched device list from API - * - POST /api/tailscale/sync — Trigger API sync - * - GET /api/tailscale/acl — Fetch ACL policy - */ - -const request = require('supertest'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const testServicesFile = path.join(os.tmpdir(), `tailscale-services-${Date.now()}.json`); -const testConfigFile = path.join(os.tmpdir(), `tailscale-config-${Date.now()}.json`); - -process.env.SERVICES_FILE = testServicesFile; -process.env.CONFIG_FILE = testConfigFile; -process.env.ENABLE_HEALTH_CHECKER = 'false'; -process.env.NODE_ENV = 'test'; - -fs.writeFileSync(testServicesFile, '[]', 'utf8'); -fs.writeFileSync(testConfigFile, '{}', 'utf8'); - -const app = require('../server'); - -describe('Tailscale Routes', () => { - afterAll(() => { - try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ } - try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ } - }); - - describe('GET /api/tailscale/status', () => { - test('should return 200 with status data', async () => { - const res = await request(app).get('/api/tailscale/status'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - - // If Tailscale is not installed in test env, expect installed: false - if (!res.body.installed) { - expect(res.body.installed).toBe(false); - expect(res.body.connected).toBe(false); - expect(res.body.message).toBeDefined(); - } else { - // If installed, expect richer data - expect(res.body).toHaveProperty('connected'); - expect(res.body).toHaveProperty('self'); - expect(res.body).toHaveProperty('config'); - expect(res.body).toHaveProperty('devices'); - expect(res.body).toHaveProperty('deviceCount'); - } - }); - }); - - describe('GET /api/tailscale/check-connection', () => { - test('should return 200 with connection info', async () => { - const res = await request(app).get('/api/tailscale/check-connection'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body).toHaveProperty('isTailscale'); - expect(typeof res.body.isTailscale).toBe('boolean'); - expect(res.body).toHaveProperty('clientIP'); - }); - - test('should detect non-Tailscale IP for localhost requests', async () => { - const res = await request(app).get('/api/tailscale/check-connection'); - - expect(res.statusCode).toBe(200); - // Supertest connects via loopback, not a 100.x.x.x address - expect(res.body.isTailscale).toBe(false); - }); - }); - - describe('GET /api/tailscale/devices', () => { - test('should return 200 with devices array', async () => { - const res = await request(app).get('/api/tailscale/devices'); - - expect(res.statusCode).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body).toHaveProperty('devices'); - expect(Array.isArray(res.body.devices)).toBe(true); - }); - }); - - describe('POST /api/tailscale/oauth-config', () => { - test('should reject missing required fields', async () => { - const res = await request(app) - .post('/api/tailscale/oauth-config') - .send({}); - - expect(res.statusCode).toBe(400); - }); - - test('should reject partial credentials', async () => { - const res = await request(app) - .post('/api/tailscale/oauth-config') - .send({ clientId: 'test-id' }); - - expect(res.statusCode).toBe(400); - }); - }); - - describe('GET /api/tailscale/api-devices', () => { - test('should return 400 when OAuth is not configured', async () => { - const res = await request(app).get('/api/tailscale/api-devices'); - - expect(res.statusCode).toBe(400); - }); - }); - - describe('POST /api/tailscale/sync', () => { - test('should return 400 when OAuth is not configured', async () => { - const res = await request(app).post('/api/tailscale/sync'); - - expect(res.statusCode).toBe(400); - }); - }); - - describe('POST /api/tailscale/protect-service', () => { - test('should reject missing subdomain', async () => { - const res = await request(app) - .post('/api/tailscale/protect-service') - .send({}); - - expect(res.statusCode).toBe(400); - }); - }); -}); diff --git a/dashcaddy-api/__tests__/update-manager.test.js b/dashcaddy-api/__tests__/update-manager.test.js deleted file mode 100644 index 96f5c20..0000000 --- a/dashcaddy-api/__tests__/update-manager.test.js +++ /dev/null @@ -1,192 +0,0 @@ -// update-manager.js creates a Docker instance at module level. -// On test machines without Docker, this is fine — Docker methods are only called -// in async methods that we won't invoke in unit tests. - -const updateManager = require('../update-manager'); - -beforeEach(() => { - // Reset singleton state - updateManager.history = []; - updateManager.availableUpdates = new Map(); - updateManager.config = { autoUpdate: {} }; - updateManager.checking = false; - if (updateManager.checkInterval) { - clearInterval(updateManager.checkInterval); - updateManager.checkInterval = null; - } -}); - -afterAll(() => { - updateManager.stop(); -}); - -describe('extractTag', () => { - test('extracts tag from "nginx:latest"', () => { - expect(updateManager.extractTag('nginx:latest')).toBe('latest'); - }); - - test('returns "latest" when no tag specified', () => { - expect(updateManager.extractTag('nginx')).toBe('latest'); - }); - - test('extracts tag from registry/repo:tag format', () => { - expect(updateManager.extractTag('docker.io/library/nginx:1.21')).toBe('1.21'); - }); - - test('handles tags with dots and hyphens', () => { - expect(updateManager.extractTag('myapp:v1.2.3-rc1')).toBe('v1.2.3-rc1'); - }); -}); - -describe('parseAuthHeader', () => { - test('returns null for null header', () => { - expect(updateManager.parseAuthHeader(null)).toBeNull(); - }); - - test('returns null for non-Bearer header', () => { - expect(updateManager.parseAuthHeader('Basic realm="test"')).toBeNull(); - }); - - test('parses Bearer realm URL', () => { - const header = 'Bearer realm="https://auth.docker.io/token"'; - const result = updateManager.parseAuthHeader(header); - expect(result).toContain('https://auth.docker.io/token'); - }); - - test('includes service parameter', () => { - const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"'; - const result = updateManager.parseAuthHeader(header); - expect(result).toContain('service=registry.docker.io'); - }); - - test('includes scope parameter', () => { - const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"'; - const result = updateManager.parseAuthHeader(header); - expect(result).toContain('scope='); - }); -}); - -describe('getAvailableUpdates', () => { - test('returns empty array initially', () => { - expect(updateManager.getAvailableUpdates()).toEqual([]); - }); - - test('returns array from availableUpdates map', () => { - updateManager.availableUpdates.set('c1', { containerId: 'c1', imageName: 'nginx' }); - const updates = updateManager.getAvailableUpdates(); - expect(updates).toHaveLength(1); - expect(updates[0].containerId).toBe('c1'); - }); -}); - -describe('getHistory', () => { - test('returns entries in reverse order', () => { - updateManager.addToHistory({ containerId: 'c1', status: 'success' }); - updateManager.addToHistory({ containerId: 'c2', status: 'success' }); - const history = updateManager.getHistory(); - expect(history[0].containerId).toBe('c2'); - }); - - test('returns empty array when no history', () => { - expect(updateManager.getHistory()).toEqual([]); - }); - - test('respects limit parameter', () => { - for (let i = 0; i < 10; i++) { - updateManager.addToHistory({ containerId: `c${i}` }); - } - expect(updateManager.getHistory(3)).toHaveLength(3); - }); -}); - -describe('addToHistory', () => { - test('appends entry', () => { - updateManager.addToHistory({ containerId: 'c1' }); - expect(updateManager.history).toHaveLength(1); - }); - - test('trims to 100 entries', () => { - for (let i = 0; i < 105; i++) { - updateManager.addToHistory({ containerId: `c${i}` }); - } - expect(updateManager.history.length).toBeLessThanOrEqual(100); - }); -}); - -describe('configureAutoUpdate', () => { - test('creates autoUpdate config section', () => { - updateManager.configureAutoUpdate('c1', { enabled: true }); - expect(updateManager.config.autoUpdate['c1']).toBeDefined(); - }); - - test('stores container-specific config', () => { - updateManager.configureAutoUpdate('c1', { - enabled: true, - schedule: 'daily', - securityOnly: true - }); - expect(updateManager.config.autoUpdate['c1'].schedule).toBe('daily'); - expect(updateManager.config.autoUpdate['c1'].securityOnly).toBe(true); - }); - - test('defaults autoRollback to true', () => { - updateManager.configureAutoUpdate('c1', { enabled: true }); - expect(updateManager.config.autoUpdate['c1'].autoRollback).toBe(true); - }); - - test('defaults schedule to weekly', () => { - updateManager.configureAutoUpdate('c1', {}); - expect(updateManager.config.autoUpdate['c1'].schedule).toBe('weekly'); - }); -}); - -describe('scheduleUpdate', () => { - test('throws for past scheduled time', () => { - const past = new Date(Date.now() - 60000).toISOString(); - expect(() => updateManager.scheduleUpdate('c1', past)).toThrow('Scheduled time must be in the future'); - }); - - test('accepts future scheduled time', () => { - jest.useFakeTimers(); - const future = new Date(Date.now() + 60000).toISOString(); - expect(() => updateManager.scheduleUpdate('c1', future)).not.toThrow(); - jest.useRealTimers(); - }); -}); - -describe('getChangelog', () => { - test('returns placeholder response', async () => { - const result = await updateManager.getChangelog('nginx:latest'); - expect(result.imageName).toBe('nginx:latest'); - expect(result.changelog).toBeDefined(); - }); -}); - -describe('start / stop', () => { - test('start sets checking flag', () => { - jest.useFakeTimers(); - updateManager.start(); - expect(updateManager.checking).toBe(true); - updateManager.stop(); - jest.useRealTimers(); - }); - - test('stop clears interval', () => { - jest.useFakeTimers(); - updateManager.start(); - updateManager.stop(); - expect(updateManager.checking).toBe(false); - expect(updateManager.checkInterval).toBeNull(); - jest.useRealTimers(); - }); - - test('start is idempotent', () => { - jest.useFakeTimers(); - updateManager.start(); - const first = updateManager.checkInterval; - updateManager.start(); - expect(updateManager.checkInterval).toBe(first); - updateManager.stop(); - jest.useRealTimers(); - }); -}); diff --git a/dashcaddy-api/assets/.htaccess b/dashcaddy-api/assets/.htaccess new file mode 100644 index 0000000..86ba288 --- /dev/null +++ b/dashcaddy-api/assets/.htaccess @@ -0,0 +1,21 @@ +# Font file headers to prevent sanitizer issues + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, POST, OPTIONS" + Header set Access-Control-Allow-Headers "Content-Type" + Header set Cache-Control "public, max-age=31536000" + + # Proper MIME types + + AddType font/woff2 .woff2 + AddType font/woff .woff + AddType font/ttf .ttf + AddType application/vnd.ms-fontobject .eot + + + +# Prevent direct access to font conversion scripts + + Order allow,deny + Deny from all + \ No newline at end of file diff --git a/dashcaddy-api/BUFFER_AUDIT.md b/dashcaddy-api/assets/New Text Document.txt similarity index 100% rename from dashcaddy-api/BUFFER_AUDIT.md rename to dashcaddy-api/assets/New Text Document.txt diff --git a/dashcaddy-api/assets/SAMI-CLOUD.png b/dashcaddy-api/assets/SAMI-CLOUD.png new file mode 100644 index 0000000..d4d00f5 Binary files /dev/null and b/dashcaddy-api/assets/SAMI-CLOUD.png differ diff --git a/dashcaddy-api/assets/SAMI-CLOUD.svg b/dashcaddy-api/assets/SAMI-CLOUD.svg new file mode 100644 index 0000000..d8074eb --- /dev/null +++ b/dashcaddy-api/assets/SAMI-CLOUD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/Slink.png b/dashcaddy-api/assets/Slink.png new file mode 100644 index 0000000..951ecfe Binary files /dev/null and b/dashcaddy-api/assets/Slink.png differ diff --git a/dashcaddy-api/assets/apple-touch-icon.png b/dashcaddy-api/assets/apple-touch-icon.png new file mode 100644 index 0000000..c6fa919 Binary files /dev/null and b/dashcaddy-api/assets/apple-touch-icon.png differ diff --git a/dashcaddy-api/assets/certificate-icon.png b/dashcaddy-api/assets/certificate-icon.png new file mode 100644 index 0000000..a82a54f Binary files /dev/null and b/dashcaddy-api/assets/certificate-icon.png differ diff --git a/dashcaddy-api/assets/chat.png b/dashcaddy-api/assets/chat.png new file mode 100644 index 0000000..63735ad Binary files /dev/null and b/dashcaddy-api/assets/chat.png differ diff --git a/dashcaddy-api/assets/cloud-favicon-512.png b/dashcaddy-api/assets/cloud-favicon-512.png new file mode 100644 index 0000000..af4acae Binary files /dev/null and b/dashcaddy-api/assets/cloud-favicon-512.png differ diff --git a/dashcaddy-api/assets/custom-logo.png b/dashcaddy-api/assets/custom-logo.png new file mode 100644 index 0000000..d4d00f5 Binary files /dev/null and b/dashcaddy-api/assets/custom-logo.png differ diff --git a/dashcaddy-api/assets/custom-logo.svg b/dashcaddy-api/assets/custom-logo.svg new file mode 100644 index 0000000..d8074eb --- /dev/null +++ b/dashcaddy-api/assets/custom-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/dashcaddy-favicon.ico b/dashcaddy-api/assets/dashcaddy-favicon.ico new file mode 100644 index 0000000..a796077 Binary files /dev/null and b/dashcaddy-api/assets/dashcaddy-favicon.ico differ diff --git a/dashcaddy-api/assets/dashcaddy-favicon.png b/dashcaddy-api/assets/dashcaddy-favicon.png new file mode 100644 index 0000000..4fb8ce9 Binary files /dev/null and b/dashcaddy-api/assets/dashcaddy-favicon.png differ diff --git a/dashcaddy-api/assets/dashcaddy-logo.png b/dashcaddy-api/assets/dashcaddy-logo.png new file mode 100644 index 0000000..a82a54f Binary files /dev/null and b/dashcaddy-api/assets/dashcaddy-logo.png differ diff --git a/dashcaddy-api/assets/dashcaddy-logo.svg b/dashcaddy-api/assets/dashcaddy-logo.svg new file mode 100644 index 0000000..d8074eb --- /dev/null +++ b/dashcaddy-api/assets/dashcaddy-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/dns-template-selector.js b/dashcaddy-api/assets/dns-template-selector.js new file mode 100644 index 0000000..ef2b266 --- /dev/null +++ b/dashcaddy-api/assets/dns-template-selector.js @@ -0,0 +1,321 @@ +/** + * DNS Template Selector + * Presents DNS server template options when user chooses to set up DNS + */ + +(function(window) { + 'use strict'; + + class DnsTemplateSelector { + constructor(progressTracker) { + this.progressTracker = progressTracker; + this.modal = null; + this.onTemplateSelected = null; + console.log('[DnsTemplateSelector] Module loaded'); + } + + /** + * Get available DNS server templates from app templates + * @returns {Array} Array of DNS template objects + */ + getDnsTemplates() { + // In a real implementation, this would fetch from app-templates.js + // For now, return hardcoded templates matching what we added + return [ + { + id: 'technitium', + name: 'Technitium DNS Server', + description: 'Modern DNS server with web UI for managing private zones', + icon: '🌐', + difficulty: 'Easy', + features: [ + 'Web-based management interface', + 'Private zone management for .sami domain', + 'DHCP server integration', + 'DNS-over-HTTPS and DNS-over-TLS support' + ], + recommended: true + }, + { + id: 'bind9', + name: 'BIND9 DNS Server', + description: 'Industry-standard DNS server - powerful and flexible', + icon: '🔧', + difficulty: 'Advanced', + features: [ + 'Industry standard DNS server', + 'Full RFC compliance', + 'Advanced zone management', + 'DNSSEC support' + ], + recommended: false + }, + { + id: 'pihole', + name: 'Pi-hole', + description: 'Network-wide ad blocker with DNS capabilities', + icon: '🛡️', + difficulty: 'Intermediate', + features: [ + 'Ad blocking at DNS level', + 'Web interface for management', + 'DHCP server included', + 'Query logging and statistics' + ], + recommended: false + }, + { + id: 'powerdns', + name: 'PowerDNS', + description: 'High-performance DNS server with SQL backend', + icon: '⚡', + difficulty: 'Intermediate', + features: [ + 'SQL database backend', + 'RESTful API for automation', + 'Geographic load balancing', + 'DNSSEC support' + ], + recommended: false + }, + { + id: 'coredns', + name: 'CoreDNS', + description: 'Cloud-native DNS server - lightweight and flexible', + icon: '☁️', + difficulty: 'Intermediate', + features: [ + 'Plugin-based architecture', + 'Kubernetes-native', + 'Lightweight and fast', + 'Prometheus metrics' + ], + recommended: false + } + ]; + } + + /** + * Show DNS template selection modal + */ + showTemplateSelector() { + // Create modal if it doesn't exist + if (!this.modal) { + this.createModal(); + } + + // Populate with templates + this.populateTemplates(); + + // Show modal + this.modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + } + + /** + * Create the modal HTML structure + * @private + */ + createModal() { + const modal = document.createElement('div'); + modal.id = 'dns-template-modal'; + modal.className = 'dns-template-modal'; + modal.innerHTML = ` +
+
+

🌐 Choose a DNS Server

+

Setting up a DNS server is essential for managing your private .sami domain

+ +
+
+ +
+ +
+ `; + + document.body.appendChild(modal); + this.modal = modal; + + // Add event listeners + modal.querySelector('.dns-template-close').addEventListener('click', () => this.close()); + modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater()); + + // Close on overlay click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + this.close(); + } + }); + + // Close on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.style.display === 'flex') { + this.close(); + } + }); + } + + /** + * Populate modal with DNS templates + * @private + */ + populateTemplates() { + const grid = document.getElementById('dns-template-grid'); + if (!grid) return; + + const templates = this.getDnsTemplates(); + grid.innerHTML = ''; + + templates.forEach(template => { + const card = this.createTemplateCard(template); + grid.appendChild(card); + }); + } + + /** + * Create a template card element + * @private + */ + createTemplateCard(template) { + const card = document.createElement('div'); + card.className = 'dns-template-card'; + if (template.recommended) { + card.classList.add('recommended'); + } + + const difficultyClass = template.difficulty.toLowerCase(); + + card.innerHTML = ` + ${template.recommended ? '' : ''} +
${template.icon}
+

${template.name}

+

${template.description}

+
+ ${template.difficulty} +
+ + + `; + + // Add click handler to select button + const selectBtn = card.querySelector('.dns-template-select-btn'); + selectBtn.addEventListener('click', () => this.handleTemplateSelection(template)); + + return card; + } + + /** + * Handle template selection + * @private + */ + handleTemplateSelection(template) { + console.log(`[DnsTemplateSelector] Template selected: ${template.id}`); + + // Close modal + this.close(); + + // Trigger callback if set + if (this.onTemplateSelected) { + this.onTemplateSelected(template); + } else { + // Default behavior: open app selector with DNS filter + this.openAppSelector(template.id); + } + } + + /** + * Handle "Set up later" button + * @private + */ + handleSetupLater() { + console.log('[DnsTemplateSelector] DNS setup deferred'); + + // Mark as deferred in progress tracker + if (this.progressTracker) { + this.progressTracker.markDnsSetupDeferred(); + } + + // Close modal + this.close(); + + // Show notification + this.showNotification('DNS setup deferred. You can set it up later from the App Selector.'); + } + + /** + * Open app selector with specific template + * @private + */ + openAppSelector(templateId) { + // Try to open the app selector modal if it exists + const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]'); + if (appSelectorBtn) { + appSelectorBtn.click(); + + // Wait a bit then filter to the selected template + setTimeout(() => { + const searchInput = document.querySelector('#app-search'); + if (searchInput) { + searchInput.value = templateId; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 300); + } else { + // Fallback: show instructions + this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`); + } + } + + /** + * Show notification message + * @private + */ + showNotification(message) { + // Simple notification - could be enhanced + const notification = document.createElement('div'); + notification.className = 'dns-template-notification'; + notification.textContent = message; + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: var(--card-base); + color: var(--fg); + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 10001; + max-width: 300px; + `; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transition = 'opacity 0.3s'; + setTimeout(() => notification.remove(), 300); + }, 3000); + } + + /** + * Close the modal + */ + close() { + if (this.modal) { + this.modal.style.display = 'none'; + document.body.style.overflow = ''; + } + } + } + + window.DnsTemplateSelector = DnsTemplateSelector; + console.log('[DnsTemplateSelector] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/driver.min.css b/dashcaddy-api/assets/driver.min.css new file mode 100644 index 0000000..7ce0bd6 --- /dev/null +++ b/dashcaddy-api/assets/driver.min.css @@ -0,0 +1 @@ +.driver-active .driver-overlay,.driver-active *{pointer-events:none}.driver-active .driver-active-element,.driver-active .driver-active-element *,.driver-popover,.driver-popover *{pointer-events:auto}@keyframes animate-fade-in{0%{opacity:0}to{opacity:1}}.driver-fade .driver-overlay{animation:animate-fade-in .2s ease-in-out}.driver-fade .driver-popover{animation:animate-fade-in .2s}.driver-popover{all:unset;box-sizing:border-box;color:#2d2d2d;margin:0;padding:15px;border-radius:5px;min-width:250px;max-width:300px;box-shadow:0 1px 10px #0006;z-index:1000000000;position:fixed;top:0;right:0;background-color:#fff}.driver-popover *{font-family:Helvetica Neue,Inter,ui-sans-serif,"Apple Color Emoji",Helvetica,Arial,sans-serif}.driver-popover-title{font:19px/normal sans-serif;font-weight:700;display:block;position:relative;line-height:1.5;zoom:1;margin:0}.driver-popover-close-btn{all:unset;position:absolute;top:0;right:0;width:32px;height:28px;cursor:pointer;font-size:18px;font-weight:500;color:#d2d2d2;z-index:1;text-align:center;transition:color;transition-duration:.2s}.driver-popover-close-btn:hover,.driver-popover-close-btn:focus{color:#2d2d2d}.driver-popover-title[style*=block]+.driver-popover-description{margin-top:5px}.driver-popover-description{margin-bottom:0;font:14px/normal sans-serif;line-height:1.5;font-weight:400;zoom:1}.driver-popover-footer{margin-top:15px;text-align:right;zoom:1;display:flex;align-items:center;justify-content:space-between}.driver-popover-progress-text{font-size:13px;font-weight:400;color:#727272;zoom:1}.driver-popover-footer button{all:unset;display:inline-block;box-sizing:border-box;padding:3px 7px;text-decoration:none;text-shadow:1px 1px 0 #fff;background-color:#fff;color:#2d2d2d;font:12px/normal sans-serif;cursor:pointer;outline:0;zoom:1;line-height:1.3;border:1px solid #ccc;border-radius:3px}.driver-popover-footer .driver-popover-btn-disabled{opacity:.5;pointer-events:none}:not(body):has(>.driver-active-element){overflow:hidden!important}.driver-no-interaction,.driver-no-interaction *{pointer-events:none!important}.driver-popover-footer button:hover,.driver-popover-footer button:focus{background-color:#f7f7f7}.driver-popover-navigation-btns{display:flex;flex-grow:1;justify-content:flex-end}.driver-popover-navigation-btns button+button{margin-left:4px}.driver-popover-arrow{content:"";position:absolute;border:5px solid #fff}.driver-popover-arrow-side-over{display:none}.driver-popover-arrow-side-left{left:100%;border-right-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-right{right:100%;border-left-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-top{top:100%;border-right-color:transparent;border-bottom-color:transparent;border-left-color:transparent}.driver-popover-arrow-side-bottom{bottom:100%;border-left-color:transparent;border-top-color:transparent;border-right-color:transparent}.driver-popover-arrow-side-center{display:none}.driver-popover-arrow-side-left.driver-popover-arrow-align-start,.driver-popover-arrow-side-right.driver-popover-arrow-align-start{top:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-start,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start{left:15px}.driver-popover-arrow-align-end.driver-popover-arrow-side-left,.driver-popover-arrow-align-end.driver-popover-arrow-side-right{bottom:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-end,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end{right:15px}.driver-popover-arrow-side-left.driver-popover-arrow-align-center,.driver-popover-arrow-side-right.driver-popover-arrow-align-center{top:50%;margin-top:-5px}.driver-popover-arrow-side-top.driver-popover-arrow-align-center,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center{left:50%;margin-left:-5px}.driver-popover-arrow-none{display:none} diff --git a/dashcaddy-api/assets/driver.min.js b/dashcaddy-api/assets/driver.min.js new file mode 100644 index 0000000..87acd6e --- /dev/null +++ b/dashcaddy-api/assets/driver.min.js @@ -0,0 +1,2 @@ +this.driver=this.driver||{};this.driver.js=function(D){"use strict";let F={};function z(e={}){F={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?F[e]:F}function W(e,o,t,i){return(e/=i/2)<1?t/2*e*e+o:-t/2*(--e*(e-2)-1)+o}function q(e){const o='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(t=>{const i=t.matches(o),p=Array.from(t.querySelectorAll(o));return[...i?[t]:[],...p]}).filter(t=>getComputedStyle(t).pointerEvents!=="none"&&ae(t))}function V(e){if(!e||se(e))return;const o=a("smoothScroll");e.scrollIntoView({behavior:!o||re(e)?"auto":"smooth",inline:"center",block:"center"})}function re(e){if(!e||!e.parentElement)return;const o=e.parentElement;return o.scrollHeight>o.clientHeight}function se(e){const o=e.getBoundingClientRect();return o.top>=0&&o.left>=0&&o.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&o.right<=(window.innerWidth||document.documentElement.clientWidth)}function ae(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let N={};function b(e,o){N[e]=o}function l(e){return e?N[e]:N}function K(){N={}}let E={};function O(e,o){E[e]=o}function _(e){var o;(o=E[e])==null||o.call(E)}function ce(){E={}}function le(e,o,t,i){let p=l("__activeStagePosition");const n=p||t.getBoundingClientRect(),f=i.getBoundingClientRect(),w=W(e,n.x,f.x-n.x,o),r=W(e,n.y,f.y-n.y,o),v=W(e,n.width,f.width-n.width,o),s=W(e,n.height,f.height-n.height,o);p={x:w,y:r,width:v,height:s},Y(p),b("__activeStagePosition",p)}function X(e){if(!e)return;const o=e.getBoundingClientRect(),t={x:o.x,y:o.y,width:o.width,height:o.height};b("__activeStagePosition",t),Y(t)}function de(){const e=l("__activeStagePosition"),o=l("__overlaySvg");if(!e)return;if(!o){console.warn("No stage svg found.");return}const t=window.innerWidth,i=window.innerHeight;o.setAttribute("viewBox",`0 0 ${t} ${i}`)}function pe(e){const o=ue(e);document.body.appendChild(o),G(o,t=>{t.target.tagName==="path"&&_("overlayClick")}),b("__overlaySvg",o)}function Y(e){const o=l("__overlaySvg");if(!o){pe(e);return}const t=o.firstElementChild;if((t==null?void 0:t.tagName)!=="path")throw new Error("no path element found in stage svg");t.setAttribute("d",j(e))}function ue(e){const o=window.innerWidth,t=window.innerHeight,i=document.createElementNS("http://www.w3.org/2000/svg","svg");i.classList.add("driver-overlay","driver-overlay-animated"),i.setAttribute("viewBox",`0 0 ${o} ${t}`),i.setAttribute("xmlSpace","preserve"),i.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),i.setAttribute("version","1.1"),i.setAttribute("preserveAspectRatio","xMinYMin slice"),i.style.fillRule="evenodd",i.style.clipRule="evenodd",i.style.strokeLinejoin="round",i.style.strokeMiterlimit="2",i.style.zIndex="10000",i.style.position="fixed",i.style.top="0",i.style.left="0",i.style.width="100%",i.style.height="100%";const p=document.createElementNS("http://www.w3.org/2000/svg","path");return p.setAttribute("d",j(e)),p.style.fill=a("overlayColor")||"rgb(0,0,0)",p.style.opacity=`${a("overlayOpacity")}`,p.style.pointerEvents="auto",p.style.cursor="auto",i.appendChild(p),i}function j(e){const o=window.innerWidth,t=window.innerHeight,i=a("stagePadding")||0,p=a("stageRadius")||0,n=e.width+i*2,f=e.height+i*2,w=Math.min(p,n/2,f/2),r=Math.floor(Math.max(w,0)),v=e.x-i+r,s=e.y-i,c=n-r*2,d=f-r*2;return`M${o},0L0,0L0,${t}L${o},${t}L${o},0Z + M${v},${s} h${c} a${r},${r} 0 0 1 ${r},${r} v${d} a${r},${r} 0 0 1 -${r},${r} h-${c} a${r},${r} 0 0 1 -${r},-${r} v-${d} a${r},${r} 0 0 1 ${r},-${r} z`}function ve(){const e=l("__overlaySvg");e&&e.remove()}function fe(){const e=document.getElementById("driver-dummy-element");if(e)return e;let o=document.createElement("div");return o.id="driver-dummy-element",o.style.width="0",o.style.height="0",o.style.pointerEvents="none",o.style.opacity="0",o.style.position="fixed",o.style.top="50%",o.style.left="50%",document.body.appendChild(o),o}function Q(e){const{element:o}=e;let t=typeof o=="string"?document.querySelector(o):o;t||(t=fe()),ge(t,e)}function he(){const e=l("__activeElement"),o=l("__activeStep");e&&(X(e),de(),oe(e,o))}function ge(e,o){const i=Date.now(),p=l("__activeStep"),n=l("__activeElement")||e,f=!n||n===e,w=e.id==="driver-dummy-element",r=n.id==="driver-dummy-element",v=a("animate"),s=o.onHighlightStarted||a("onHighlightStarted"),c=(o==null?void 0:o.onHighlighted)||a("onHighlighted"),d=(p==null?void 0:p.onDeselected)||a("onDeselected"),m=a(),g=l();!f&&d&&d(r?void 0:n,p,{config:m,state:g}),s&&s(w?void 0:e,o,{config:m,state:g});const u=!f&&v;let h=!1;xe(),b("previousStep",p),b("previousElement",n),b("activeStep",o),b("activeElement",e);const P=()=>{if(l("__transitionCallback")!==P)return;const x=Date.now()-i,y=400-x<=400/2;o.popover&&y&&!h&&u&&(J(e,o),h=!0),a("animate")&&x<400?le(x,400,n,e):(X(e),c&&c(w?void 0:e,o,{config:a(),state:l()}),b("__transitionCallback",void 0),b("__previousStep",p),b("__previousElement",n),b("__activeStep",o),b("__activeElement",e)),window.requestAnimationFrame(P)};b("__transitionCallback",P),window.requestAnimationFrame(P),V(e),!u&&o.popover&&J(e,o),n.classList.remove("driver-active-element","driver-no-interaction"),n.removeAttribute("aria-haspopup"),n.removeAttribute("aria-expanded"),n.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function we(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(o=>{o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls")})}function A(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),b("__resizeTimeout",window.requestAnimationFrame(he))}function me(e){var r;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),p=(r=l("popover"))==null?void 0:r.wrapper,n=q([...p?[p]:[],...i?[i]:[]]),f=n[0],w=n[n.length-1];if(e.preventDefault(),e.shiftKey){const v=n[n.indexOf(document.activeElement)-1]||w;v==null||v.focus()}else{const v=n[n.indexOf(document.activeElement)+1]||f;v==null||v.focus()}}function Z(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?_("escapePress"):e.key==="ArrowRight"?_("arrowRightPress"):e.key==="ArrowLeft"&&_("arrowLeftPress"))}function G(e,o,t){const i=(n,f)=>{const w=n.target;e.contains(w)&&((!t||t(w))&&(n.preventDefault(),n.stopPropagation(),n.stopImmediatePropagation()),f==null||f(n))};document.addEventListener("pointerdown",i,!0),document.addEventListener("mousedown",i,!0),document.addEventListener("pointerup",i,!0),document.addEventListener("mouseup",i,!0),document.addEventListener("click",n=>{i(n,o)},!0)}function ye(){window.addEventListener("keyup",Z,!1),window.addEventListener("keydown",me,!1),window.addEventListener("resize",A),window.addEventListener("scroll",A)}function be(){window.removeEventListener("keyup",Z),window.removeEventListener("resize",A),window.removeEventListener("scroll",A)}function xe(){const e=l("popover");e&&(e.wrapper.style.display="none")}function J(e,o){var C,y;let t=l("popover");t&&document.body.removeChild(t.wrapper),t=Pe(),document.body.appendChild(t.wrapper);const{title:i,description:p,showButtons:n,disableButtons:f,showProgress:w,nextBtnText:r=a("nextBtnText")||"Next →",prevBtnText:v=a("prevBtnText")||"← Previous",progressText:s=a("progressText")||"{current} of {total}"}=o.popover||{};t.nextButton.innerHTML=r,t.previousButton.innerHTML=v,t.progress.innerHTML=s,i?(t.title.innerHTML=i,t.title.style.display="block"):t.title.style.display="none",p?(t.description.innerHTML=p,t.description.style.display="block"):t.description.style.display="none";const c=n||a("showButtons"),d=w||a("showProgress")||!1,m=(c==null?void 0:c.includes("next"))||(c==null?void 0:c.includes("previous"))||d;t.closeButton.style.display=c.includes("close")?"block":"none",m?(t.footer.style.display="flex",t.progress.style.display=d?"block":"none",t.nextButton.style.display=c.includes("next")?"block":"none",t.previousButton.style.display=c.includes("previous")?"block":"none"):t.footer.style.display="none";const g=f||a("disableButtons")||[];g!=null&&g.includes("next")&&(t.nextButton.disabled=!0,t.nextButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("previous")&&(t.previousButton.disabled=!0,t.previousButton.classList.add("driver-popover-btn-disabled")),g!=null&&g.includes("close")&&(t.closeButton.disabled=!0,t.closeButton.classList.add("driver-popover-btn-disabled"));const u=t.wrapper;u.style.display="block",u.style.left="",u.style.top="",u.style.bottom="",u.style.right="",u.id="driver-popover-content",u.setAttribute("role","dialog"),u.setAttribute("aria-labelledby","driver-popover-title"),u.setAttribute("aria-describedby","driver-popover-description");const h=t.arrow;h.className="driver-popover-arrow";const P=((C=o.popover)==null?void 0:C.popoverClass)||a("popoverClass")||"";u.className=`driver-popover ${P}`.trim(),G(t.wrapper,k=>{var M,R,I;const T=k.target,H=((M=o.popover)==null?void 0:M.onNextClick)||a("onNextClick"),$=((R=o.popover)==null?void 0:R.onPrevClick)||a("onPrevClick"),B=((I=o.popover)==null?void 0:I.onCloseClick)||a("onCloseClick");if(T.classList.contains("driver-popover-next-btn"))return H?H(e,o,{config:a(),state:l()}):_("nextClick");if(T.classList.contains("driver-popover-prev-btn"))return $?$(e,o,{config:a(),state:l()}):_("prevClick");if(T.classList.contains("driver-popover-close-btn"))return B?B(e,o,{config:a(),state:l()}):_("closeClick")},k=>!(t!=null&&t.description.contains(k))&&!(t!=null&&t.title.contains(k))&&typeof k.className=="string"&&k.className.includes("driver-popover")),b("popover",t);const S=((y=o.popover)==null?void 0:y.onPopoverRender)||a("onPopoverRender");S&&S(t,{config:a(),state:l()}),oe(e,o),V(u);const L=e.classList.contains("driver-dummy-element"),x=q([u,...L?[]:[e]]);x.length>0&&x[0].focus()}function U(){const e=l("popover");if(!(e!=null&&e.wrapper))return;const o=e.wrapper.getBoundingClientRect(),t=a("stagePadding")||0,i=a("popoverOffset")||0;return{width:o.width+t+i,height:o.height+t+i,realWidth:o.width,realHeight:o.height}}function ee(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.top-p,window.innerHeight-i.realHeight-n.width),n.width):e==="end"?Math.max(Math.min(t.top-(i==null?void 0:i.realHeight)+t.height+p,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):e==="center"?Math.max(Math.min(t.top+t.height/2-(i==null?void 0:i.realHeight)/2,window.innerHeight-(i==null?void 0:i.realHeight)-n.width),n.width):0}function te(e,o){const{elementDimensions:t,popoverDimensions:i,popoverPadding:p,popoverArrowDimensions:n}=o;return e==="start"?Math.max(Math.min(t.left-p,window.innerWidth-i.realWidth-n.width),n.width):e==="end"?Math.max(Math.min(t.left-(i==null?void 0:i.realWidth)+t.width+p,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):e==="center"?Math.max(Math.min(t.left+t.width/2-(i==null?void 0:i.realWidth)/2,window.innerWidth-(i==null?void 0:i.realWidth)-n.width),n.width):0}function oe(e,o){const t=l("popover");if(!t)return;const{align:i="start",side:p="left"}=(o==null?void 0:o.popover)||{},n=i,f=e.id==="driver-dummy-element"?"over":p,w=a("stagePadding")||0,r=U(),v=t.arrow.getBoundingClientRect(),s=e.getBoundingClientRect(),c=s.top-r.height;let d=c>=0;const m=window.innerHeight-(s.bottom+r.height);let g=m>=0;const u=s.left-r.width;let h=u>=0;const P=window.innerWidth-(s.right+r.width);let S=P>=0;const L=!d&&!g&&!h&&!S;let x=f;if(f==="top"&&d?S=h=g=!1:f==="bottom"&&g?S=h=d=!1:f==="left"&&h?S=d=g=!1:f==="right"&&S&&(h=d=g=!1),f==="over"){const C=window.innerWidth/2-r.realWidth/2,y=window.innerHeight/2-r.realHeight/2;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto"}else if(L){const C=window.innerWidth/2-(r==null?void 0:r.realWidth)/2,y=10;t.wrapper.style.left=`${C}px`,t.wrapper.style.right="auto",t.wrapper.style.bottom=`${y}px`,t.wrapper.style.top="auto"}else if(h){const C=Math.min(u,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="left"}else if(S){const C=Math.min(P,window.innerWidth-(r==null?void 0:r.realWidth)-v.width),y=ee(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.right=`${C}px`,t.wrapper.style.top=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.left="auto",x="right"}else if(d){const C=Math.min(c,window.innerHeight-r.realHeight-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.top=`${C}px`,t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom="auto",t.wrapper.style.right="auto",x="top"}else if(g){const C=Math.min(m,window.innerHeight-(r==null?void 0:r.realHeight)-v.width);let y=te(n,{elementDimensions:s,popoverDimensions:r,popoverPadding:w,popoverArrowDimensions:v});t.wrapper.style.left=`${y}px`,t.wrapper.style.bottom=`${C}px`,t.wrapper.style.top="auto",t.wrapper.style.right="auto",x="bottom"}L?t.arrow.classList.add("driver-popover-arrow-none"):Ce(n,x,e)}function Ce(e,o,t){const i=l("popover");if(!i)return;const p=t.getBoundingClientRect(),n=U(),f=i.arrow,w=n.width,r=window.innerWidth,v=p.width,s=p.left,c=n.height,d=window.innerHeight,m=p.top,g=p.height;f.className="driver-popover-arrow";let u=o,h=e;o==="top"?(s+v<=0?(u="right",h="end"):s+v-w<=0&&(u="top",h="start"),s>=r?(u="left",h="end"):s+w>=r&&(u="top",h="end")):o==="bottom"?(s+v<=0?(u="right",h="start"):s+v-w<=0&&(u="bottom",h="start"),s>=r?(u="left",h="start"):s+w>=r&&(u="bottom",h="end")):o==="left"?(m+g<=0?(u="bottom",h="end"):m+g-c<=0&&(u="left",h="start"),m>=d?(u="top",h="end"):m+c>=d&&(u="left",h="end")):o==="right"&&(m+g<=0?(u="bottom",h="start"):m+g-c<=0&&(u="right",h="start"),m>=d?(u="top",h="start"):m+c>=d&&(u="right",h="end")),u?(f.classList.add(`driver-popover-arrow-side-${u}`),f.classList.add(`driver-popover-arrow-align-${h}`)):f.classList.add("driver-popover-arrow-none")}function Pe(){const e=document.createElement("div");e.classList.add("driver-popover");const o=document.createElement("div");o.classList.add("driver-popover-arrow");const t=document.createElement("header");t.id="driver-popover-title",t.classList.add("driver-popover-title"),t.style.display="none",t.innerText="Popover Title";const i=document.createElement("div");i.id="driver-popover-description",i.classList.add("driver-popover-description"),i.style.display="none",i.innerText="Popover description is here";const p=document.createElement("button");p.type="button",p.classList.add("driver-popover-close-btn"),p.setAttribute("aria-label","Close"),p.innerHTML="×";const n=document.createElement("footer");n.classList.add("driver-popover-footer");const f=document.createElement("span");f.classList.add("driver-popover-progress-text"),f.innerText="";const w=document.createElement("span");w.classList.add("driver-popover-navigation-btns");const r=document.createElement("button");r.type="button",r.classList.add("driver-popover-prev-btn"),r.innerHTML="← Previous";const v=document.createElement("button");return v.type="button",v.classList.add("driver-popover-next-btn"),v.innerHTML="Next →",w.appendChild(r),w.appendChild(v),n.appendChild(f),n.appendChild(w),e.appendChild(p),e.appendChild(o),e.appendChild(t),e.appendChild(i),e.appendChild(n),{wrapper:e,arrow:o,title:t,description:i,footer:n,previousButton:r,nextButton:v,closeButton:p,footerButtons:w,progress:f}}function Se(){var o;const e=l("popover");e&&((o=e.wrapper.parentElement)==null||o.removeChild(e.wrapper))}const Le="";function ke(e={}){z(e);function o(){a("allowClose")&&v()}function t(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s+1;c[d]?r(d):v()}function i(){const s=l("activeIndex"),c=a("steps")||[];if(typeof s=="undefined")return;const d=s-1;c[d]?r(d):v()}function p(s){(a("steps")||[])[s]?r(s):v()}function n(){var h;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined"||typeof l("activeIndex")=="undefined")return;const u=((h=d.popover)==null?void 0:h.onPrevClick)||a("onPrevClick");if(u)return u(m,d,{config:a(),state:l()});i()}function f(){var u;if(l("__transitionCallback"))return;const c=l("activeIndex"),d=l("__activeStep"),m=l("__activeElement");if(typeof c=="undefined"||typeof d=="undefined")return;const g=((u=d.popover)==null?void 0:u.onNextClick)||a("onNextClick");if(g)return g(m,d,{config:a(),state:l()});t()}function w(){l("isInitialized")||(b("isInitialized",!0),document.body.classList.add("driver-active",a("animate")?"driver-fade":"driver-simple"),ye(),O("overlayClick",o),O("escapePress",o),O("arrowLeftPress",n),O("arrowRightPress",f))}function r(s=0){var H,$,B,M,R,I,ie,ne;const c=a("steps");if(!c){console.error("No steps to drive through"),v();return}if(!c[s]){v();return}b("__activeOnDestroyed",document.activeElement),b("activeIndex",s);const d=c[s],m=c[s+1],g=c[s-1],u=((H=d.popover)==null?void 0:H.doneBtnText)||a("doneBtnText")||"Done",h=a("allowClose"),P=typeof(($=d.popover)==null?void 0:$.showProgress)!="undefined"?(B=d.popover)==null?void 0:B.showProgress:a("showProgress"),L=(((M=d.popover)==null?void 0:M.progressText)||a("progressText")||"{{current}} of {{total}}").replace("{{current}}",`${s+1}`).replace("{{total}}",`${c.length}`),x=((R=d.popover)==null?void 0:R.showButtons)||a("showButtons"),C=["next","previous",...h?["close"]:[]].filter(_e=>!(x!=null&&x.length)||x.includes(_e)),y=((I=d.popover)==null?void 0:I.onNextClick)||a("onNextClick"),k=((ie=d.popover)==null?void 0:ie.onPrevClick)||a("onPrevClick"),T=((ne=d.popover)==null?void 0:ne.onCloseClick)||a("onCloseClick");Q({...d,popover:{showButtons:C,nextBtnText:m?void 0:u,disableButtons:[...g?[]:["previous"]],showProgress:P,progressText:L,onNextClick:y||(()=>{m?r(s+1):v()}),onPrevClick:k||(()=>{r(s-1)}),onCloseClick:T||(()=>{v()}),...(d==null?void 0:d.popover)||{}}})}function v(s=!0){const c=l("__activeElement"),d=l("__activeStep"),m=l("__activeOnDestroyed"),g=a("onDestroyStarted");if(s&&g){const P=!c||(c==null?void 0:c.id)==="driver-dummy-element";g(P?void 0:c,d,{config:a(),state:l()});return}const u=(d==null?void 0:d.onDeselected)||a("onDeselected"),h=a("onDestroyed");if(document.body.classList.remove("driver-active","driver-fade","driver-simple"),be(),Se(),we(),ve(),ce(),K(),c&&d){const P=c.id==="driver-dummy-element";u&&u(P?void 0:c,d,{config:a(),state:l()}),h&&h(P?void 0:c,d,{config:a(),state:l()})}m&&m.focus()}return{isActive:()=>l("isInitialized")||!1,refresh:A,drive:(s=0)=>{w(),r(s)},setConfig:z,setSteps:s=>{K(),z({...a(),steps:s})},getConfig:a,getState:l,getActiveIndex:()=>l("activeIndex"),isFirstStep:()=>l("activeIndex")===0,isLastStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&c===s.length-1},getActiveStep:()=>l("activeStep"),getActiveElement:()=>l("activeElement"),getPreviousElement:()=>l("previousElement"),getPreviousStep:()=>l("previousStep"),moveNext:t,movePrevious:i,moveTo:p,hasNextStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c+1]},hasPreviousStep:()=>{const s=a("steps")||[],c=l("activeIndex");return c!==void 0&&s[c-1]},highlight:s=>{w(),Q({...s,popover:s.popover?{showButtons:[],showProgress:!1,progressText:"",...s.popover}:void 0})},destroy:()=>{v(!1)}}}return D.driver=ke,Object.defineProperty(D,Symbol.toStringTag,{value:"Module"}),D}({}); diff --git a/dashcaddy-api/assets/emby.png b/dashcaddy-api/assets/emby.png new file mode 100644 index 0000000..ad7a8cd Binary files /dev/null and b/dashcaddy-api/assets/emby.png differ diff --git a/dashcaddy-api/assets/error-handler.js b/dashcaddy-api/assets/error-handler.js new file mode 100644 index 0000000..4b27c21 --- /dev/null +++ b/dashcaddy-api/assets/error-handler.js @@ -0,0 +1,259 @@ +/** + * Error Handler + * Handles errors gracefully without breaking the onboarding tour + */ + +(function(window) { + 'use strict'; + + class ErrorHandler { + constructor() { + this.errors = []; + this.maxErrors = 50; // Keep last 50 errors + } + + /** + * Log an error without breaking the tour + * @param {string} context - Context where error occurred + * @param {Error|string} error - The error object or message + * @param {Object} metadata - Additional metadata + */ + logError(context, error, metadata = {}) { + const errorEntry = { + timestamp: new Date().toISOString(), + context, + message: error instanceof Error ? error.message : error, + stack: error instanceof Error ? error.stack : null, + metadata + }; + + // Add to errors array + this.errors.push(errorEntry); + + // Keep only last maxErrors + if (this.errors.length > this.maxErrors) { + this.errors.shift(); + } + + // Log to console + console.error(`[Onboarding Error] ${context}:`, error, metadata); + + // Optionally send to error tracking service + // this.sendToErrorTracking(errorEntry); + } + + /** + * Attempt to recover from an error and continue tour + * @param {Error} error - The error object + * @param {number} currentStep - Current step index + * @returns {Object} Recovery action + */ + recoverFromError(error, currentStep) { + const errorType = this.classifyError(error); + + switch (errorType) { + case 'ELEMENT_NOT_FOUND': + this.logError('Element Not Found', error, { currentStep }); + return { + action: 'SKIP_STEP', + nextStep: currentStep + 1, + message: 'Target element not found, skipping to next step' + }; + + case 'STORAGE_UNAVAILABLE': + this.logError('Storage Unavailable', error); + return { + action: 'USE_MEMORY_STORAGE', + message: 'Local storage unavailable, using in-memory storage' + }; + + case 'DRIVER_NOT_LOADED': + this.logError('Driver.js Not Loaded', error); + return { + action: 'ABORT_TOUR', + message: 'Driver.js library not loaded, cannot start tour' + }; + + case 'INVALID_TOOLTIP': + this.logError('Invalid Tooltip Configuration', error, { currentStep }); + return { + action: 'SKIP_STEP', + nextStep: currentStep + 1, + message: 'Invalid tooltip configuration, skipping' + }; + + case 'THEME_DETECTION_FAILED': + this.logError('Theme Detection Failed', error); + return { + action: 'USE_DEFAULT_THEME', + message: 'Using default dark theme' + }; + + default: + this.logError('Unknown Error', error, { currentStep }); + return { + action: 'ABORT_TOUR', + message: 'Unexpected error occurred, aborting tour' + }; + } + } + + /** + * Classify error type + * @private + * @param {Error} error - The error object + * @returns {string} Error type + */ + classifyError(error) { + const message = error.message || error.toString(); + + if (message.includes('element') && message.includes('not found')) { + return 'ELEMENT_NOT_FOUND'; + } + if (message.includes('storage') || message.includes('quota')) { + return 'STORAGE_UNAVAILABLE'; + } + if (message.includes('driver') || message.includes('undefined')) { + return 'DRIVER_NOT_LOADED'; + } + if (message.includes('invalid') || message.includes('validation')) { + return 'INVALID_TOOLTIP'; + } + if (message.includes('theme')) { + return 'THEME_DETECTION_FAILED'; + } + + return 'UNKNOWN'; + } + + /** + * Get all logged errors + * @returns {Array} Array of error entries + */ + getErrors() { + return [...this.errors]; + } + + /** + * Clear all logged errors + */ + clearErrors() { + this.errors = []; + } + + /** + * Get error statistics + * @returns {Object} Error statistics + */ + getStatistics() { + const stats = { + total: this.errors.length, + byContext: {}, + byType: {}, + recent: this.errors.slice(-10) + }; + + this.errors.forEach(error => { + // Count by context + stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1; + + // Count by type + const type = this.classifyError({ message: error.message }); + stats.byType[type] = (stats.byType[type] || 0) + 1; + }); + + return stats; + } + + /** + * Handle graceful degradation when Driver.js fails to load + * @returns {boolean} Whether fallback was successful + */ + handleDriverLoadFailure() { + this.logError('Driver.js Load Failure', 'Driver.js library failed to load'); + + // Show fallback message + const fallbackMessage = document.createElement('div'); + fallbackMessage.id = 'onboarding-fallback'; + fallbackMessage.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: var(--card-base, #2a2a2a); + color: var(--fg, #ffffff); + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 9999; + max-width: 300px; + font-size: 14px; + `; + fallbackMessage.innerHTML = ` + Welcome to DashCaddy!
+

+ The interactive tour is unavailable, but you can explore the dashboard freely. + Check the documentation for help getting started. +

+ `; + + document.body.appendChild(fallbackMessage); + + // Auto-remove after 10 seconds + setTimeout(() => { + if (fallbackMessage.parentNode) { + fallbackMessage.parentNode.removeChild(fallbackMessage); + } + }, 10000); + + return true; + } + + /** + * Handle storage unavailable scenario + * @returns {Object} In-memory storage fallback + */ + handleStorageUnavailable() { + this.logError('Storage Unavailable', 'Local storage is not available'); + + // Create in-memory storage + const memoryStorage = { + data: {}, + getItem(key) { + return this.data[key] || null; + }, + setItem(key, value) { + this.data[key] = value; + }, + removeItem(key) { + delete this.data[key]; + }, + clear() { + this.data = {}; + } + }; + + console.warn('[ErrorHandler] Using in-memory storage - progress will not persist'); + return memoryStorage; + } + + /** + * Send error to tracking service (placeholder) + * @private + * @param {Object} errorEntry - Error entry to send + */ + sendToErrorTracking(errorEntry) { + // Placeholder for error tracking integration + // Could integrate with Sentry, LogRocket, etc. + // Example: + // if (window.Sentry) { + // Sentry.captureException(new Error(errorEntry.message), { + // extra: errorEntry.metadata + // }); + // } + } + } + + window.ErrorHandler = ErrorHandler; + console.log('[ErrorHandler] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/favicon.ico b/dashcaddy-api/assets/favicon.ico new file mode 100644 index 0000000..77e6e9c Binary files /dev/null and b/dashcaddy-api/assets/favicon.ico differ diff --git a/dashcaddy-api/assets/favicon.png b/dashcaddy-api/assets/favicon.png new file mode 100644 index 0000000..e385d2a Binary files /dev/null and b/dashcaddy-api/assets/favicon.png differ diff --git a/dashcaddy-api/assets/favicon.svg b/dashcaddy-api/assets/favicon.svg new file mode 100644 index 0000000..adcf3b4 --- /dev/null +++ b/dashcaddy-api/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/filebrowser.png b/dashcaddy-api/assets/filebrowser.png new file mode 100644 index 0000000..0899c43 Binary files /dev/null and b/dashcaddy-api/assets/filebrowser.png differ diff --git a/dashcaddy-api/assets/fonts.css b/dashcaddy-api/assets/fonts.css new file mode 100644 index 0000000..f5e3463 --- /dev/null +++ b/dashcaddy-api/assets/fonts.css @@ -0,0 +1,91 @@ +/* Sami Sans Font Family - External CSS */ + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Regular.woff2') format('woff2'), + url('fonts/SamiSans-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Regular.woff2') format('woff2'), + url('fonts/SamiSans-Italic.ttf') format('truetype'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Medium.woff2') format('woff2'), + url('fonts/SamiSans-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'), + url('fonts/SamiSans-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Bold.woff2') format('woff2'), + url('fonts/SamiSans-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'), + url('fonts/SamiSans-ExtraBold.ttf') format('truetype'); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Black.woff2') format('woff2'), + url('fonts/SamiSans-Black.ttf') format('truetype'); + font-weight: 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Light.woff2') format('woff2'), + url('fonts/SamiSans-Light.ttf') format('truetype'); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'), + url('fonts/SamiSans-ExtraLight.ttf') format('truetype'); + font-weight: 200; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Sami Sans'; + src: url('fonts/SamiSans-Thin.woff2') format('woff2'), + url('fonts/SamiSans-Thin.ttf') format('truetype'); + font-weight: 100; + font-style: normal; + font-display: swap; +} diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Black.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Black.ttf new file mode 100644 index 0000000..ea4b04f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Black.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-BlackItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-BlackItalic.ttf new file mode 100644 index 0000000..005a92a Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-BlackItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Bold.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Bold.ttf new file mode 100644 index 0000000..7ee1c38 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Bold.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-BoldItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-BoldItalic.ttf new file mode 100644 index 0000000..6f2f6ba Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-BoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Light.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Light.ttf new file mode 100644 index 0000000..269b97d Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Light.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-LightItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-LightItalic.ttf new file mode 100644 index 0000000..d10a4c6 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-LightItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Medium.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Medium.ttf new file mode 100644 index 0000000..0dca4b4 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Medium.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-MediumItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-MediumItalic.ttf new file mode 100644 index 0000000..4ab921f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-MediumItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-Regular.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-Regular.ttf new file mode 100644 index 0000000..2d3b18d Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-Regular.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiGrotesk-RegularItalic.ttf b/dashcaddy-api/assets/fonts/SamiGrotesk-RegularItalic.ttf new file mode 100644 index 0000000..6c15c7e Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiGrotesk-RegularItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Black.ttf b/dashcaddy-api/assets/fonts/SamiSans-Black.ttf new file mode 100644 index 0000000..c44894b Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Black.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Black.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Black.woff2 new file mode 100644 index 0000000..6929b36 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Black.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.ttf new file mode 100644 index 0000000..eccde7d Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.woff2 new file mode 100644 index 0000000..3048ee4 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-BlackItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Bold.ttf b/dashcaddy-api/assets/fonts/SamiSans-Bold.ttf new file mode 100644 index 0000000..6bc519f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Bold.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Bold.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Bold.woff2 new file mode 100644 index 0000000..185b3a3 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Bold.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.ttf new file mode 100644 index 0000000..a47ea80 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.woff2 new file mode 100644 index 0000000..62f36be Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-BoldItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.ttf b/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.ttf new file mode 100644 index 0000000..8cdb5a9 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.woff2 new file mode 100644 index 0000000..7ce126b Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraBold.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.ttf new file mode 100644 index 0000000..9e0cc55 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.woff2 new file mode 100644 index 0000000..521db52 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraBoldItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.ttf b/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.ttf new file mode 100644 index 0000000..b39c51e Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.woff2 new file mode 100644 index 0000000..a0890de Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraLight.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.ttf new file mode 100644 index 0000000..5222446 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.woff2 new file mode 100644 index 0000000..f23459a Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ExtraLightItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Italic.ttf b/dashcaddy-api/assets/fonts/SamiSans-Italic.ttf new file mode 100644 index 0000000..97fcbe3 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Italic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Italic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Italic.woff2 new file mode 100644 index 0000000..d7e72d1 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Italic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Light.ttf b/dashcaddy-api/assets/fonts/SamiSans-Light.ttf new file mode 100644 index 0000000..446e73c Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Light.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Light.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Light.woff2 new file mode 100644 index 0000000..9c4227f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Light.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-LightItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-LightItalic.ttf new file mode 100644 index 0000000..ab054fc Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-LightItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-LightItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-LightItalic.woff2 new file mode 100644 index 0000000..6ea5bc7 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-LightItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Medium.ttf b/dashcaddy-api/assets/fonts/SamiSans-Medium.ttf new file mode 100644 index 0000000..d440388 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Medium.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Medium.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Medium.woff2 new file mode 100644 index 0000000..bc2048c Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Medium.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.ttf new file mode 100644 index 0000000..d61074b Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.woff2 new file mode 100644 index 0000000..05f6488 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-MediumItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Regular.ttf b/dashcaddy-api/assets/fonts/SamiSans-Regular.ttf new file mode 100644 index 0000000..09cdb41 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Regular.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Regular.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Regular.woff2 new file mode 100644 index 0000000..55b58bd Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Regular.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-SemiBold.ttf b/dashcaddy-api/assets/fonts/SamiSans-SemiBold.ttf new file mode 100644 index 0000000..956a313 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-SemiBold.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-SemiBold.woff2 b/dashcaddy-api/assets/fonts/SamiSans-SemiBold.woff2 new file mode 100644 index 0000000..e14f439 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-SemiBold.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.ttf new file mode 100644 index 0000000..494082a Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.woff2 new file mode 100644 index 0000000..406aa7d Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-SemiBoldItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Thin.ttf b/dashcaddy-api/assets/fonts/SamiSans-Thin.ttf new file mode 100644 index 0000000..1e50ae1 Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Thin.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-Thin.woff2 b/dashcaddy-api/assets/fonts/SamiSans-Thin.woff2 new file mode 100644 index 0000000..281a96a Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-Thin.woff2 differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.ttf b/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.ttf new file mode 100644 index 0000000..8eb93ba Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.woff2 b/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.woff2 new file mode 100644 index 0000000..03a9b3f Binary files /dev/null and b/dashcaddy-api/assets/fonts/SamiSans-ThinItalic.woff2 differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf new file mode 100644 index 0000000..ea4b04f Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf new file mode 100644 index 0000000..005a92a Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BlackItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf new file mode 100644 index 0000000..7ee1c38 Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf new file mode 100644 index 0000000..6f2f6ba Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf new file mode 100644 index 0000000..269b97d Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf new file mode 100644 index 0000000..d10a4c6 Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-LightItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf new file mode 100644 index 0000000..0dca4b4 Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf new file mode 100644 index 0000000..4ab921f Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf new file mode 100644 index 0000000..2d3b18d Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf differ diff --git a/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf new file mode 100644 index 0000000..6c15c7e Binary files /dev/null and b/dashcaddy-api/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf differ diff --git a/dashcaddy-api/assets/icon-192.png b/dashcaddy-api/assets/icon-192.png new file mode 100644 index 0000000..07a6369 Binary files /dev/null and b/dashcaddy-api/assets/icon-192.png differ diff --git a/dashcaddy-api/assets/icon-512.png b/dashcaddy-api/assets/icon-512.png new file mode 100644 index 0000000..af4acae Binary files /dev/null and b/dashcaddy-api/assets/icon-512.png differ diff --git a/dashcaddy-api/assets/jellyfin.png b/dashcaddy-api/assets/jellyfin.png new file mode 100644 index 0000000..c8c07ef Binary files /dev/null and b/dashcaddy-api/assets/jellyfin.png differ diff --git a/dashcaddy-api/assets/nginx.png b/dashcaddy-api/assets/nginx.png new file mode 100644 index 0000000..07a6369 Binary files /dev/null and b/dashcaddy-api/assets/nginx.png differ diff --git a/dashcaddy-api/assets/onboarding.css b/dashcaddy-api/assets/onboarding.css new file mode 100644 index 0000000..f8dc06b --- /dev/null +++ b/dashcaddy-api/assets/onboarding.css @@ -0,0 +1,354 @@ +/** + * Onboarding Tooltip Styles + * Custom styling for Driver.js tooltips to match DashCaddy theme + */ + +/* Driver.js overrides are injected dynamically by ThemeAdapter */ +/* This file contains additional custom styles */ + +.driver-popover { + max-width: 500px !important; + z-index: 10000 !important; +} + +.driver-popover-title { + font-size: 1.2rem !important; + margin-bottom: 12px !important; +} + +.driver-popover-description { + font-size: 0.95rem !important; + line-height: 1.6 !important; +} + +.driver-popover-description p { + margin: 8px 0 !important; +} + +.driver-popover-description ul { + margin: 8px 0 !important; + padding-left: 20px !important; +} + +.driver-popover-description li { + margin: 4px 0 !important; +} + +.driver-popover-description code { + background: rgba(0, 0, 0, 0.1) !important; + padding: 2px 6px !important; + border-radius: 3px !important; + font-family: 'Courier New', monospace !important; + font-size: 0.9em !important; +} + +.driver-popover-footer { + margin-top: 16px !important; + display: flex !important; + gap: 8px !important; + justify-content: flex-end !important; +} + +.driver-popover-footer button { + padding: 8px 16px !important; + border-radius: 8px !important; + font-size: 0.9rem !important; + cursor: pointer !important; + transition: all 0.2s ease !important; +} + +.driver-popover-footer button:hover { + transform: translateY(-1px) !important; +} + +.driver-popover-close-btn { + position: absolute !important; + top: 12px !important; + right: 12px !important; + width: 24px !important; + height: 24px !important; + border-radius: 50% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer !important; + opacity: 0.6 !important; + transition: opacity 0.2s ease !important; +} + +.driver-popover-close-btn:hover { + opacity: 1 !important; +} + +.driver-popover-arrow { + border-width: 8px !important; +} + +/* Progress indicator */ +.driver-popover-progress-text { + font-size: 0.85rem !important; + margin-bottom: 8px !important; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .driver-popover { + max-width: calc(100vw - 32px) !important; + } + + .driver-popover-title { + font-size: 1.1rem !important; + } + + .driver-popover-description { + font-size: 0.9rem !important; + } + + .driver-popover-footer button { + padding: 6px 12px !important; + font-size: 0.85rem !important; + } +} + +/* Restart tour button in dashboard */ +#restart-tour-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +#restart-tour-btn::before { + content: "🎓"; + font-size: 1.1em; +} + + +/* DNS Template Selector Modal */ +.dns-template-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 10000; + align-items: center; + justify-content: center; + padding: 20px; +} + +.dns-template-modal-content { + background: var(--card-base); + border-radius: 12px; + max-width: 900px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.dns-template-header { + padding: 30px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.dns-template-header h2 { + margin: 0 0 10px 0; + color: var(--fg); + font-size: 28px; +} + +.dns-template-header p { + margin: 0; + color: var(--fg-muted); + font-size: 14px; +} + +.dns-template-close { + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + font-size: 32px; + color: var(--fg-muted); + cursor: pointer; + padding: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; +} + +.dns-template-close:hover { + background: var(--hover); + color: var(--fg); +} + +.dns-template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + padding: 30px; +} + +.dns-template-card { + background: var(--card-hover); + border: 2px solid var(--border); + border-radius: 12px; + padding: 20px; + transition: all 0.3s; + position: relative; + display: flex; + flex-direction: column; +} + +.dns-template-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + border-color: var(--accent); +} + +.dns-template-card.recommended { + border-color: var(--accent); + background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%); +} + +.recommended-badge { + position: absolute; + top: -10px; + right: 20px; + background: var(--accent); + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.dns-template-icon { + font-size: 48px; + margin-bottom: 15px; + text-align: center; +} + +.dns-template-card h3 { + margin: 0 0 10px 0; + color: var(--fg); + font-size: 18px; + text-align: center; +} + +.dns-template-description { + color: var(--fg-muted); + font-size: 13px; + margin: 0 0 15px 0; + text-align: center; + flex-grow: 1; +} + +.dns-template-difficulty { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + text-align: center; + margin: 0 auto 15px auto; +} + +.difficulty-easy { + background: #2ecc71; + color: white; +} + +.difficulty-intermediate { + background: #f39c12; + color: white; +} + +.difficulty-advanced { + background: #e74c3c; + color: white; +} + +.dns-template-features { + list-style: none; + padding: 0; + margin: 0 0 20px 0; + font-size: 12px; + color: var(--fg-muted); +} + +.dns-template-features li { + padding: 6px 0; + padding-left: 20px; + position: relative; +} + +.dns-template-features li:before { + content: "✓"; + position: absolute; + left: 0; + color: var(--accent); + font-weight: bold; +} + +.dns-template-select-btn { + background: var(--accent); + color: white; + border: none; + padding: 12px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + width: 100%; +} + +.dns-template-select-btn:hover { + background: var(--accent-strong); + transform: scale(1.02); +} + +.dns-template-footer { + padding: 20px 30px; + border-top: 1px solid var(--border); + text-align: center; +} + +.dns-template-later-btn { + background: transparent; + color: var(--fg-muted); + border: 1px solid var(--border); + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.dns-template-later-btn:hover { + background: var(--hover); + color: var(--fg); + border-color: var(--fg-muted); +} + +/* Responsive design */ +@media (max-width: 768px) { + .dns-template-grid { + grid-template-columns: 1fr; + } + + .dns-template-modal-content { + max-height: 95vh; + } +} diff --git a/dashcaddy-api/assets/onboarding.js b/dashcaddy-api/assets/onboarding.js new file mode 100644 index 0000000..98949ac --- /dev/null +++ b/dashcaddy-api/assets/onboarding.js @@ -0,0 +1,177 @@ +/** + * DashCaddy User Onboarding System + * Main entry point for the tooltip-based onboarding experience + * + * This file initializes the onboarding system and coordinates between + * the various components (TourManager, ProgressTracker, ThemeAdapter, etc.) + */ + +(function() { + 'use strict'; + + let progressTracker; + let themeAdapter; + let tourManager; + let dnsTemplateSelector; + let errorHandler; + + /** + * Initialize the onboarding system + */ + async function initializeOnboarding() { + try { + console.log('[Onboarding] Initializing system...'); + + // Initialize Error Handler first + errorHandler = new ErrorHandler(); + console.log('[Onboarding] Error Handler initialized'); + + // Initialize Progress Tracker + progressTracker = new ProgressTracker('dashcaddy_onboarding'); + console.log('[Onboarding] Progress Tracker initialized'); + + // Initialize Theme Adapter + themeAdapter = new ThemeAdapter(); + console.log('[Onboarding] Theme Adapter initialized'); + + // Initialize DNS Template Selector + dnsTemplateSelector = new DnsTemplateSelector(progressTracker); + console.log('[Onboarding] DNS Template Selector initialized'); + + // Initialize Tour Manager + tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); + console.log('[Onboarding] Tour Manager initialized'); + + // Check if tour should auto-start + if (tourManager.shouldAutoStart()) { + console.log('[Onboarding] Auto-starting tour for first-time user'); + // Wait a bit for page to fully load + setTimeout(() => { + tourManager.startTour(); + }, 1000); + } else { + const tourCompleted = progressTracker.isTourCompleted(); + const currentStep = progressTracker.getCurrentStep(); + console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); + + // If tour is in progress, offer to resume + if (!tourCompleted && currentStep > 0) { + console.log('[Onboarding] Tour in progress, can be resumed manually'); + } + } + + // Add restart tour button to tools row + addRestartTourButton(); + + // Expose to global scope for manual triggering + window.DashCaddyOnboarding = { + startTour: () => tourManager.startTour(), + restartTour: () => tourManager.restartTour(), + showTooltip: (id) => tourManager.showTooltip(id), + showWhatsNew: () => tourManager.showWhatsNew(), + resetProgress: () => progressTracker.resetProgress(), + getErrors: () => errorHandler.getErrors(), + getErrorStats: () => errorHandler.getStatistics() + }; + + console.log('[Onboarding] System initialized successfully'); + } catch (error) { + console.error('[Onboarding] Initialization error:', error); + + // Use error handler if available + if (errorHandler) { + errorHandler.logError('Initialization', error); + } + + // Graceful degradation - don't break the dashboard + console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding'); + } + } + + /** + * Add restart tour button to tools row + */ + function addRestartTourButton() { + const toolsRow = document.querySelector('.tools'); + if (!toolsRow) return; + + const clickHandler = () => { + if (tourManager) { + console.log('[Onboarding] Starting tour via button click'); + tourManager.restartTour(); + } else { + console.error('[Onboarding] Tour manager not initialized'); + alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization'); + } + }; + + // If button already exists in the HTML, just attach the handler + const existing = document.getElementById('restart-tour-btn'); + if (existing) { + existing.onclick = clickHandler; + return; + } + + const button = document.createElement('button'); + button.id = 'restart-tour-btn'; + button.textContent = 'Help Tour'; + button.title = 'Restart the onboarding tour'; + button.onclick = clickHandler; + toolsRow.appendChild(button); + } + + /** + * Check if Driver.js is loaded + */ + function checkDriverLoaded() { + // Driver.js v1.x IIFE: window.driver.js.driver is the factory function + const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; + if (typeof driverFactory !== 'function') { + console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver); + return false; + } + return true; + } + + /** + * Wait for Driver.js to load, then initialize + */ + function waitForDriver() { + let retries = 0; + const maxRetries = 10; + + function attemptInit() { + if (checkDriverLoaded()) { + initializeOnboarding(); + } else { + retries++; + if (retries < maxRetries) { + // Retry after a short delay + setTimeout(attemptInit, 500); + } else { + // Max retries reached, show fallback + console.error('[Onboarding] Driver.js failed to load after multiple attempts'); + if (errorHandler) { + errorHandler.handleDriverLoadFailure(); + } else { + // Create temporary error handler for fallback + const tempHandler = new ErrorHandler(); + tempHandler.handleDriverLoadFailure(); + } + } + } + } + + attemptInit(); + } + + // Start initialization when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', waitForDriver); + } else { + waitForDriver(); + } + + console.log('[Onboarding] System loaded'); + +})(); diff --git a/dashcaddy-api/assets/pics.png b/dashcaddy-api/assets/pics.png new file mode 100644 index 0000000..951ecfe Binary files /dev/null and b/dashcaddy-api/assets/pics.png differ diff --git a/dashcaddy-api/assets/plex.png b/dashcaddy-api/assets/plex.png new file mode 100644 index 0000000..f30efe3 Binary files /dev/null and b/dashcaddy-api/assets/plex.png differ diff --git a/dashcaddy-api/assets/portainer.png b/dashcaddy-api/assets/portainer.png new file mode 100644 index 0000000..c0b0436 Binary files /dev/null and b/dashcaddy-api/assets/portainer.png differ diff --git a/dashcaddy-api/assets/progress-tracker.js b/dashcaddy-api/assets/progress-tracker.js new file mode 100644 index 0000000..cbfe3db --- /dev/null +++ b/dashcaddy-api/assets/progress-tracker.js @@ -0,0 +1,282 @@ +/** + * Progress Tracker + * Manages persistent storage of user progress through the onboarding flow + * using browser local storage. + * + * Storage Schema: + * { + * "version": "1.0", + * "tourCompleted": false, + * "completedTooltips": ["welcome", "dns-priority", ...], + * "currentStep": 3, + * "completionTimestamp": "2024-01-15T10:30:00Z", + * "dnsSetupDeferred": false, + * "lastVisit": "2024-01-15T10:30:00Z" + * } + */ + +(function(window) { + 'use strict'; + + /** + * ProgressTracker class + * Manages persistent storage of onboarding progress + * + * @class + * @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding') + */ + class ProgressTracker { + constructor(storageKey = 'dashcaddy_onboarding') { + this.storageKey = storageKey; + this.storageVersion = '1.0'; + + // Initialize storage if it doesn't exist + this._initializeStorage(); + + // Update last visit timestamp + this._updateLastVisit(); + } + + /** + * Initialize storage with default values if it doesn't exist + * @private + */ + _initializeStorage() { + const existing = this._getStorage(); + if (!existing || existing.version !== this.storageVersion) { + const defaultState = { + version: this.storageVersion, + tourCompleted: false, + completedTooltips: [], + currentStep: 0, + completionTimestamp: null, + dnsSetupDeferred: false, + lastVisit: new Date().toISOString() + }; + this._setStorage(defaultState); + } + } + + /** + * Get the current storage state + * @private + * @returns {Object|null} The storage state or null if unavailable + */ + _getStorage() { + try { + const data = localStorage.getItem(this.storageKey); + return data ? JSON.parse(data) : null; + } catch (error) { + console.error('[ProgressTracker] Error reading from storage:', error); + return null; + } + } + + /** + * Set the storage state + * @private + * @param {Object} state - The state to save + */ + _setStorage(state) { + try { + localStorage.setItem(this.storageKey, JSON.stringify(state)); + } catch (error) { + console.error('[ProgressTracker] Error writing to storage:', error); + // Handle quota exceeded or storage unavailable + // Fall back to session storage or in-memory storage + this._handleStorageError(error); + } + } + + /** + * Handle storage errors (quota exceeded, unavailable, etc.) + * @private + * @param {Error} error - The error that occurred + */ + _handleStorageError(error) { + // Try session storage as fallback + try { + sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage())); + console.warn('[ProgressTracker] Falling back to session storage'); + } catch (sessionError) { + console.error('[ProgressTracker] Session storage also unavailable:', sessionError); + // Could implement in-memory fallback here if needed + } + } + + /** + * Update the last visit timestamp + * @private + */ + _updateLastVisit() { + const state = this._getStorage(); + if (state) { + state.lastVisit = new Date().toISOString(); + this._setStorage(state); + } + } + + /** + * Check if a specific tooltip has been completed + * @param {string} tooltipId - The ID of the tooltip to check + * @returns {boolean} True if the tooltip has been completed + */ + isTooltipCompleted(tooltipId) { + const state = this._getStorage(); + if (!state) return false; + return state.completedTooltips.includes(tooltipId); + } + + /** + * Mark a tooltip as completed with timestamp + * @param {string} tooltipId - The ID of the tooltip to mark as completed + */ + markTooltipCompleted(tooltipId) { + const state = this._getStorage(); + if (!state) return; + + // Add tooltip to completed list if not already there + if (!state.completedTooltips.includes(tooltipId)) { + state.completedTooltips.push(tooltipId); + + // Store timestamp for this specific tooltip + if (!state.tooltipTimestamps) { + state.tooltipTimestamps = {}; + } + state.tooltipTimestamps[tooltipId] = new Date().toISOString(); + + this._setStorage(state); + } + } + + /** + * Check if the entire tour has been completed + * @returns {boolean} True if the tour is completed + */ + isTourCompleted() { + const state = this._getStorage(); + if (!state) return false; + return state.tourCompleted === true; + } + + /** + * Mark the entire tour as completed + */ + markTourCompleted() { + const state = this._getStorage(); + if (!state) return; + + state.tourCompleted = true; + state.completionTimestamp = new Date().toISOString(); + this._setStorage(state); + } + + /** + * Get the current step index + * @returns {number} The current step index (0-based) + */ + getCurrentStep() { + const state = this._getStorage(); + if (!state) return 0; + return state.currentStep || 0; + } + + /** + * Set the current step index + * @param {number} stepIndex - The step index to set (0-based) + */ + setCurrentStep(stepIndex) { + const state = this._getStorage(); + if (!state) return; + + state.currentStep = stepIndex; + this._setStorage(state); + } + + /** + * Reset all progress and clear storage + */ + resetProgress() { + const defaultState = { + version: this.storageVersion, + tourCompleted: false, + completedTooltips: [], + currentStep: 0, + completionTimestamp: null, + dnsSetupDeferred: false, + lastVisit: new Date().toISOString() + }; + this._setStorage(defaultState); + } + + /** + * Get the completion timestamp + * @returns {Date|null} The completion timestamp or null if not completed + */ + getCompletionTimestamp() { + const state = this._getStorage(); + if (!state || !state.completionTimestamp) return null; + return new Date(state.completionTimestamp); + } + + /** + * Check if DNS setup was deferred + * @returns {boolean} True if DNS setup was deferred + */ + isDnsSetupDeferred() { + const state = this._getStorage(); + if (!state) return false; + return state.dnsSetupDeferred === true; + } + + /** + * Mark DNS setup as deferred + */ + markDnsSetupDeferred() { + const state = this._getStorage(); + if (!state) return; + + state.dnsSetupDeferred = true; + this._setStorage(state); + } + + /** + * Get the timestamp for a specific tooltip completion + * @param {string} tooltipId - The ID of the tooltip + * @returns {Date|null} The timestamp or null if not completed + */ + getTooltipTimestamp(tooltipId) { + const state = this._getStorage(); + if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) { + return null; + } + return new Date(state.tooltipTimestamps[tooltipId]); + } + + /** + * Get all completed tooltip IDs + * @returns {string[]} Array of completed tooltip IDs + */ + getCompletedTooltips() { + const state = this._getStorage(); + if (!state) return []; + return state.completedTooltips || []; + } + + /** + * Get the last visit timestamp + * @returns {Date|null} The last visit timestamp + */ + getLastVisit() { + const state = this._getStorage(); + if (!state || !state.lastVisit) return null; + return new Date(state.lastVisit); + } + } + + // Export to global scope + window.ProgressTracker = ProgressTracker; + + console.log('[ProgressTracker] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/prowlarr.png b/dashcaddy-api/assets/prowlarr.png new file mode 100644 index 0000000..964f18b Binary files /dev/null and b/dashcaddy-api/assets/prowlarr.png differ diff --git a/dashcaddy-api/assets/qBittorrent.png b/dashcaddy-api/assets/qBittorrent.png new file mode 100644 index 0000000..97bdb6c Binary files /dev/null and b/dashcaddy-api/assets/qBittorrent.png differ diff --git a/dashcaddy-api/assets/radarr.png b/dashcaddy-api/assets/radarr.png new file mode 100644 index 0000000..49943a5 Binary files /dev/null and b/dashcaddy-api/assets/radarr.png differ diff --git a/dashcaddy-api/assets/router.png b/dashcaddy-api/assets/router.png new file mode 100644 index 0000000..241b9aa Binary files /dev/null and b/dashcaddy-api/assets/router.png differ diff --git a/dashcaddy-api/assets/sami-favicon.png b/dashcaddy-api/assets/sami-favicon.png new file mode 100644 index 0000000..1b8d421 Binary files /dev/null and b/dashcaddy-api/assets/sami-favicon.png differ diff --git a/dashcaddy-api/assets/sami-logo.png b/dashcaddy-api/assets/sami-logo.png new file mode 100644 index 0000000..2c6685e Binary files /dev/null and b/dashcaddy-api/assets/sami-logo.png differ diff --git a/dashcaddy-api/assets/site.webmanifest b/dashcaddy-api/assets/site.webmanifest new file mode 100644 index 0000000..e83acb0 --- /dev/null +++ b/dashcaddy-api/assets/site.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "SAMI-CLOUD Status", + "short_name": "SAMI-CLOUD", + "start_url": "index.html", + "display": "standalone", + "background_color": "#0b0f1a", + "theme_color": "#0e1116", + "icons": [ + { + "src": "assets/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "assets/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/dashcaddy-api/assets/sonarr.png b/dashcaddy-api/assets/sonarr.png new file mode 100644 index 0000000..c904551 Binary files /dev/null and b/dashcaddy-api/assets/sonarr.png differ diff --git a/dashcaddy-api/assets/syncthing.png b/dashcaddy-api/assets/syncthing.png new file mode 100644 index 0000000..ee5e1ec Binary files /dev/null and b/dashcaddy-api/assets/syncthing.png differ diff --git a/dashcaddy-api/assets/test-upload4.png b/dashcaddy-api/assets/test-upload4.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/dashcaddy-api/assets/test-upload4.png differ diff --git a/dashcaddy-api/assets/theme-adapter.js b/dashcaddy-api/assets/theme-adapter.js new file mode 100644 index 0000000..f58248f --- /dev/null +++ b/dashcaddy-api/assets/theme-adapter.js @@ -0,0 +1,308 @@ +/** + * Theme Adapter + * Ensures tooltips match the current dashboard theme + * Integrates with Driver.js to apply theme-specific styling + */ + +(function(window) { + 'use strict'; + + /** + * Theme configuration mapping for Driver.js + * Maps dashboard themes to Driver.js styling + */ + const THEME_CONFIGS = { + dark: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(0, 0, 0, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + light: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent-strong)', + overlayColor: 'rgba(0, 0, 0, 0.5)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent-strong)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + blue: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(25, 8, 172, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + nord: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(46, 52, 64, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + dracula: { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(40, 42, 54, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + 'solarized-dark': { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(0, 43, 54, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + }, + 'solarized-light': { + backgroundColor: 'var(--card-base)', + textColor: 'var(--fg)', + primaryColor: 'var(--accent)', + overlayColor: 'rgba(253, 246, 227, 0.7)', + borderColor: 'var(--border)', + highlightColor: 'var(--accent)', + fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" + } + }; + + /** + * ThemeAdapter class + * Manages theme integration for the tooltip system + */ + class ThemeAdapter { + constructor() { + this.currentTheme = this.getCurrentTheme(); + this.themeChangeCallbacks = []; + this._setupThemeChangeListener(); + } + + /** + * Get the current theme name from document root class + * @returns {string} Current theme name (e.g., 'dark', 'light', 'blue') + */ + getCurrentTheme() { + const root = document.documentElement; + const classList = Array.from(root.classList); + + // Check for theme classes + const themeClasses = ['light', 'blue', 'nord', 'dracula', 'solarized-dark', 'solarized-light']; + const foundTheme = themeClasses.find(theme => classList.includes(theme)); + + // Default to 'dark' if no theme class found + return foundTheme || 'dark'; + } + + /** + * Get Driver.js theme configuration for current theme + * @returns {Object} Theme configuration object + */ + getDriverTheme() { + const themeName = this.getCurrentTheme(); + const config = THEME_CONFIGS[themeName] || THEME_CONFIGS.dark; + + // Resolve CSS variables to actual values + const resolvedConfig = {}; + for (const [key, value] of Object.entries(config)) { + if (typeof value === 'string' && value.startsWith('var(')) { + // Extract CSS variable name + const varName = value.match(/var\((--[^)]+)\)/)?.[1]; + if (varName) { + const computedValue = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + resolvedConfig[key] = computedValue || value; + } else { + resolvedConfig[key] = value; + } + } else { + resolvedConfig[key] = value; + } + } + + return resolvedConfig; + } + + /** + * Register a callback for theme changes + * @param {Function} callback - Function to call when theme changes + */ + onThemeChange(callback) { + if (typeof callback === 'function') { + this.themeChangeCallbacks.push(callback); + } + } + + /** + * Setup theme change listener using MutationObserver + * @private + */ + _setupThemeChangeListener() { + const root = document.documentElement; + + // Create observer to watch for class changes on root element + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + const newTheme = this.getCurrentTheme(); + if (newTheme !== this.currentTheme) { + const oldTheme = this.currentTheme; + this.currentTheme = newTheme; + this._notifyThemeChange(newTheme, oldTheme); + } + } + }); + }); + + // Start observing + observer.observe(root, { + attributes: true, + attributeFilter: ['class'] + }); + + console.log('[ThemeAdapter] Theme change listener initialized'); + } + + /** + * Notify all registered callbacks of theme change + * @private + * @param {string} newTheme - New theme name + * @param {string} oldTheme - Old theme name + */ + _notifyThemeChange(newTheme, oldTheme) { + console.log(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`); + + this.themeChangeCallbacks.forEach(callback => { + try { + callback(newTheme, oldTheme); + } catch (error) { + console.error('[ThemeAdapter] Error in theme change callback:', error); + } + }); + } + + /** + * Apply theme to Driver.js instance + * @param {Object} driver - Driver.js instance + */ + applyTheme(driver) { + if (!driver) { + console.warn('[ThemeAdapter] No driver instance provided'); + return; + } + + const themeConfig = this.getDriverTheme(); + + // Apply theme configuration to driver + // Note: Driver.js v1.0+ uses CSS variables, so we inject a style element + this._injectDriverStyles(themeConfig); + + console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme); + } + + /** + * Inject custom styles for Driver.js based on theme + * @private + * @param {Object} themeConfig - Theme configuration + */ + _injectDriverStyles(themeConfig) { + // Remove existing theme styles + const existingStyle = document.getElementById('driver-theme-styles'); + if (existingStyle) { + existingStyle.remove(); + } + + // Create new style element + const style = document.createElement('style'); + style.id = 'driver-theme-styles'; + style.textContent = ` + .driver-popover { + background: ${themeConfig.backgroundColor} !important; + color: ${themeConfig.textColor} !important; + border: 1px solid ${themeConfig.borderColor} !important; + border-radius: 12px !important; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-title { + color: ${themeConfig.textColor} !important; + font-weight: 600 !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-description { + color: ${themeConfig.textColor} !important; + font-family: ${themeConfig.fontFamily} !important; + } + + .driver-popover-footer button { + background: ${themeConfig.primaryColor} !important; + color: ${themeConfig.backgroundColor} !important; + border: none !important; + font-family: ${themeConfig.fontFamily} !important; + font-weight: 500 !important; + } + + .driver-popover-footer button:hover { + opacity: 0.9 !important; + } + + .driver-popover-close-btn { + color: ${themeConfig.textColor} !important; + } + + .driver-overlay { + background: ${themeConfig.overlayColor} !important; + } + + .driver-highlighted-element { + outline: 2px solid ${themeConfig.highlightColor} !important; + outline-offset: 4px !important; + } + + .driver-popover-progress-text { + color: ${themeConfig.textColor} !important; + opacity: 0.7 !important; + font-family: ${themeConfig.fontFamily} !important; + } + `; + + document.head.appendChild(style); + } + + /** + * Get all available theme names + * @returns {string[]} Array of theme names + */ + getAvailableThemes() { + return Object.keys(THEME_CONFIGS); + } + + /** + * Check if a theme is available + * @param {string} themeName - Theme name to check + * @returns {boolean} True if theme is available + */ + isThemeAvailable(themeName) { + return THEME_CONFIGS.hasOwnProperty(themeName); + } + } + + // Export to global scope + window.ThemeAdapter = ThemeAdapter; + + console.log('[ThemeAdapter] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/tooltip-definitions.js b/dashcaddy-api/assets/tooltip-definitions.js new file mode 100644 index 0000000..8ef36d2 --- /dev/null +++ b/dashcaddy-api/assets/tooltip-definitions.js @@ -0,0 +1,337 @@ +/** + * Tooltip Definitions + * Defines all tooltip content, positioning, and behavior for the onboarding system + */ + +(function(window) { + 'use strict'; + + /** + * Validate a tooltip definition + * @param {Object} tooltip - The tooltip definition to validate + * @returns {Object} { valid: boolean, errors: string[] } + */ + function validateTooltipDefinition(tooltip) { + const errors = []; + + // Required fields + if (!tooltip.id || typeof tooltip.id !== 'string') { + errors.push('Tooltip must have a valid string id'); + } + + if (!tooltip.element) { + errors.push('Tooltip must have an element selector or HTMLElement'); + } + + if (!tooltip.popover || typeof tooltip.popover !== 'object') { + errors.push('Tooltip must have a popover object'); + } else { + // Validate popover fields + if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') { + errors.push('Tooltip popover must have a valid string title'); + } + + if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') { + errors.push('Tooltip popover must have a valid string description'); + } + + // Validate position if provided + if (tooltip.popover.position) { + const validPositions = ['top', 'bottom', 'left', 'right', 'center']; + if (!validPositions.includes(tooltip.popover.position)) { + errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`); + } + } + + // Validate align if provided + if (tooltip.popover.align) { + const validAligns = ['start', 'center', 'end']; + if (!validAligns.includes(tooltip.popover.align)) { + errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`); + } + } + + // Validate showButtons if provided + if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) { + errors.push('showButtons must be an array'); + } + + // Validate callbacks if provided + const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater']; + callbacks.forEach(callback => { + if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') { + errors.push(`${callback} must be a function`); + } + }); + } + + // Validate condition if provided + if (tooltip.condition && typeof tooltip.condition !== 'function') { + errors.push('condition must be a function'); + } + + // Validate priority if provided + if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') { + errors.push('priority must be a number'); + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Validate an array of tooltip definitions + * @param {Array} tooltips - Array of tooltip definitions + * @returns {Object} { valid: boolean, errors: Object[] } + */ + function validateTooltipDefinitions(tooltips) { + if (!Array.isArray(tooltips)) { + return { + valid: false, + errors: [{ tooltip: null, errors: ['tooltips must be an array'] }] + }; + } + + const allErrors = []; + const ids = new Set(); + + tooltips.forEach((tooltip, index) => { + const validation = validateTooltipDefinition(tooltip); + + if (!validation.valid) { + allErrors.push({ + tooltip: tooltip.id || `index ${index}`, + errors: validation.errors + }); + } + + // Check for duplicate IDs + if (tooltip.id) { + if (ids.has(tooltip.id)) { + allErrors.push({ + tooltip: tooltip.id, + errors: [`Duplicate tooltip ID: ${tooltip.id}`] + }); + } + ids.add(tooltip.id); + } + }); + + return { + valid: allErrors.length === 0, + errors: allErrors + }; + } + + /** + * Error handler for tooltip system + */ + class TooltipError extends Error { + constructor(message, tooltipId = null) { + super(message); + this.name = 'TooltipError'; + this.tooltipId = tooltipId; + } + } + + /** + * Handle tooltip definition errors + * @param {Object} validation - Validation result + * @throws {TooltipError} If validation fails + */ + function handleValidationErrors(validation) { + if (!validation.valid) { + const errorMessages = validation.errors.map(e => + `${e.tooltip}: ${e.errors.join(', ')}` + ).join('\n'); + + console.error('[TooltipDefinitions] Validation errors:', errorMessages); + throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`); + } + } + + // Export to global scope + window.TooltipValidation = { + validateTooltipDefinition, + validateTooltipDefinitions, + handleValidationErrors, + TooltipError + }; + + console.log('[TooltipDefinitions] Validation module loaded'); + +})(window); + + +/** + * Tooltip Definitions Array + * Defines all tooltips for the onboarding tour + */ +const TOOLTIP_DEFINITIONS = [ + // 1. Welcome tooltip pointing to logo + { + id: 'welcome', + element: '#brand', + popover: { + title: 'Welcome to DashCaddy!', + description: ` +

Your personal dashboard for managing services with Caddy reverse proxy.

+

Let's take a quick tour to help you get started.

+

Tip: You can customize this logo in Settings.

+ `, + position: 'bottom', + align: 'start', + showButtons: ['next'], + showProgress: true + }, + priority: 1, + isNewFeature: false + }, + + // 2. Add Service button + { + id: 'add-service', + element: '#add-service-btn', + popover: { + title: 'Adding New Services', + description: ` +

Click + Add Service to deploy new apps or add existing services to your dashboard.

+

Choose from 50+ templates including:

+ + `, + position: 'bottom', + showButtons: ['previous', 'next'], + showProgress: true + }, + priority: 2, + isNewFeature: false, + condition: () => { + return document.getElementById('add-service-btn') !== null; + } + }, + + // 3. App Grid explanation + { + id: 'app-grid', + element: '#cards', + popover: { + title: 'Your Services', + description: ` +

This is your service grid where all your deployed applications appear.

+

Each card shows:

+ + `, + position: 'top', + showButtons: ['previous', 'next'], + showProgress: true + }, + priority: 3, + isNewFeature: false + }, + + // 4. Theme selector + { + id: 'theme-selector', + element: '#theme', + popover: { + title: 'Customize Your Theme', + description: ` +

DashCaddy comes with 7 themes. Click here to switch between them.

+

Your preference is saved automatically.

+ `, + position: 'bottom', + showButtons: ['previous', 'close'], + showProgress: true + }, + priority: 4, + isNewFeature: false, + condition: () => { + return document.getElementById('theme') !== null; + } + } +]; + +/** + * Get tooltip definitions + * @returns {Array} Array of tooltip definitions + */ +function getTooltipDefinitions() { + return TOOLTIP_DEFINITIONS; +} + +/** + * Get a specific tooltip by ID + * @param {string} id - Tooltip ID + * @returns {Object|null} Tooltip definition or null if not found + */ +function getTooltipById(id) { + return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null; +} + +/** + * Get tooltips filtered by condition + * @returns {Array} Array of tooltips that pass their condition check + */ +function getActiveTooltips() { + return TOOLTIP_DEFINITIONS.filter(tooltip => { + if (tooltip.condition && typeof tooltip.condition === 'function') { + try { + return tooltip.condition(); + } catch (error) { + console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error); + return false; + } + } + return true; + }); +} + +/** + * Get tooltips sorted by priority + * @returns {Array} Array of tooltips sorted by priority (ascending) + */ +function getSortedTooltips() { + const tooltips = getActiveTooltips(); + return tooltips.sort((a, b) => { + const priorityA = a.priority || 999; + const priorityB = b.priority || 999; + return priorityA - priorityB; + }); +} + +/** + * Get tooltips marked as new features + * @returns {Array} Array of tooltips marked with isNewFeature flag + */ +function getNewFeatureTooltips() { + const tooltips = getActiveTooltips(); + return tooltips.filter(tooltip => tooltip.isNewFeature === true) + .sort((a, b) => { + const priorityA = a.priority || 999; + const priorityB = b.priority || 999; + return priorityA - priorityB; + }); +} + +// Export to global scope +window.TooltipDefinitions = { + TOOLTIP_DEFINITIONS, + getTooltipDefinitions, + getTooltipById, + getActiveTooltips, + getSortedTooltips, + getNewFeatureTooltips +}; + +console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); + diff --git a/dashcaddy-api/assets/tour-manager.js b/dashcaddy-api/assets/tour-manager.js new file mode 100644 index 0000000..d972a7c --- /dev/null +++ b/dashcaddy-api/assets/tour-manager.js @@ -0,0 +1,363 @@ +/** + * Tour Manager + * Orchestrates the onboarding tour using Driver.js + */ + +(function(window) { + 'use strict'; + + class TourManager { + constructor(progressTracker, themeAdapter, dnsTemplateSelector) { + this.progressTracker = progressTracker; + this.themeAdapter = themeAdapter; + this.dnsTemplateSelector = dnsTemplateSelector; + this.driver = null; + this.currentStepIndex = 0; + this.isActive = false; + this.resizeHandler = null; + this.layoutChangeHandler = null; + } + + /** + * Initialize Driver.js with theme-aware configuration + */ + async initializeDriver() { + // Driver.js v1.x IIFE: window.driver.js.driver is the factory function + const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; + + if (typeof driverFactory !== 'function') { + console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver); + return false; + } + + const themeConfig = this.themeAdapter.getDriverTheme(); + + this.driver = driverFactory({ + showProgress: true, + showButtons: ['next', 'previous', 'close'], + allowClose: true, + overlayClickNext: false, + overlayOpacity: 0, + stagePadding: 0, + stageRadius: 0, + allowKeyboardControl: true, + popoverClass: 'dashcaddy-popover', + onDestroyed: () => this.onTourComplete(), + onDestroyStarted: () => { + if (!this.progressTracker.isTourCompleted()) { + this.onTourSkip(); + } + } + }); + + // Apply theme + this.themeAdapter.applyTheme(this.driver); + + // Listen for theme changes + this.themeAdapter.onThemeChange(() => { + this.themeAdapter.applyTheme(this.driver); + }); + + // Set up dynamic repositioning + this.setupDynamicRepositioning(); + + return true; + } + + /** + * Check if tour should auto-start + */ + shouldAutoStart() { + return !this.progressTracker.isTourCompleted() && + this.progressTracker.getCurrentStep() === 0; + } + + /** + * Start the onboarding tour + */ + async startTour() { + if (!this.driver) { + const initialized = await this.initializeDriver(); + if (!initialized) return; + } + + // Get active tooltips (filtered by conditions) + const allTooltips = window.TooltipDefinitions.getSortedTooltips(); + + // Filter out completed tooltips + const completedIds = this.progressTracker.getCompletedTooltips(); + const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id)); + + if (activeTooltips.length === 0) { + console.log('[TourManager] No tooltips to show'); + this.progressTracker.markTourCompleted(); + return; + } + + // Convert to Driver.js steps with navigation logic + const steps = activeTooltips.map((tooltip, index) => { + const isFirst = index === 0; + const isLast = index === activeTooltips.length - 1; + + const step = { + element: tooltip.element, + popover: { + title: tooltip.popover.title, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start', + showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), + showProgress: tooltip.popover.showProgress !== false, + onNextClick: () => { + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }, + onPrevClick: () => { + this.progressTracker.setCurrentStep(Math.max(0, index - 1)); + this.currentStepIndex = Math.max(0, index - 1); + this.driver.movePrevious(); + }, + onCloseClick: () => { + this.skipTour(); + } + } + }; + + // Add custom handlers for DNS tooltip + if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) { + step.popover.onSetupNowClick = () => { + console.log('[TourManager] Opening DNS template selector'); + this.dnsTemplateSelector.showTemplateSelector(); + // Mark tooltip as completed and move to next + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }; + + step.popover.onLaterClick = () => { + console.log('[TourManager] DNS setup deferred'); + this.progressTracker.markDnsSetupDeferred(); + // Mark tooltip as completed and move to next + this.progressTracker.markTooltipCompleted(tooltip.id); + this.progressTracker.setCurrentStep(index + 1); + this.currentStepIndex = index + 1; + this.driver.moveNext(); + }; + } + + return step; + }); + + this.isActive = true; + this.driver.setSteps(steps); + this.driver.drive(); + } + + /** + * Resume tour from last step + */ + async resumeTour() { + const currentStep = this.progressTracker.getCurrentStep(); + if (currentStep > 0) { + await this.startTour(); + // Driver.js will start from beginning, we'd need to skip to current step + // This is a simplified implementation + } else { + await this.startTour(); + } + } + + /** + * Skip the entire tour + */ + skipTour() { + if (this.driver) { + this.driver.destroy(); + } + this.cleanupDynamicRepositioning(); + this.isActive = false; + } + + /** + * Restart tour from beginning + */ + async restartTour() { + this.progressTracker.resetProgress(); + await this.startTour(); + } + + /** + * Show a specific tooltip by ID + */ + async showTooltip(tooltipId) { + const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId); + if (!tooltip) { + console.error(`[TourManager] Tooltip not found: ${tooltipId}`); + return; + } + + if (!this.driver) { + await this.initializeDriver(); + } + + const step = { + element: tooltip.element, + popover: { + title: tooltip.popover.title, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start' + } + }; + + this.driver.highlight(step); + } + + /** + * Show "What's New" tour - only tooltips marked as new features + */ + async showWhatsNew() { + if (!this.driver) { + const initialized = await this.initializeDriver(); + if (!initialized) return; + } + + // Get only new feature tooltips + const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips(); + + if (newFeatureTooltips.length === 0) { + console.log('[TourManager] No new features to show'); + return; + } + + console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`); + + // Convert to Driver.js steps + const steps = newFeatureTooltips.map((tooltip, index) => { + const isFirst = index === 0; + const isLast = index === newFeatureTooltips.length - 1; + + return { + element: tooltip.element, + popover: { + title: `✨ NEW: ${tooltip.popover.title}`, + description: tooltip.popover.description, + side: tooltip.popover.position || 'bottom', + align: tooltip.popover.align || 'start', + showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), + showProgress: true, + onNextClick: () => { + this.driver.moveNext(); + }, + onPrevClick: () => { + this.driver.movePrevious(); + }, + onCloseClick: () => { + this.skipTour(); + } + } + }; + }); + + this.isActive = true; + this.driver.setSteps(steps); + this.driver.drive(); + } + + /** + * Set up dynamic repositioning for window resize and layout changes + */ + setupDynamicRepositioning() { + // Window resize handler with debouncing + let resizeTimeout; + this.resizeHandler = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + if (this.isActive && this.driver) { + console.log('[TourManager] Window resized, repositioning tooltip'); + this.driver.refresh(); + } + }, 150); // Debounce for 150ms + }; + + // Layout change handler (for theme changes, DOM mutations) + this.layoutChangeHandler = () => { + if (this.isActive && this.driver) { + console.log('[TourManager] Layout changed, repositioning tooltip'); + // Small delay to allow layout to settle + setTimeout(() => { + if (this.driver) { + this.driver.refresh(); + } + }, 100); + } + }; + + // Add event listeners + window.addEventListener('resize', this.resizeHandler); + + // Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition) + this.themeAdapter.onThemeChange(this.layoutChangeHandler); + } + + /** + * Clean up dynamic repositioning listeners + */ + cleanupDynamicRepositioning() { + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + } + } + + /** + * Get buttons to show for a specific step + * @private + */ + _getButtonsForStep(tooltip, isFirst, isLast) { + // Check if tooltip has custom buttons defined + if (tooltip.popover.showButtons) { + return tooltip.popover.showButtons; + } + + // Default button configuration + const buttons = []; + + if (!isFirst) { + buttons.push('previous'); + } + + if (!isLast) { + buttons.push('next'); + } else { + buttons.push('close'); + } + + return buttons; + } + + /** + * Handle tour completion + */ + onTourComplete() { + this.progressTracker.markTourCompleted(); + this.isActive = false; + console.log('[TourManager] Tour completed'); + } + + /** + * Handle tour skip + */ + onTourSkip() { + // Save current progress but don't mark as completed + console.log('[TourManager] Tour skipped'); + this.isActive = false; + } + } + + window.TourManager = TourManager; + console.log('[TourManager] Module loaded'); + +})(window); diff --git a/dashcaddy-api/assets/transmission.png b/dashcaddy-api/assets/transmission.png new file mode 100644 index 0000000..57bedae Binary files /dev/null and b/dashcaddy-api/assets/transmission.png differ diff --git a/dashcaddy-api/assets/weather/clear-day.svg b/dashcaddy-api/assets/weather/clear-day.svg new file mode 100644 index 0000000..d0d36ca --- /dev/null +++ b/dashcaddy-api/assets/weather/clear-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/clear-night.svg b/dashcaddy-api/assets/weather/clear-night.svg new file mode 100644 index 0000000..bd3f1cb --- /dev/null +++ b/dashcaddy-api/assets/weather/clear-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/cloudy.svg b/dashcaddy-api/assets/weather/cloudy.svg new file mode 100644 index 0000000..b868d87 --- /dev/null +++ b/dashcaddy-api/assets/weather/cloudy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/drizzle.svg b/dashcaddy-api/assets/weather/drizzle.svg new file mode 100644 index 0000000..27513c8 --- /dev/null +++ b/dashcaddy-api/assets/weather/drizzle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/fog.svg b/dashcaddy-api/assets/weather/fog.svg new file mode 100644 index 0000000..12208db --- /dev/null +++ b/dashcaddy-api/assets/weather/fog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/partly-cloudy-day.svg b/dashcaddy-api/assets/weather/partly-cloudy-day.svg new file mode 100644 index 0000000..6fcec43 --- /dev/null +++ b/dashcaddy-api/assets/weather/partly-cloudy-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/partly-cloudy-night.svg b/dashcaddy-api/assets/weather/partly-cloudy-night.svg new file mode 100644 index 0000000..2c49905 --- /dev/null +++ b/dashcaddy-api/assets/weather/partly-cloudy-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/rain.svg b/dashcaddy-api/assets/weather/rain.svg new file mode 100644 index 0000000..74b33d3 --- /dev/null +++ b/dashcaddy-api/assets/weather/rain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/sleet.svg b/dashcaddy-api/assets/weather/sleet.svg new file mode 100644 index 0000000..03d6a3a --- /dev/null +++ b/dashcaddy-api/assets/weather/sleet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/snow.svg b/dashcaddy-api/assets/weather/snow.svg new file mode 100644 index 0000000..e444068 --- /dev/null +++ b/dashcaddy-api/assets/weather/snow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/thunderstorm.svg b/dashcaddy-api/assets/weather/thunderstorm.svg new file mode 100644 index 0000000..390f11b --- /dev/null +++ b/dashcaddy-api/assets/weather/thunderstorm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/assets/weather/wind.svg b/dashcaddy-api/assets/weather/wind.svg new file mode 100644 index 0000000..55d168c --- /dev/null +++ b/dashcaddy-api/assets/weather/wind.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashcaddy-api/auth-manager.js b/dashcaddy-api/auth-manager.js index ed4d9ec..bb0a9bc 100644 --- a/dashcaddy-api/auth-manager.js +++ b/dashcaddy-api/auth-manager.js @@ -8,10 +8,8 @@ const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const credentialManager = require('./credential-manager'); const cryptoUtils = require('./crypto-utils'); -const { safeLog } = require('./logger-utils'); // JWT signing secret - derived from encryption key for consistency -// SECURITY: Loaded from secure storage, never logged const JWT_SECRET = cryptoUtils.loadOrCreateKey(); // Namespace for API keys in credential manager @@ -46,7 +44,6 @@ class AuthManager { { expiresIn } ); - // SECURITY: Log event only, never log the actual token console.log(`[AuthManager] Generated JWT for user: ${payload.sub}, expires in: ${expiresIn}`); return token; } catch (error) { @@ -73,8 +70,7 @@ class AuthManager { if (error.name === 'TokenExpiredError') { console.log('[AuthManager] JWT token expired'); } else if (error.name === 'JsonWebTokenError') { - // SECURITY: Never log the actual token - console.log('[AuthManager] JWT token invalid'); + console.log('[AuthManager] JWT token invalid:', error.message); } else { console.error('[AuthManager] JWT verification failed:', error.message); } @@ -120,7 +116,6 @@ class AuthManager { // Cache metadata this.keyMetadataCache.set(keyId, metadata); - // SECURITY: Log event only, never log the actual API key console.log(`[AuthManager] Generated API key: ${name} (${keyId})`); return { diff --git a/dashcaddy-api/ca/CERTIFICATE-API.md b/dashcaddy-api/ca/CERTIFICATE-API.md new file mode 100644 index 0000000..fb5452a --- /dev/null +++ b/dashcaddy-api/ca/CERTIFICATE-API.md @@ -0,0 +1,306 @@ +# DashCA Certificate Generation API + +## Overview + +DashCA now provides automatic SSL certificate generation for services on your network. This allows services like Technitium DNS to automatically obtain valid SSL certificates signed by your internal CA. + +## Features + +- **Automatic Certificate Generation**: Generate SSL certificates for any domain on demand +- **Multiple Formats**: PFX, PEM, CRT, KEY, and full chain certificates +- **Certificate Caching**: Certificates are cached and reused if still valid (>30 days remaining) +- **No Authentication Required**: Public API endpoints for easy automation +- **Automatic Renewal**: Certificates are regenerated when they expire in less than 30 days + +## API Endpoints + +### 1. Generate/Download Certificate + +**GET** `/api/ca/cert/:domain` + +Generate and download an SSL certificate for the specified domain. + +**Parameters:** +- `:domain` (required) - The domain name (e.g., `dns1.sami`, `dns2.sami`) +- `format` (optional) - Certificate format: `pfx`, `pem`, `crt`, `key`, or `fullchain` (default: `pfx`) +- `password` (optional) - Password for PFX files (default: `dashcaddy`) + +**Examples:** + +```bash +# Download PFX certificate for dns1.sami +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=pfx&password=dashcaddy" + +# Download PEM bundle (private key + certificate + chain) +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=pem" + +# Download certificate only +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=crt" + +# Download private key only +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=key" + +# Download full certificate chain (cert + intermediate + root) +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=fullchain" +``` + +**PowerShell:** + +```powershell +Invoke-WebRequest -Uri "https://ca.sami/api/ca/cert/dns1.sami?format=pfx" -OutFile "dns1.pfx" +``` + +### 2. List Generated Certificates + +**GET** `/api/ca/certs` + +List all generated certificates with their status and expiration information. + +**Example:** + +```bash +curl https://ca.sami/api/ca/certs +``` + +**Response:** + +```json +{ + "success": true, + "certificates": [ + { + "domain": "dns1.sami", + "subject": "CN=dns1.sami", + "validFrom": "Feb 11 21:37:02 2026 GMT", + "validUntil": "Feb 11 21:37:02 2027 GMT", + "daysUntilExpiration": 364, + "fingerprint": "4E:74:F8:49:...", + "status": "valid" + } + ] +} +``` + +**Status values:** +- `valid` - Certificate is valid and has >30 days remaining +- `expiring-soon` - Certificate expires in less than 30 days +- `expired` - Certificate has expired + +## Certificate Details + +- **Validity**: 365 days (1 year) +- **Algorithm**: RSA 2048-bit (for broad compatibility) +- **Signature**: SHA-256 +- **Key Usage**: Key Encipherment, Data Encipherment, Digital Signature +- **Extended Key Usage**: Server Authentication +- **Subject Alternative Names**: Primary domain + wildcard subdomain (if applicable) + +Example: A certificate for `dns1.sami` includes both `dns1.sami` and `*.dns1.sami`. + +## Automation Scripts + +### PowerShell - Update Technitium DNS Certificate + +Located at: `C:\caddy\scripts\update-technitium-cert.ps1` + +```powershell +# Update certificate for a single DNS server +.\update-technitium-cert.ps1 ` + -Domain "dns1.sami" ` + -TechnitiumUrl "http://192.168.254.204:5380" ` + -CertPath "C:\ProgramData\Technitium\DnsServer\cert.pfx" + +# Update all DNS servers at once +.\update-all-dns-certs.ps1 +``` + +### Schedule Automatic Updates + +Create a Windows scheduled task to update certificates monthly: + +```powershell +# Create scheduled task for monthly certificate updates +schtasks /create ` + /tn "Update DNS Certificates" ` + /tr "powershell -ExecutionPolicy Bypass -File C:\caddy\scripts\update-all-dns-certs.ps1" ` + /sc monthly ` + /d 1 ` + /st 03:00 ` + /ru SYSTEM +``` + +## Manual Certificate Installation + +### Technitium DNS + +1. Download PFX certificate: + ```bash + curl -k -O "https://ca.sami/api/ca/cert/dns1.sami?format=pfx&password=dashcaddy" + ``` + +2. Copy to Technitium directory: + ``` + Copy dns1.sami.pfx to C:\ProgramData\Technitium\DnsServer\cert.pfx + ``` + +3. Restart Technitium DNS Server service: + ```powershell + Restart-Service "Technitium DNS Server" + ``` + +4. Access Technitium DNS web interface via HTTPS: + ``` + https://dns1.sami:5380 + ``` + +### Other Services + +For other services requiring SSL certificates, use the appropriate format: + +- **PFX**: Windows services, .NET applications, IIS +- **PEM**: Most Linux services (Apache, Nginx, HAProxy) +- **CRT + KEY**: Separate certificate and key files (Nginx, Apache) +- **Fullchain**: Full certificate chain for maximum compatibility + +## Security Considerations + +1. **Root CA Protection**: The root CA private key is mounted read-only in the API container +2. **Serial Number Tracking**: Each certificate gets a unique serial number stored per domain +3. **Password Protection**: PFX files are password-protected (default: `dashcaddy`) +4. **Network Access**: Certificate API is available on your Tailscale network only +5. **No Revocation**: Certificates cannot be revoked; wait for expiration or delete manually + +## Troubleshooting + +### Certificate Generation Fails + +Check if the PKI directory is accessible: +```bash +docker exec dashcaddy-api ls -la /app/pki/ +``` + +Should show: `root.crt`, `root.key`, `intermediate.crt`, `intermediate.key` + +### Certificate Not Trusted by Browser + +Install the root CA certificate on your device: +```bash +curl -O https://ca.sami/root.crt +``` + +Follow platform-specific installation instructions on the DashCA homepage. + +### Certificate Expired + +Request a new certificate - it will automatically regenerate: +```bash +curl -O "https://ca.sami/api/ca/cert/dns1.sami?format=pfx" +``` + +### Generated Certificates Location + +On the host system: +``` +C:\caddy\generated-certs\ + ├── dns1.sami\ + │ ├── server.key + │ ├── server.csr + │ ├── server.crt + │ ├── server.pfx + │ ├── server.pem + │ └── fullchain.pem + ├── dns2.sami\ + └── dns3.sami\ +``` + +## Integration Examples + +### curl + +```bash +# Simple download +curl -O https://ca.sami/api/ca/cert/myservice.sami?format=pfx + +# With custom password +curl -O "https://ca.sami/api/ca/cert/myservice.sami?format=pfx&password=mypassword" +``` + +### PowerShell + +```powershell +# Download and install +$certPath = "C:\certs\myservice.pfx" +Invoke-WebRequest -Uri "https://ca.sami/api/ca/cert/myservice.sami?format=pfx" -OutFile $certPath + +# Verify certificate +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certPath, "dashcaddy") +Write-Host "Certificate valid until: $($cert.NotAfter)" +``` + +### Bash/Linux + +```bash +#!/bin/bash +# Download and extract PEM files +curl -k "https://ca.sami/api/ca/cert/myservice.sami?format=pem" -o myservice.pem + +# Extract key and cert +openssl pkey -in myservice.pem -out myservice.key +openssl x509 -in myservice.pem -out myservice.crt +``` + +### Docker Compose + +```yaml +services: + myservice: + image: myimage + volumes: + - ./certs:/certs + environment: + SSL_CERT_FILE: /certs/myservice.crt + SSL_KEY_FILE: /certs/myservice.key + command: | + sh -c " + curl -k https://ca.sami/api/ca/cert/myservice.sami?format=crt -o /certs/myservice.crt && + curl -k https://ca.sami/api/ca/cert/myservice.sami?format=key -o /certs/myservice.key && + exec myservice + " +``` + +## DNS Server Configuration + +### DNS1 (Primary) +- **IP**: 192.168.254.204 +- **Domain**: dns1.sami +- **Certificate**: https://ca.sami/api/ca/cert/dns1.sami?format=pfx + +### DNS2 (Secondary) +- **IP**: 100.74.102.61 (Tailscale) +- **Domain**: dns2.sami +- **Certificate**: https://ca.sami/api/ca/cert/dns2.sami?format=pfx + +### DNS3 (Tertiary) +- **IP**: 100.89.216.23 (Tailscale) +- **Domain**: dns3.sami +- **Certificate**: https://ca.sami/api/ca/cert/dns3.sami?format=pfx + +## Future Enhancements + +Potential features for future versions: + +- Certificate revocation lists (CRL) +- OCSP responder +- Certificate renewal webhooks +- Email notifications for expiring certificates +- Web UI for certificate management +- Custom certificate validity periods +- Certificate templates for different service types +- Automatic DNS record validation + +## Support + +For issues or questions, check: +- DashCA homepage: https://ca.sami +- Certificate list: https://ca.sami/api/ca/certs +- Server logs: `docker logs dashcaddy-api` diff --git a/dashcaddy-api/ca/README.md b/dashcaddy-api/ca/README.md new file mode 100644 index 0000000..aa09881 --- /dev/null +++ b/dashcaddy-api/ca/README.md @@ -0,0 +1,281 @@ +# DashCA - Certificate Authority Distribution + +A self-hosted landing page for distributing your root CA certificate with one-click installation across all major platforms. + +## Quick Start + +### Regenerate All Certificate Formats + +```bash +cd scripts +bash generate-all.sh +``` + +This will: +1. Copy root.crt and intermediate.crt from Caddy PKI +2. Generate root.der (DER format for Windows) +3. Generate root.mobileconfig (Apple profile for iOS/macOS) +4. Extract certificate metadata to cert-info.json + +### Deploy to Production + +```bash +# Copy all files to production directory +cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/ +``` + +Or deploy via the dashboard app selector (preferred method). + +## File Structure + +``` +ca/ +├── index.html # Landing page with OS detection +├── root.crt # Root CA certificate (PEM format) +├── root.der # Root CA certificate (DER format) +├── root.mobileconfig # Apple configuration profile +├── intermediate.crt # Intermediate CA certificate +├── cert-info.json # Certificate metadata (auto-generated) +├── scripts/ +│ ├── install.ps1 # Windows PowerShell installer +│ ├── install.sh # Linux/macOS shell installer +│ ├── generate-cert-info.js # Extract certificate metadata +│ ├── generate-mobileconfig.js # Generate Apple profile +│ └── generate-all.sh # Wrapper script to regenerate all +└── assets/ + └── (icons, logos, etc.) +``` + +## Certificate Information + +**Source:** Caddy's built-in PKI at `C:/caddy/certs/pki/authorities/local/` + +- **Name:** Sami Home Network Root CA +- **Algorithm:** ECDSA P-256 with SHA-256 +- **Valid Until:** Dec 22, 2034 +- **Fingerprint:** `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E` + +## Installation Scripts + +### Windows (install.ps1) + +Features: +- Requires Administrator privileges +- Downloads certificate from ca.sami +- Verifies SHA-256 fingerprint +- Installs to LocalMachine\Root store +- Checks for existing installation + +**One-liner:** +```powershell +irm https://ca.sami/install.ps1 | iex +``` + +### Linux/macOS (install.sh) + +Features: +- Requires sudo/root +- Auto-detects OS (Debian, RedHat, Arch, macOS) +- Platform-specific installation commands +- Fingerprint verification with OpenSSL +- Checks for existing installation + +**One-liner:** +```bash +curl -fsSL https://ca.sami/install.sh | sudo bash +``` + +### Apple Devices (root.mobileconfig) + +Features: +- Works on both iOS and macOS +- XML configuration profile format +- Contains base64-encoded certificate +- Unique UUIDs per generation +- User must manually trust after installation (iOS) + +**Installation:** +1. Download root.mobileconfig +2. iOS: Settings prompts automatically +3. macOS: System Settings → Profiles → Install +4. iOS: Enable trust in Certificate Trust Settings + +## Landing Page Features + +The landing page (`index.html`) includes: + +- **OS Detection:** Automatically detects Windows, macOS, Linux, iOS, Android +- **Certificate Info Display:** Shows name, fingerprint, expiration, algorithm +- **QR Code:** For easy mobile access (powered by qrcodejs library) +- **Download Links:** All certificate formats and installation scripts +- **Platform Tabs:** Detailed instructions for each operating system +- **Copy-to-Clipboard:** For fingerprint and command-line scripts +- **DashCaddy Theme:** Dark mode with Sami Grotesk font + +**API Integration:** +- Loads certificate info from `/api/ca/info` endpoint +- Falls back to static info if API unavailable + +## Development Workflow + +1. **Edit Files:** Make changes in `e:/CaddyCerts/sites/ca/` +2. **Test Locally:** Open `index.html` in browser (file:// protocol works) +3. **Regenerate Certificates:** Run `scripts/generate-all.sh` if CA renewed +4. **Deploy:** Copy to production or use dashboard deployment +5. **Verify:** Visit https://ca.sami and test on target platforms + +## Updating After CA Renewal + +When Caddy regenerates its CA certificate (every ~10 years): + +### 1. Regenerate Certificate Formats + +```bash +cd e:/CaddyCerts/sites/ca/scripts +bash generate-all.sh +``` + +### 2. Update Fingerprints in Scripts + +The new fingerprint will be in `cert-info.json`. Update these files: + +**install.ps1** (line 17): +```powershell +$ExpectedFingerprint = "NEW:FIN:GER:PRINT:HERE" +``` + +**install.sh** (line 13): +```bash +EXPECTED_FP="NEW:FIN:GER:PRINT:HERE" +``` + +### 3. Deploy to Production + +```bash +cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/ +``` + +### 4. Notify Users + +- Add banner to dashboard +- Send notification via configured channels +- Update documentation with new expiration date + +## API Endpoints + +DashCA integrates with DashCaddy API: + +### GET /api/ca/info + +Returns certificate metadata: + +```json +{ + "success": true, + "certificate": { + "name": "Sami Home Network Root CA", + "fingerprint": "08:98:A5:...", + "validFrom": "Feb 12 07:44:51 2025 GMT", + "validUntil": "Dec 22 07:44:51 2034 GMT", + "daysUntilExpiration": 3235, + "algorithm": "ECDSA P-256 with SHA-256", + "serialNumber": "c1:dc:48:...", + "downloadUrl": "https://ca.sami/root.crt" + } +} +``` + +### GET /api/health/ca + +Returns CA expiration health status: + +```json +{ + "status": "healthy", + "message": "CA certificate valid for 3235 days", + "daysUntilExpiration": 3235, + "expiresAt": "Dec 22 07:44:51 2034 GMT" +} +``` + +**Status values:** +- `healthy`: >90 days remaining +- `warning`: 30-90 days +- `critical`: <30 days or expired +- `error`: Certificate not found or error reading + +## Troubleshooting + +### Certificate Not Found Error + +**Symptom:** Scripts fail with "certificate not found" +**Cause:** Caddy hasn't generated the local CA yet +**Solution:** Visit any *.sami domain to trigger CA generation + +### Fingerprint Mismatch + +**Symptom:** Install scripts reject certificate with fingerprint mismatch +**Cause:** CA was renewed but scripts not updated +**Solution:** Run `generate-all.sh` and update fingerprints in install scripts + +### iOS Profile Won't Install + +**Symptom:** .mobileconfig shows error when installing +**Cause:** Invalid XML or missing UUIDs +**Solution:** Regenerate with `node generate-mobileconfig.js` + +### Android Shows "Not Trusted" + +**Symptom:** Certificate installs but sites still show warnings +**Cause:** Android installs as "user" certificate; some apps don't trust user CAs +**Solution:** This is by design. System CA installation requires root access. + +### Landing Page Shows "Loading..." + +**Symptom:** Certificate info stuck on loading state +**Cause:** API endpoint not accessible +**Solution:** Check that dashcaddy-api server is running and `/api/ca/info` responds + +## Testing Checklist + +Before deploying to production: + +- [ ] All certificate formats generated successfully +- [ ] Landing page loads correctly in browser +- [ ] OS detection works (test multiple user agents) +- [ ] QR code renders and scans correctly +- [ ] Download links work for all file types +- [ ] API endpoint returns valid certificate info +- [ ] Copy-to-clipboard buttons work +- [ ] Platform instruction tabs function correctly +- [ ] Responsive design works on mobile viewport +- [ ] HTTPS access works after deployment + +## Security Notes + +- **Private Key:** NEVER serve the CA private key (`root.key`). Only public certificates are safe to distribute. +- **Fingerprint Verification:** Install scripts verify fingerprint to prevent MITM attacks +- **Access Control:** ca.sami should only be accessible on your Tailnet/internal network +- **HTTPS Enforcement:** The page itself uses HTTPS (via Caddy's internal CA) to protect the distribution +- **No Auto-Execution:** All installation methods require explicit user action + +## Contributing + +When adding features to DashCA: + +1. Test on multiple platforms before committing +2. Update this README with new features +3. Add relevant sections to troubleshooting guide +4. Update CLAUDE.md if deployment process changes +5. Ensure backward compatibility with existing certificates + +## Resources + +- **Caddy PKI Documentation:** https://caddyserver.com/docs/caddyfile/directives/tls#pki +- **mobileconfig Format:** https://developer.apple.com/documentation/devicemanagement +- **OpenSSL Certificate Commands:** https://www.openssl.org/docs/man1.1.1/man1/x509.html +- **QR Code Library:** https://github.com/davidshimjs/qrcodejs + +--- + +**Part of the DashCaddy project** - Unified management for Docker + Caddy + DNS diff --git a/dashcaddy-api/ca/index.html b/dashcaddy-api/ca/index.html new file mode 100644 index 0000000..3d83d78 --- /dev/null +++ b/dashcaddy-api/ca/index.html @@ -0,0 +1,1243 @@ + + + + + + DashCA - Certificate Authority + + + + + + + + +
+ +
+ +

DashCA

+

Certificate Authority Distribution

+
+ + +
+

+ 📜 + Certificate Information +

+
+
+
Certificate Name
+
+ Loading... +
+
+
+
Algorithm
+
+ Loading... +
+
+
+
Valid Until
+
+ Loading... +
+
+
+
Days Remaining
+
+ Loading... +
+
+
+
SHA-256 Fingerprint
+
+ Loading... + +
+
+
+
+ + +
+ +
+ Detecting your operating system... +
+
+ + +
+

📱 Quick Access for Mobile

+
+

Scan with your phone's camera to visit this page

+
+ + +
+

+ 🔐 + Service Certificates +

+

+ SSL certificates generated for services on your network +

+
+ +
+
+ Loading... + Checking +
+
Fetching certificate status...
+
+
+
+ + + + + +
+

+ 📖 + Installation Instructions +

+ +
+ + + + + +
+ + +
+
+
1
+
+

Run PowerShell as Administrator

+

Right-click the Start button and select "Windows PowerShell (Admin)" or "Terminal (Admin)"

+
+
+
+
2
+
+

Run the installation command

+

Copy and paste this command into PowerShell:

+
+ + irm https://ca.sami/install.ps1 | iex +
+
+
+
+
3
+
+

Verify installation

+

Visit any *.sami domain (like https://status.sami) - you should see a secure connection with no warnings.

+
+
+
+ + +
+
+
1
+
+

Download the certificate profile

+

Click "Apple Profile" above to download root.mobileconfig

+
+
+
+
2
+
+

Install the profile

+

Open System Settings → Privacy & Security → Profiles. Click the downloaded profile and click "Install".

+
+
+
+
3
+
+

Alternative: Command line installation

+

Open Terminal and run:

+
+ + curl -fsSL https://ca.sami/install.sh | sudo bash +
+
+
+
+ + +
+
+
1
+
+

Open Terminal

+

Open your terminal application (usually Ctrl+Alt+T)

+
+
+
+
2
+
+

Run the installation command

+

Copy and paste this command (will prompt for sudo password):

+
+ + curl -fsSL https://ca.sami/install.sh | sudo bash +
+
+
+
+
3
+
+

Supported distributions

+

The installer supports Debian/Ubuntu, RedHat/CentOS/Fedora, and Arch Linux. It will automatically detect your distribution and use the appropriate commands.

+
+
+
+ + +
+
+
1
+
+

Scan QR code or visit this page

+

Use your iPhone's camera to scan the QR code above, or visit https://ca.sami directly in Safari

+
+
+
+
2
+
+

Download the profile

+

Tap "Apple Profile" to download root.mobileconfig. You'll see a notification that the profile was downloaded.

+
+
+
+
3
+
+

Install the profile

+

Go to Settings (you should see a notification), tap on the downloaded profile, and tap "Install". Enter your passcode if prompted.

+
+
+
+
4
+
+

Trust the certificate

+

Go to Settings → General → About → Certificate Trust Settings. Enable full trust for "Sami Home Network Root CA".

+
+
+
+ + +
+
+
1
+
+

Download the certificate

+

Tap "Root Certificate (.crt)" above to download the certificate to your device

+
+
+
+
2
+
+

Install the certificate

+

Open Settings → Security → Encryption & credentials → Install a certificate → CA certificate. Select the downloaded file.

+
+
+
+
3
+
+

Note about user certificates

+

Android installs this as a "user" certificate. Some apps may not trust user certificates for security reasons. For full trust, you would need to install it as a system certificate (requires root access).

+
+
+
+
+ + + +
+ + + + diff --git a/dashcaddy-api/ca/install.ps1 b/dashcaddy-api/ca/install.ps1 new file mode 100644 index 0000000..cc28a37 --- /dev/null +++ b/dashcaddy-api/ca/install.ps1 @@ -0,0 +1,132 @@ +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Installs the Sami Home Network Root CA certificate to the Trusted Root Certification Authorities store. + +.DESCRIPTION + This script downloads the root CA certificate from ca.sami, verifies its fingerprint, + and installs it to the local machine's trusted root store. This allows all *.sami domains + to be trusted system-wide without browser warnings. + +.NOTES + Requires Administrator privileges. + For use with DashCA - https://ca.sami +#> + +$ErrorActionPreference = "Stop" + +# Configuration +$CertUrl = "https://ca.sami/root.crt" +$ExpectedFingerprint = "0898A563F5A1A2585F02D7A8A25487E6BC33969F9B5DB053622 07FAF9621290E" +$TempFile = "$env:TEMP\sami-root-ca.crt" + +# Colors +$Red = [System.ConsoleColor]::Red +$Green = [System.ConsoleColor]::Green +$Cyan = [System.ConsoleColor]::Cyan +$Yellow = [System.ConsoleColor]::Yellow + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host " DashCA Installer" -ForegroundColor $Cyan +Write-Host " Sami Home Network Root CA" -ForegroundColor $Cyan +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host "" + +# Step 1: Download certificate +Write-Host "[1/4] Downloading certificate from $CertUrl..." -ForegroundColor $Cyan +try { + $ProgressPreference = 'SilentlyContinue' # Disable progress bar for faster download + Invoke-WebRequest -Uri $CertUrl -OutFile $TempFile -UseBasicParsing -ErrorAction Stop + Write-Host " ✓ Certificate downloaded" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to download certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Write-Host "" + Write-Host "Troubleshooting:" -ForegroundColor $Yellow + Write-Host " - Ensure you are on the Tailnet/network where ca.sami is accessible" -ForegroundColor $Yellow + Write-Host " - Try accessing https://ca.sami in your browser first" -ForegroundColor $Yellow + exit 1 +} + +# Step 2: Verify fingerprint +Write-Host "[2/4] Verifying certificate fingerprint..." -ForegroundColor $Cyan +try { + $Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($TempFile) + $Fingerprint = $Cert.Thumbprint + + $NormalizedExpected = $ExpectedFingerprint -replace '[:\s]', '' + $NormalizedActual = $Fingerprint -replace '[:\s]', '' + + if ($NormalizedActual -ne $NormalizedExpected) { + Write-Host " ✗ Fingerprint mismatch!" -ForegroundColor $Red + Write-Host " Expected: $ExpectedFingerprint" -ForegroundColor $Yellow + Write-Host " Got: $Fingerprint" -ForegroundColor $Red + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "SECURITY WARNING: The downloaded certificate does not match the expected fingerprint." -ForegroundColor $Red + Write-Host "This could indicate a man-in-the-middle attack or certificate renewal." -ForegroundColor $Red + Write-Host "Please verify with your network administrator before proceeding." -ForegroundColor $Red + exit 1 + } + + Write-Host " ✓ Fingerprint verified: $Fingerprint" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to verify fingerprint" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + exit 1 +} + +# Step 3: Check if already installed +Write-Host "[3/4] Checking for existing certificate..." -ForegroundColor $Cyan +$ExistingCert = Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Fingerprint } +if ($ExistingCert) { + Write-Host " ℹ Certificate already installed" -ForegroundColor $Yellow + Write-Host " Subject: $($ExistingCert.Subject)" -ForegroundColor $Yellow + Write-Host " Not After: $($ExistingCert.NotAfter)" -ForegroundColor $Yellow + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "The Sami Home Network Root CA is already trusted on this system." -ForegroundColor $Green + Write-Host "No further action needed!" -ForegroundColor $Green + Write-Host "" + exit 0 +} +Write-Host " ✓ Certificate not yet installed, proceeding..." -ForegroundColor $Green + +# Step 4: Install certificate +Write-Host "[4/4] Installing certificate to Trusted Root store..." -ForegroundColor $Cyan +try { + $ImportedCert = Import-Certificate -FilePath $TempFile -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction Stop + Write-Host " ✓ Certificate installed successfully" -ForegroundColor $Green + Write-Host " Subject: $($ImportedCert.Subject)" -ForegroundColor $Green + Write-Host " Thumbprint: $($ImportedCert.Thumbprint)" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to install certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + Write-Host "" + Write-Host "Installation failed. Please ensure you are running as Administrator." -ForegroundColor $Red + exit 1 +} + +# Cleanup +Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Green +Write-Host " SUCCESS!" -ForegroundColor $Green +Write-Host "========================================" -ForegroundColor $Green +Write-Host "" +Write-Host "The Sami Home Network Root CA has been installed to your Trusted Root store." -ForegroundColor $Green +Write-Host "" +Write-Host "What's next:" -ForegroundColor $Cyan +Write-Host " ✓ All *.sami domains will now be trusted system-wide" -ForegroundColor $Green +Write-Host " ✓ Browsers (Edge, Chrome, Firefox) will no longer show security warnings" -ForegroundColor $Green +Write-Host " ✓ Applications will trust HTTPS connections to your local services" -ForegroundColor $Green +Write-Host "" +Write-Host "Test it out:" -ForegroundColor $Cyan +Write-Host " Visit https://status.sami or any other *.sami service" -ForegroundColor $Yellow +Write-Host " The connection should show as secure with no warnings" -ForegroundColor $Yellow +Write-Host "" diff --git a/dashcaddy-api/ca/install.sh b/dashcaddy-api/ca/install.sh new file mode 100644 index 0000000..0fcb365 --- /dev/null +++ b/dashcaddy-api/ca/install.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# +# DashCA Installer - Sami Home Network Root CA +# Installs the root CA certificate system-wide on Linux and macOS +# +# Usage: curl -fsSL https://ca.sami/install.sh | sudo bash +# +set -e + +# Configuration +CERT_URL="https://ca.sami/root.crt" +EXPECTED_FP="08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:9F:9B:5D:B0:53:62:20:7F:AF:96:21:29:0E" +CERT_NAME="Sami_Home_Network_Root_CA" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "" +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} DashCA Installer${NC}" +echo -e "${CYAN} Sami Home Network Root CA${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check for root/sudo +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}✗ This script requires root privileges${NC}" + echo "" + echo "Please run with sudo:" + echo -e " ${YELLOW}curl -fsSL https://ca.sami/install.sh | sudo bash${NC}" + echo "" + echo "Or download first, then run:" + echo -e " ${YELLOW}curl -o install.sh https://ca.sami/install.sh${NC}" + echo -e " ${YELLOW}sudo bash install.sh${NC}" + echo "" + exit 1 +fi + +# Detect OS +echo -e "${CYAN}[1/6] Detecting operating system...${NC}" +if [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + OS_NAME="macOS" +elif [[ -f /etc/os-release ]]; then + . /etc/os-release + if [[ "$ID" == "debian" ]] || [[ "$ID" == "ubuntu" ]] || [[ "$ID_LIKE" == *"debian"* ]]; then + OS="debian" + OS_NAME="Debian/Ubuntu" + elif [[ "$ID" == "fedora" ]] || [[ "$ID" == "rhel" ]] || [[ "$ID" == "centos" ]] || [[ "$ID_LIKE" == *"fedora"* ]] || [[ "$ID_LIKE" == *"rhel"* ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS/Fedora" + elif [[ "$ID" == "arch" ]] || [[ "$ID_LIKE" == *"arch"* ]]; then + OS="arch" + OS_NAME="Arch Linux" + else + OS="unknown" + OS_NAME="Unknown Linux" + fi +elif [[ -f /etc/redhat-release ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS" +elif [[ -f /etc/arch-release ]]; then + OS="arch" + OS_NAME="Arch Linux" +else + OS="unknown" + OS_NAME="Unknown" +fi + +if [[ "$OS" == "unknown" ]]; then + echo -e "${RED} ✗ Unsupported operating system${NC}" + echo "" + echo "This script supports:" + echo " - Debian/Ubuntu" + echo " - RedHat/CentOS/Fedora" + echo " - Arch Linux" + echo " - macOS" + echo "" + echo "For manual installation, download the certificate:" + echo -e " ${YELLOW}curl -O $CERT_URL${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Detected: $OS_NAME${NC}" + +# Download certificate +echo -e "${CYAN}[2/6] Downloading certificate from $CERT_URL...${NC}" +TEMP_CERT=$(mktemp) +if ! curl -fsSL "$CERT_URL" -o "$TEMP_CERT"; then + echo -e "${RED} ✗ Failed to download certificate${NC}" + echo "" + echo -e "${YELLOW}Troubleshooting:${NC}" + echo " - Ensure you are on the Tailnet/network where ca.sami is accessible" + echo " - Try accessing https://ca.sami in your browser first" + echo " - Check your network connection" + rm -f "$TEMP_CERT" + exit 1 +fi +echo -e "${GREEN} ✓ Certificate downloaded${NC}" + +# Verify fingerprint +echo -e "${CYAN}[3/6] Verifying certificate fingerprint...${NC}" +if ! command -v openssl &> /dev/null; then + echo -e "${RED} ✗ OpenSSL not found${NC}" + echo "Please install OpenSSL to verify certificate fingerprint" + rm -f "$TEMP_CERT" + exit 1 +fi + +ACTUAL_FP=$(openssl x509 -in "$TEMP_CERT" -noout -fingerprint -sha256 | cut -d= -f2) + +if [[ "$ACTUAL_FP" != "$EXPECTED_FP" ]]; then + echo -e "${RED} ✗ Fingerprint mismatch!${NC}" + echo -e "${YELLOW} Expected: $EXPECTED_FP${NC}" + echo -e "${RED} Got: $ACTUAL_FP${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${RED}SECURITY WARNING: The downloaded certificate does not match the expected fingerprint.${NC}" + echo -e "${RED}This could indicate a man-in-the-middle attack or certificate renewal.${NC}" + echo -e "${RED}Please verify with your network administrator before proceeding.${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Fingerprint verified${NC}" + +# Extract certificate details +echo -e "${CYAN}[4/6] Extracting certificate information...${NC}" +CERT_SUBJECT=$(openssl x509 -in "$TEMP_CERT" -noout -subject | sed 's/subject=//') +CERT_NOT_AFTER=$(openssl x509 -in "$TEMP_CERT" -noout -enddate | sed 's/notAfter=//') +echo -e "${GREEN} ✓ Subject: $CERT_SUBJECT${NC}" +echo -e "${GREEN} ✓ Valid until: $CERT_NOT_AFTER${NC}" + +# Check if already installed +echo -e "${CYAN}[5/6] Checking for existing installation...${NC}" +ALREADY_INSTALLED=false + +case "$OS" in + debian) + if [[ -f "/usr/local/share/ca-certificates/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + redhat) + if [[ -f "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + arch) + if [[ -f "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + macos) + if security find-certificate -a -c "$CERT_SUBJECT" /Library/Keychains/System.keychain &>/dev/null; then + ALREADY_INSTALLED=true + fi + ;; +esac + +if [[ "$ALREADY_INSTALLED" == "true" ]]; then + echo -e "${YELLOW} ℹ Certificate already installed${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${GREEN}The Sami Home Network Root CA is already trusted on this system.${NC}" + echo -e "${GREEN}No further action needed!${NC}" + echo "" + exit 0 +fi + +echo -e "${GREEN} ✓ Certificate not yet installed, proceeding...${NC}" + +# Install based on OS +echo -e "${CYAN}[6/6] Installing certificate...${NC}" +case "$OS" in + debian) + cp "$TEMP_CERT" "/usr/local/share/ca-certificates/${CERT_NAME}.crt" + update-ca-certificates + echo -e "${GREEN} ✓ Certificate installed via update-ca-certificates${NC}" + ;; + redhat) + cp "$TEMP_CERT" "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" + update-ca-trust + echo -e "${GREEN} ✓ Certificate installed via update-ca-trust${NC}" + ;; + arch) + cp "$TEMP_CERT" "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" + trust extract-compat + echo -e "${GREEN} ✓ Certificate installed via trust extract-compat${NC}" + ;; + macos) + security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$TEMP_CERT" + echo -e "${GREEN} ✓ Certificate installed to System Keychain${NC}" + ;; +esac + +# Cleanup +rm -f "$TEMP_CERT" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} SUCCESS!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "${GREEN}The Sami Home Network Root CA has been installed system-wide.${NC}" +echo "" +echo -e "${CYAN}What's next:${NC}" +echo -e " ${GREEN}✓${NC} All *.sami domains will now be trusted" +echo -e " ${GREEN}✓${NC} Browsers will no longer show security warnings" +echo -e " ${GREEN}✓${NC} Applications will trust HTTPS connections to your local services" +echo "" +echo -e "${CYAN}Test it out:${NC}" +echo -e " ${YELLOW}Visit https://status.sami or any other *.sami service${NC}" +echo -e " ${YELLOW}The connection should show as secure with no warnings${NC}" +echo "" diff --git a/dashcaddy-api/ca/intermediate.crt b/dashcaddy-api/ca/intermediate.crt new file mode 100644 index 0000000..d992ac2 --- /dev/null +++ b/dashcaddy-api/ca/intermediate.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/ca/root.crt b/dashcaddy-api/ca/root.crt new file mode 100644 index 0000000..97a4ce4 --- /dev/null +++ b/dashcaddy-api/ca/root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/ca/root.der b/dashcaddy-api/ca/root.der new file mode 100644 index 0000000..b22c573 Binary files /dev/null and b/dashcaddy-api/ca/root.der differ diff --git a/dashcaddy-api/ca/root.mobileconfig b/dashcaddy-api/ca/root.mobileconfig new file mode 100644 index 0000000..611fd5e --- /dev/null +++ b/dashcaddy-api/ca/root.mobileconfig @@ -0,0 +1,45 @@ + + + + + PayloadContent + + + PayloadCertificateFileName + root.crt + PayloadContent + + MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEiMCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3JrIFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgkwyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8Cyam9Y42t1K8Fx5q5iy+bs8w= + + PayloadDescription + Root CA certificate for Sami Home Network + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca.root-ca + PayloadType + com.apple.security.root + PayloadUUID + 059F6B88-E62A-4219-90D5-7FABBE83540A + PayloadVersion + 1 + + + PayloadDescription + Install the Sami Home Network Root CA to trust locally-issued certificates for *.sami domains. + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca + PayloadOrganization + Sami Home Network + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + AF495D1C-16AF-44A7-8C6C-173CC8E82FC3 + PayloadVersion + 1 + + diff --git a/dashcaddy-api/ca/scripts/generate-all.sh b/dashcaddy-api/ca/scripts/generate-all.sh new file mode 100644 index 0000000..3746d80 --- /dev/null +++ b/dashcaddy-api/ca/scripts/generate-all.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -e + +# DashCA Certificate Generation Script +# This script generates all required certificate formats + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CA_DIR="$(dirname "$SCRIPT_DIR")" +CADDY_CERT_DIR="C:/caddy/certs/pki/authorities/local" + +echo "======================================" +echo "DashCA Certificate Format Generator" +echo "======================================" +echo "" + +# Step 1: Copy certificates from Caddy +echo "[1/4] Copying certificates from Caddy PKI..." +if [ ! -f "$CADDY_CERT_DIR/root.crt" ]; then + echo "ERROR: Root certificate not found at $CADDY_CERT_DIR/root.crt" + exit 1 +fi + +cp "$CADDY_CERT_DIR/root.crt" "$CA_DIR/" +cp "$CADDY_CERT_DIR/intermediate.crt" "$CA_DIR/" 2>/dev/null || echo " (Intermediate certificate not found, skipping)" +echo " ✓ Certificates copied" + +# Step 2: Generate DER format +echo "[2/4] Generating DER format..." +openssl x509 -in "$CA_DIR/root.crt" -outform DER -out "$CA_DIR/root.der" +echo " ✓ DER format generated: root.der" + +# Step 3: Generate certificate info JSON +echo "[3/4] Extracting certificate metadata..." +node "$SCRIPT_DIR/generate-cert-info.js" + +# Step 4: Generate Apple mobileconfig +echo "[4/4] Generating Apple mobile configuration profile..." +node "$SCRIPT_DIR/generate-mobileconfig.js" + +echo "" +echo "======================================" +echo "✓ All certificate formats generated!" +echo "======================================" +echo "" +echo "Files created in: $CA_DIR" +ls -lh "$CA_DIR"/*.{crt,der,mobileconfig,json} 2>/dev/null || echo "Files created successfully" +echo "" +echo "To deploy to production:" +echo " cp -r $CA_DIR/* C:/caddy/sites/ca/" +echo "" diff --git a/dashcaddy-api/ca/scripts/generate-cert-info.js b/dashcaddy-api/ca/scripts/generate-cert-info.js new file mode 100644 index 0000000..d3d22c9 --- /dev/null +++ b/dashcaddy-api/ca/scripts/generate-cert-info.js @@ -0,0 +1,75 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const CERT_PATH = path.join(__dirname, '../root.crt'); +const OUTPUT_PATH = path.join(__dirname, '../cert-info.json'); + +function extractCertInfo() { + try { + console.log('Extracting certificate information from:', CERT_PATH); + + // Extract SHA-256 fingerprint + const fingerprint = execSync(`openssl x509 -in "${CERT_PATH}" -noout -fingerprint -sha256`) + .toString() + .trim() + .split('=')[1]; + + // Extract validity dates + const dates = execSync(`openssl x509 -in "${CERT_PATH}" -noout -dates`).toString(); + const notBefore = dates.match(/notBefore=(.*)/)[1].trim(); + const notAfter = dates.match(/notAfter=(.*)/)[1].trim(); + + // Extract subject + const subject = execSync(`openssl x509 -in "${CERT_PATH}" -noout -subject`) + .toString() + .trim() + .split('CN = ')[1] || execSync(`openssl x509 -in "${CERT_PATH}" -noout -subject`) + .toString() + .trim() + .split('CN=')[1]; + + // Extract serial number + const serialNumber = execSync(`openssl x509 -in "${CERT_PATH}" -noout -serial`) + .toString() + .trim() + .split('=')[1]; + + // Calculate days until expiration + const expirationDate = new Date(notAfter); + const today = new Date(); + const daysUntilExpiration = Math.floor((expirationDate - today) / (1000 * 60 * 60 * 24)); + + const certInfo = { + name: subject, + fingerprint: fingerprint, + validFrom: notBefore, + validUntil: notAfter, + daysUntilExpiration: daysUntilExpiration, + algorithm: 'ECDSA P-256 with SHA-256', + issuer: subject, // Self-signed root CA + serialNumber: serialNumber, + generatedAt: new Date().toISOString() + }; + + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(certInfo, null, 2)); + console.log('✓ Certificate information extracted successfully!'); + console.log(' Output:', OUTPUT_PATH); + console.log(' Name:', certInfo.name); + console.log(' Fingerprint:', certInfo.fingerprint); + console.log(' Valid until:', certInfo.validUntil); + console.log(' Days until expiration:', certInfo.daysUntilExpiration); + + return certInfo; + } catch (error) { + console.error('Error extracting certificate information:', error.message); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + extractCertInfo(); +} + +module.exports = { extractCertInfo }; diff --git a/dashcaddy-api/ca/scripts/generate-mobileconfig.js b/dashcaddy-api/ca/scripts/generate-mobileconfig.js new file mode 100644 index 0000000..77fe85e --- /dev/null +++ b/dashcaddy-api/ca/scripts/generate-mobileconfig.js @@ -0,0 +1,105 @@ +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); + +const CERT_PATH = path.join(__dirname, '../root.crt'); +const OUTPUT_PATH = path.join(__dirname, '../root.mobileconfig'); + +function generateUUID() { + return crypto.randomUUID().toUpperCase(); +} + +function generateMobileConfig() { + try { + console.log('Generating Apple mobile configuration profile...'); + console.log('Reading certificate from:', CERT_PATH); + + // Read certificate + const certPem = fs.readFileSync(CERT_PATH, 'utf8'); + + // Extract base64 content (remove PEM headers and newlines) + const certBase64 = certPem + .replace('-----BEGIN CERTIFICATE-----', '') + .replace('-----END CERTIFICATE-----', '') + .replace(/\s/g, ''); + + // Generate UUIDs for profile and payload + const profileUUID = generateUUID(); + const payloadUUID = generateUUID(); + + const mobileconfig = ` + + + + PayloadContent + + + PayloadCertificateFileName + root.crt + PayloadContent + + ${certBase64} + + PayloadDescription + Root CA certificate for Sami Home Network + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca.root-ca + PayloadType + com.apple.security.root + PayloadUUID + ${payloadUUID} + PayloadVersion + 1 + + + PayloadDescription + Install the Sami Home Network Root CA to trust locally-issued certificates for *.sami domains. + PayloadDisplayName + Sami Home Network Root CA + PayloadIdentifier + com.sami-home.ca + PayloadOrganization + Sami Home Network + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + ${profileUUID} + PayloadVersion + 1 + + +`; + + fs.writeFileSync(OUTPUT_PATH, mobileconfig); + console.log('✓ Mobile configuration profile generated successfully!'); + console.log(' Output:', OUTPUT_PATH); + console.log(' Profile UUID:', profileUUID); + console.log(' Payload UUID:', payloadUUID); + console.log('\nTo install on iOS:'); + console.log(' 1. Download root.mobileconfig to your device'); + console.log(' 2. Open Settings app (it should prompt automatically)'); + console.log(' 3. Tap "Install Profile" and follow the prompts'); + console.log(' 4. Go to Settings > General > About > Certificate Trust Settings'); + console.log(' 5. Enable full trust for "Sami Home Network Root CA"'); + console.log('\nTo install on macOS:'); + console.log(' 1. Download root.mobileconfig'); + console.log(' 2. Open System Settings > Privacy & Security > Profiles'); + console.log(' 3. Click the profile and click Install'); + + return { profileUUID, payloadUUID }; + } catch (error) { + console.error('Error generating mobile configuration profile:', error.message); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + generateMobileConfig(); +} + +module.exports = { generateMobileConfig }; diff --git a/dashcaddy-api/ca/scripts/install.ps1 b/dashcaddy-api/ca/scripts/install.ps1 new file mode 100644 index 0000000..cc28a37 --- /dev/null +++ b/dashcaddy-api/ca/scripts/install.ps1 @@ -0,0 +1,132 @@ +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Installs the Sami Home Network Root CA certificate to the Trusted Root Certification Authorities store. + +.DESCRIPTION + This script downloads the root CA certificate from ca.sami, verifies its fingerprint, + and installs it to the local machine's trusted root store. This allows all *.sami domains + to be trusted system-wide without browser warnings. + +.NOTES + Requires Administrator privileges. + For use with DashCA - https://ca.sami +#> + +$ErrorActionPreference = "Stop" + +# Configuration +$CertUrl = "https://ca.sami/root.crt" +$ExpectedFingerprint = "0898A563F5A1A2585F02D7A8A25487E6BC33969F9B5DB053622 07FAF9621290E" +$TempFile = "$env:TEMP\sami-root-ca.crt" + +# Colors +$Red = [System.ConsoleColor]::Red +$Green = [System.ConsoleColor]::Green +$Cyan = [System.ConsoleColor]::Cyan +$Yellow = [System.ConsoleColor]::Yellow + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host " DashCA Installer" -ForegroundColor $Cyan +Write-Host " Sami Home Network Root CA" -ForegroundColor $Cyan +Write-Host "========================================" -ForegroundColor $Cyan +Write-Host "" + +# Step 1: Download certificate +Write-Host "[1/4] Downloading certificate from $CertUrl..." -ForegroundColor $Cyan +try { + $ProgressPreference = 'SilentlyContinue' # Disable progress bar for faster download + Invoke-WebRequest -Uri $CertUrl -OutFile $TempFile -UseBasicParsing -ErrorAction Stop + Write-Host " ✓ Certificate downloaded" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to download certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Write-Host "" + Write-Host "Troubleshooting:" -ForegroundColor $Yellow + Write-Host " - Ensure you are on the Tailnet/network where ca.sami is accessible" -ForegroundColor $Yellow + Write-Host " - Try accessing https://ca.sami in your browser first" -ForegroundColor $Yellow + exit 1 +} + +# Step 2: Verify fingerprint +Write-Host "[2/4] Verifying certificate fingerprint..." -ForegroundColor $Cyan +try { + $Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($TempFile) + $Fingerprint = $Cert.Thumbprint + + $NormalizedExpected = $ExpectedFingerprint -replace '[:\s]', '' + $NormalizedActual = $Fingerprint -replace '[:\s]', '' + + if ($NormalizedActual -ne $NormalizedExpected) { + Write-Host " ✗ Fingerprint mismatch!" -ForegroundColor $Red + Write-Host " Expected: $ExpectedFingerprint" -ForegroundColor $Yellow + Write-Host " Got: $Fingerprint" -ForegroundColor $Red + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "SECURITY WARNING: The downloaded certificate does not match the expected fingerprint." -ForegroundColor $Red + Write-Host "This could indicate a man-in-the-middle attack or certificate renewal." -ForegroundColor $Red + Write-Host "Please verify with your network administrator before proceeding." -ForegroundColor $Red + exit 1 + } + + Write-Host " ✓ Fingerprint verified: $Fingerprint" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to verify fingerprint" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + exit 1 +} + +# Step 3: Check if already installed +Write-Host "[3/4] Checking for existing certificate..." -ForegroundColor $Cyan +$ExistingCert = Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Fingerprint } +if ($ExistingCert) { + Write-Host " ℹ Certificate already installed" -ForegroundColor $Yellow + Write-Host " Subject: $($ExistingCert.Subject)" -ForegroundColor $Yellow + Write-Host " Not After: $($ExistingCert.NotAfter)" -ForegroundColor $Yellow + Remove-Item $TempFile -Force + Write-Host "" + Write-Host "The Sami Home Network Root CA is already trusted on this system." -ForegroundColor $Green + Write-Host "No further action needed!" -ForegroundColor $Green + Write-Host "" + exit 0 +} +Write-Host " ✓ Certificate not yet installed, proceeding..." -ForegroundColor $Green + +# Step 4: Install certificate +Write-Host "[4/4] Installing certificate to Trusted Root store..." -ForegroundColor $Cyan +try { + $ImportedCert = Import-Certificate -FilePath $TempFile -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction Stop + Write-Host " ✓ Certificate installed successfully" -ForegroundColor $Green + Write-Host " Subject: $($ImportedCert.Subject)" -ForegroundColor $Green + Write-Host " Thumbprint: $($ImportedCert.Thumbprint)" -ForegroundColor $Green +} catch { + Write-Host " ✗ Failed to install certificate" -ForegroundColor $Red + Write-Host " Error: $_" -ForegroundColor $Red + Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + Write-Host "" + Write-Host "Installation failed. Please ensure you are running as Administrator." -ForegroundColor $Red + exit 1 +} + +# Cleanup +Remove-Item $TempFile -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "========================================" -ForegroundColor $Green +Write-Host " SUCCESS!" -ForegroundColor $Green +Write-Host "========================================" -ForegroundColor $Green +Write-Host "" +Write-Host "The Sami Home Network Root CA has been installed to your Trusted Root store." -ForegroundColor $Green +Write-Host "" +Write-Host "What's next:" -ForegroundColor $Cyan +Write-Host " ✓ All *.sami domains will now be trusted system-wide" -ForegroundColor $Green +Write-Host " ✓ Browsers (Edge, Chrome, Firefox) will no longer show security warnings" -ForegroundColor $Green +Write-Host " ✓ Applications will trust HTTPS connections to your local services" -ForegroundColor $Green +Write-Host "" +Write-Host "Test it out:" -ForegroundColor $Cyan +Write-Host " Visit https://status.sami or any other *.sami service" -ForegroundColor $Yellow +Write-Host " The connection should show as secure with no warnings" -ForegroundColor $Yellow +Write-Host "" diff --git a/dashcaddy-api/ca/scripts/install.sh b/dashcaddy-api/ca/scripts/install.sh new file mode 100644 index 0000000..0fcb365 --- /dev/null +++ b/dashcaddy-api/ca/scripts/install.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# +# DashCA Installer - Sami Home Network Root CA +# Installs the root CA certificate system-wide on Linux and macOS +# +# Usage: curl -fsSL https://ca.sami/install.sh | sudo bash +# +set -e + +# Configuration +CERT_URL="https://ca.sami/root.crt" +EXPECTED_FP="08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:9F:9B:5D:B0:53:62:20:7F:AF:96:21:29:0E" +CERT_NAME="Sami_Home_Network_Root_CA" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "" +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} DashCA Installer${NC}" +echo -e "${CYAN} Sami Home Network Root CA${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check for root/sudo +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}✗ This script requires root privileges${NC}" + echo "" + echo "Please run with sudo:" + echo -e " ${YELLOW}curl -fsSL https://ca.sami/install.sh | sudo bash${NC}" + echo "" + echo "Or download first, then run:" + echo -e " ${YELLOW}curl -o install.sh https://ca.sami/install.sh${NC}" + echo -e " ${YELLOW}sudo bash install.sh${NC}" + echo "" + exit 1 +fi + +# Detect OS +echo -e "${CYAN}[1/6] Detecting operating system...${NC}" +if [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + OS_NAME="macOS" +elif [[ -f /etc/os-release ]]; then + . /etc/os-release + if [[ "$ID" == "debian" ]] || [[ "$ID" == "ubuntu" ]] || [[ "$ID_LIKE" == *"debian"* ]]; then + OS="debian" + OS_NAME="Debian/Ubuntu" + elif [[ "$ID" == "fedora" ]] || [[ "$ID" == "rhel" ]] || [[ "$ID" == "centos" ]] || [[ "$ID_LIKE" == *"fedora"* ]] || [[ "$ID_LIKE" == *"rhel"* ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS/Fedora" + elif [[ "$ID" == "arch" ]] || [[ "$ID_LIKE" == *"arch"* ]]; then + OS="arch" + OS_NAME="Arch Linux" + else + OS="unknown" + OS_NAME="Unknown Linux" + fi +elif [[ -f /etc/redhat-release ]]; then + OS="redhat" + OS_NAME="RedHat/CentOS" +elif [[ -f /etc/arch-release ]]; then + OS="arch" + OS_NAME="Arch Linux" +else + OS="unknown" + OS_NAME="Unknown" +fi + +if [[ "$OS" == "unknown" ]]; then + echo -e "${RED} ✗ Unsupported operating system${NC}" + echo "" + echo "This script supports:" + echo " - Debian/Ubuntu" + echo " - RedHat/CentOS/Fedora" + echo " - Arch Linux" + echo " - macOS" + echo "" + echo "For manual installation, download the certificate:" + echo -e " ${YELLOW}curl -O $CERT_URL${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Detected: $OS_NAME${NC}" + +# Download certificate +echo -e "${CYAN}[2/6] Downloading certificate from $CERT_URL...${NC}" +TEMP_CERT=$(mktemp) +if ! curl -fsSL "$CERT_URL" -o "$TEMP_CERT"; then + echo -e "${RED} ✗ Failed to download certificate${NC}" + echo "" + echo -e "${YELLOW}Troubleshooting:${NC}" + echo " - Ensure you are on the Tailnet/network where ca.sami is accessible" + echo " - Try accessing https://ca.sami in your browser first" + echo " - Check your network connection" + rm -f "$TEMP_CERT" + exit 1 +fi +echo -e "${GREEN} ✓ Certificate downloaded${NC}" + +# Verify fingerprint +echo -e "${CYAN}[3/6] Verifying certificate fingerprint...${NC}" +if ! command -v openssl &> /dev/null; then + echo -e "${RED} ✗ OpenSSL not found${NC}" + echo "Please install OpenSSL to verify certificate fingerprint" + rm -f "$TEMP_CERT" + exit 1 +fi + +ACTUAL_FP=$(openssl x509 -in "$TEMP_CERT" -noout -fingerprint -sha256 | cut -d= -f2) + +if [[ "$ACTUAL_FP" != "$EXPECTED_FP" ]]; then + echo -e "${RED} ✗ Fingerprint mismatch!${NC}" + echo -e "${YELLOW} Expected: $EXPECTED_FP${NC}" + echo -e "${RED} Got: $ACTUAL_FP${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${RED}SECURITY WARNING: The downloaded certificate does not match the expected fingerprint.${NC}" + echo -e "${RED}This could indicate a man-in-the-middle attack or certificate renewal.${NC}" + echo -e "${RED}Please verify with your network administrator before proceeding.${NC}" + echo "" + exit 1 +fi + +echo -e "${GREEN} ✓ Fingerprint verified${NC}" + +# Extract certificate details +echo -e "${CYAN}[4/6] Extracting certificate information...${NC}" +CERT_SUBJECT=$(openssl x509 -in "$TEMP_CERT" -noout -subject | sed 's/subject=//') +CERT_NOT_AFTER=$(openssl x509 -in "$TEMP_CERT" -noout -enddate | sed 's/notAfter=//') +echo -e "${GREEN} ✓ Subject: $CERT_SUBJECT${NC}" +echo -e "${GREEN} ✓ Valid until: $CERT_NOT_AFTER${NC}" + +# Check if already installed +echo -e "${CYAN}[5/6] Checking for existing installation...${NC}" +ALREADY_INSTALLED=false + +case "$OS" in + debian) + if [[ -f "/usr/local/share/ca-certificates/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + redhat) + if [[ -f "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + arch) + if [[ -f "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" ]]; then + ALREADY_INSTALLED=true + fi + ;; + macos) + if security find-certificate -a -c "$CERT_SUBJECT" /Library/Keychains/System.keychain &>/dev/null; then + ALREADY_INSTALLED=true + fi + ;; +esac + +if [[ "$ALREADY_INSTALLED" == "true" ]]; then + echo -e "${YELLOW} ℹ Certificate already installed${NC}" + rm -f "$TEMP_CERT" + echo "" + echo -e "${GREEN}The Sami Home Network Root CA is already trusted on this system.${NC}" + echo -e "${GREEN}No further action needed!${NC}" + echo "" + exit 0 +fi + +echo -e "${GREEN} ✓ Certificate not yet installed, proceeding...${NC}" + +# Install based on OS +echo -e "${CYAN}[6/6] Installing certificate...${NC}" +case "$OS" in + debian) + cp "$TEMP_CERT" "/usr/local/share/ca-certificates/${CERT_NAME}.crt" + update-ca-certificates + echo -e "${GREEN} ✓ Certificate installed via update-ca-certificates${NC}" + ;; + redhat) + cp "$TEMP_CERT" "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" + update-ca-trust + echo -e "${GREEN} ✓ Certificate installed via update-ca-trust${NC}" + ;; + arch) + cp "$TEMP_CERT" "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" + trust extract-compat + echo -e "${GREEN} ✓ Certificate installed via trust extract-compat${NC}" + ;; + macos) + security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$TEMP_CERT" + echo -e "${GREEN} ✓ Certificate installed to System Keychain${NC}" + ;; +esac + +# Cleanup +rm -f "$TEMP_CERT" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} SUCCESS!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "${GREEN}The Sami Home Network Root CA has been installed system-wide.${NC}" +echo "" +echo -e "${CYAN}What's next:${NC}" +echo -e " ${GREEN}✓${NC} All *.sami domains will now be trusted" +echo -e " ${GREEN}✓${NC} Browsers will no longer show security warnings" +echo -e " ${GREEN}✓${NC} Applications will trust HTTPS connections to your local services" +echo "" +echo -e "${CYAN}Test it out:${NC}" +echo -e " ${YELLOW}Visit https://status.sami or any other *.sami service${NC}" +echo -e " ${YELLOW}The connection should show as secure with no warnings${NC}" +echo "" diff --git a/dashcaddy-api/docker-compose.yml b/dashcaddy-api/docker-compose.yml deleted file mode 100644 index 96ca55c..0000000 --- a/dashcaddy-api/docker-compose.yml +++ /dev/null @@ -1,39 +0,0 @@ -services: - dashcaddy-api: - build: . - container_name: dashcaddy-api - ports: - - "3001:3001" - volumes: - - C:/Caddy/Caddyfile:/caddyfile:rw - - C:/Caddy/services.json:/app/services.json:rw - - C:/Caddy/dns-credentials.json:/app/dns-credentials.json:rw - - C:/Caddy/config.json:/app/config.json:rw - - C:/Caddy/totp-config.json:/app/totp-config.json:rw - - C:/Caddy/credentials.json:/app/credentials.json:rw - - C:/Caddy/.encryption-key:/app/.encryption-key:rw - - C:/Caddy/.license-secret:/app/.license-secret:ro - - C:/caddy/sites/status/assets:/app/assets:rw - - C:/caddy/sites/ca:/app/ca:ro - - C:/caddy/certs/pki/authorities/local:/app/pki:ro - - C:/caddy/generated-certs:/app/generated-certs:rw - - C:/caddy/sites/status/themes:/app/themes:rw - - /var/run/docker.sock:/var/run/docker.sock - # Media browser mounts - add your drives here for folder browsing - # Format: HostPath:/browse/DriveLetter:ro (read-only for safety) - - C:/:/browse/C:ro - - D:/:/browse/D:ro - - E:/:/browse/E:ro - environment: - - CADDYFILE_PATH=/caddyfile - - CADDY_ADMIN_URL=http://host.docker.internal:2019 - - ASSETS_PATH=/app/assets - - CREDENTIALS_FILE=/app/credentials.json - # Configure your network IPs here for quick selection in Add Service modal - - HOST_LAN_IP=192.168.254.204 - - HOST_TAILSCALE_IP=100.71.97.12 - # Media browser root mappings (container_path=host_path,...) - - MEDIA_BROWSE_ROOTS=/browse/C=C:/,/browse/D=D:/,/browse/E=E:/ - extra_hosts: - - "host.docker.internal:host-gateway" - restart: unless-stopped diff --git a/dashcaddy-api/generated-certs/dns1.sami/ca.srl b/dashcaddy-api/generated-certs/dns1.sami/ca.srl new file mode 100644 index 0000000..b95306d --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/ca.srl @@ -0,0 +1 @@ +13BAB06A9ABC2B407316D10046784104DC1ED3A0 diff --git a/dashcaddy-api/generated-certs/dns1.sami/fullchain.pem b/dashcaddy-api/generated-certs/dns1.sami/fullchain.pem new file mode 100644 index 0000000..62af75f --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/fullchain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUE7qwapq8K0BzFtEARnhBBNwe06AwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzcwMloXDTI3MDIxMTIxMzcwMlowFDESMBAGA1UEAwwJZG5zMS5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqmXXrlT4L2bo19bs +JXsmz+6QVwozbi6tUIbuhrpXP5rSzqwhKy48wbOlYUqBnMPNwlsGS+BE/m0OMh0M +dTtE91dfSXmoIV4nLZ8d92iWt64awsB5GqO6Vn2wyOZB4GBJ4ttAZpnPMX2EU6zx +f9S1N3VHgtyO+y8fG03eTqfB8HVibBpwChZJSYUqdMmJByyGYogTCXbkuKQE+szc +XaGnu6ckRQeTzwHns3h2Jx8lhyg9AMUtWQ8d+CT0PSobSxtBEMlCJvEW36bdJ4Bo +2fD+h0moAdvxOn5p12zhqYpgIGsz8y++iR0CkLP/qOLFIcaqND5ixx+FoQb5AFse +4JA5IwIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMxLnNhbWmCCyouZG5zMS5zYW1pMB0GA1UdDgQWBBQ6 +tqY38Y7OJqK5CPbHWMURSgjOdTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiAoxRh+u2DzgFumKTqlS6BwDXZmb4c5ANTb +mCj6CYulKQIhAP2dC0WpaCkSkEM56GhbiQAcdaS9x0Lb004Pi7IGwS6t +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/openssl.cnf b/dashcaddy-api/generated-certs/dns1.sami/openssl.cnf new file mode 100644 index 0000000..be9eced --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/openssl.cnf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = dns1.sami + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment, digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = dns1.sami +DNS.2 = *.dns1.sami \ No newline at end of file diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.crt b/dashcaddy-api/generated-certs/dns1.sami/server.crt new file mode 100644 index 0000000..f484571 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/server.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUE7qwapq8K0BzFtEARnhBBNwe06AwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzcwMloXDTI3MDIxMTIxMzcwMlowFDESMBAGA1UEAwwJZG5zMS5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqmXXrlT4L2bo19bs +JXsmz+6QVwozbi6tUIbuhrpXP5rSzqwhKy48wbOlYUqBnMPNwlsGS+BE/m0OMh0M +dTtE91dfSXmoIV4nLZ8d92iWt64awsB5GqO6Vn2wyOZB4GBJ4ttAZpnPMX2EU6zx +f9S1N3VHgtyO+y8fG03eTqfB8HVibBpwChZJSYUqdMmJByyGYogTCXbkuKQE+szc +XaGnu6ckRQeTzwHns3h2Jx8lhyg9AMUtWQ8d+CT0PSobSxtBEMlCJvEW36bdJ4Bo +2fD+h0moAdvxOn5p12zhqYpgIGsz8y++iR0CkLP/qOLFIcaqND5ixx+FoQb5AFse +4JA5IwIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMxLnNhbWmCCyouZG5zMS5zYW1pMB0GA1UdDgQWBBQ6 +tqY38Y7OJqK5CPbHWMURSgjOdTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiAoxRh+u2DzgFumKTqlS6BwDXZmb4c5ANTb +mCj6CYulKQIhAP2dC0WpaCkSkEM56GhbiQAcdaS9x0Lb004Pi7IGwS6t +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.csr b/dashcaddy-api/generated-certs/dns1.sami/server.csr new file mode 100644 index 0000000..2f8390a --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/server.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICWTCCAUECAQAwFDESMBAGA1UEAwwJZG5zMS5zYW1pMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAqmXXrlT4L2bo19bsJXsmz+6QVwozbi6tUIbuhrpX +P5rSzqwhKy48wbOlYUqBnMPNwlsGS+BE/m0OMh0MdTtE91dfSXmoIV4nLZ8d92iW +t64awsB5GqO6Vn2wyOZB4GBJ4ttAZpnPMX2EU6zxf9S1N3VHgtyO+y8fG03eTqfB +8HVibBpwChZJSYUqdMmJByyGYogTCXbkuKQE+szcXaGnu6ckRQeTzwHns3h2Jx8l +hyg9AMUtWQ8d+CT0PSobSxtBEMlCJvEW36bdJ4Bo2fD+h0moAdvxOn5p12zhqYpg +IGsz8y++iR0CkLP/qOLFIcaqND5ixx+FoQb5AFse4JA5IwIDAQABoAAwDQYJKoZI +hvcNAQELBQADggEBACS3FIp80TGvc3VuFXfWfzQ7JBDJ/bvQ1TqBZa5w0tMoRfpA +rIM3SOLKjMmbmA7jd2rDZx7CzdNevgSFuqAf8EWS5dvYtytK18Bm4suZtSf/l6VX +8GhCbNqzZsOBER3sstsY/Y45MbQYorj1LyY5NKaQn1LifQT8unhPIqtouWzpwgzz +WNdHPs51tgBEJ6t8PQdy6mQjI4gUY0y6dyyuIoZ6JuUnqJl3ctMiZUz4iUJotGOz +LeTR9PWwfCdzj8MuHBYb46ZR8Y5WOFH7aphS1WCHy7LuxeGypY4n2rVsqTqlzMuf +q2yiJaUdHC6rhR2F5djMxgoPA9XMfg9y7TwbVkM= +-----END CERTIFICATE REQUEST----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.key b/dashcaddy-api/generated-certs/dns1.sami/server.key new file mode 100644 index 0000000..fc9fe56 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqZdeuVPgvZujX +1uwleybP7pBXCjNuLq1Qhu6Gulc/mtLOrCErLjzBs6VhSoGcw83CWwZL4ET+bQ4y +HQx1O0T3V19JeaghXictnx33aJa3rhrCwHkao7pWfbDI5kHgYEni20Bmmc8xfYRT +rPF/1LU3dUeC3I77Lx8bTd5Op8HwdWJsGnAKFklJhSp0yYkHLIZiiBMJduS4pAT6 +zNxdoae7pyRFB5PPAeezeHYnHyWHKD0AxS1ZDx34JPQ9KhtLG0EQyUIm8Rbfpt0n +gGjZ8P6HSagB2/E6fmnXbOGpimAgazPzL76JHQKQs/+o4sUhxqo0PmLHH4WhBvkA +Wx7gkDkjAgMBAAECggEADZEAMNDZCN621ebQDe5JJi+5oQRrf2+kVg+RuOGBhf6r +0mj7I90rJZNJxn42sll9VBnVKkzxXheNFGMie8iIcomrr3jjiiYp4ZFg9f+HIgA9 +mmXVy0/GMfhEQl2t8jd+vaeniZB5yFhUi1wiLVBGtHyk53+4VO1PThtozxBYk4e6 +E91Hkb0DQ6hoUttjVpb2VREwGqgASsYW+Zf2/zZJ9hDKNLfzwmg72mXyvvOvRVcX +O4o+QuVc6Kk19iuOOnxK5lrP0PyXLwkPNZmQfuQx+h85M3te0taiM79vFcttsLzp +cJXEzN4WbFWA23cxmZdwBklQQmTl/v9Ifukf/ttmOQKBgQDes2oGQG4JYU+QTyNB +BscMQ30VGbtjU4f+QzyML4PrLGPD5XQa/bQ5BkoRqYrG5+QiyFtU7pbEPsq6z1hM +yfr1rdmQHnMdSnbBCKypzPTsQdVTaeE6Mh/GvS3x5eQby8UggUsR9VSyqtc2E5Hi +4DHnLJ9vqry5FC/YY0V0/Kk3pwKBgQDD4F0IwpzY6TJp+Vmisz69qOJKc+IFtXSl +YdMRjDRioTP1GNlh9NDBb8Em6WsMPBAY3+F657ds6AtooNvYUKBjb0tvPTbz+oAj +kLQHA2m2r1T7qsMSktyFQOzaVQvmuvHR+wN8fnIVq0j4rN+YM5OkdSHVEcBNrcb4 +qr4hXnEiJQKBgQCB7RSPPymzaUV9AN6lgmnAeuNP2ypbQZGWwu1hMBt7qfMuiACb ++qYZmtS9xzdC4mlT7aZzP5tQNP4bzPpMGo7CpMHIditczGPKLOBnVD8UUzg9KQmQ +5UtqrFZQyXmyychhNW1xtbrLXiae0v4K6hfTMlJ2WJswM1nSmeAc54dmAwKBgHZP +scIV0qlOCa5q91JH1DC4rp5r6myqUp+GO/gQaJ/eYMS9UqhRODpupws61/bW3J45 +tDcJeQhmDGYRK1k4Mfh9g+HX5rZtazKQN704uYSn2Lv+Q7+XR22Rbr7duceOyXuc +k1mCAqTGBdh2isOi+53NRjctdqs0uMcAUsFTCyClAoGBAI2xTpsyFgBKXUYzXiLs +b9AsdVqZgNB3Lv852Ahm7vRNMAwwQ558mQZFM8AUgdEqSpyaMOFNSkajZa9bmLSO +mL2mwruEy8UPJYPHInPf5nM5xICIhXbzdlTiNj2mHBaBn8fdsWqMhthNdasOrfCP +FqETebA6LaAGiYD+HzbTPtWh +-----END PRIVATE KEY----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.pem b/dashcaddy-api/generated-certs/dns1.sami/server.pem new file mode 100644 index 0000000..bd58f8f --- /dev/null +++ b/dashcaddy-api/generated-certs/dns1.sami/server.pem @@ -0,0 +1,58 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqZdeuVPgvZujX +1uwleybP7pBXCjNuLq1Qhu6Gulc/mtLOrCErLjzBs6VhSoGcw83CWwZL4ET+bQ4y +HQx1O0T3V19JeaghXictnx33aJa3rhrCwHkao7pWfbDI5kHgYEni20Bmmc8xfYRT +rPF/1LU3dUeC3I77Lx8bTd5Op8HwdWJsGnAKFklJhSp0yYkHLIZiiBMJduS4pAT6 +zNxdoae7pyRFB5PPAeezeHYnHyWHKD0AxS1ZDx34JPQ9KhtLG0EQyUIm8Rbfpt0n +gGjZ8P6HSagB2/E6fmnXbOGpimAgazPzL76JHQKQs/+o4sUhxqo0PmLHH4WhBvkA +Wx7gkDkjAgMBAAECggEADZEAMNDZCN621ebQDe5JJi+5oQRrf2+kVg+RuOGBhf6r +0mj7I90rJZNJxn42sll9VBnVKkzxXheNFGMie8iIcomrr3jjiiYp4ZFg9f+HIgA9 +mmXVy0/GMfhEQl2t8jd+vaeniZB5yFhUi1wiLVBGtHyk53+4VO1PThtozxBYk4e6 +E91Hkb0DQ6hoUttjVpb2VREwGqgASsYW+Zf2/zZJ9hDKNLfzwmg72mXyvvOvRVcX +O4o+QuVc6Kk19iuOOnxK5lrP0PyXLwkPNZmQfuQx+h85M3te0taiM79vFcttsLzp +cJXEzN4WbFWA23cxmZdwBklQQmTl/v9Ifukf/ttmOQKBgQDes2oGQG4JYU+QTyNB +BscMQ30VGbtjU4f+QzyML4PrLGPD5XQa/bQ5BkoRqYrG5+QiyFtU7pbEPsq6z1hM +yfr1rdmQHnMdSnbBCKypzPTsQdVTaeE6Mh/GvS3x5eQby8UggUsR9VSyqtc2E5Hi +4DHnLJ9vqry5FC/YY0V0/Kk3pwKBgQDD4F0IwpzY6TJp+Vmisz69qOJKc+IFtXSl +YdMRjDRioTP1GNlh9NDBb8Em6WsMPBAY3+F657ds6AtooNvYUKBjb0tvPTbz+oAj +kLQHA2m2r1T7qsMSktyFQOzaVQvmuvHR+wN8fnIVq0j4rN+YM5OkdSHVEcBNrcb4 +qr4hXnEiJQKBgQCB7RSPPymzaUV9AN6lgmnAeuNP2ypbQZGWwu1hMBt7qfMuiACb ++qYZmtS9xzdC4mlT7aZzP5tQNP4bzPpMGo7CpMHIditczGPKLOBnVD8UUzg9KQmQ +5UtqrFZQyXmyychhNW1xtbrLXiae0v4K6hfTMlJ2WJswM1nSmeAc54dmAwKBgHZP +scIV0qlOCa5q91JH1DC4rp5r6myqUp+GO/gQaJ/eYMS9UqhRODpupws61/bW3J45 +tDcJeQhmDGYRK1k4Mfh9g+HX5rZtazKQN704uYSn2Lv+Q7+XR22Rbr7duceOyXuc +k1mCAqTGBdh2isOi+53NRjctdqs0uMcAUsFTCyClAoGBAI2xTpsyFgBKXUYzXiLs +b9AsdVqZgNB3Lv852Ahm7vRNMAwwQ558mQZFM8AUgdEqSpyaMOFNSkajZa9bmLSO +mL2mwruEy8UPJYPHInPf5nM5xICIhXbzdlTiNj2mHBaBn8fdsWqMhthNdasOrfCP +FqETebA6LaAGiYD+HzbTPtWh +-----END PRIVATE KEY----- + +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUE7qwapq8K0BzFtEARnhBBNwe06AwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzcwMloXDTI3MDIxMTIxMzcwMlowFDESMBAGA1UEAwwJZG5zMS5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqmXXrlT4L2bo19bs +JXsmz+6QVwozbi6tUIbuhrpXP5rSzqwhKy48wbOlYUqBnMPNwlsGS+BE/m0OMh0M +dTtE91dfSXmoIV4nLZ8d92iWt64awsB5GqO6Vn2wyOZB4GBJ4ttAZpnPMX2EU6zx +f9S1N3VHgtyO+y8fG03eTqfB8HVibBpwChZJSYUqdMmJByyGYogTCXbkuKQE+szc +XaGnu6ckRQeTzwHns3h2Jx8lhyg9AMUtWQ8d+CT0PSobSxtBEMlCJvEW36bdJ4Bo +2fD+h0moAdvxOn5p12zhqYpgIGsz8y++iR0CkLP/qOLFIcaqND5ixx+FoQb5AFse +4JA5IwIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMxLnNhbWmCCyouZG5zMS5zYW1pMB0GA1UdDgQWBBQ6 +tqY38Y7OJqK5CPbHWMURSgjOdTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiAoxRh+u2DzgFumKTqlS6BwDXZmb4c5ANTb +mCj6CYulKQIhAP2dC0WpaCkSkEM56GhbiQAcdaS9x0Lb004Pi7IGwS6t +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns1.sami/server.pfx b/dashcaddy-api/generated-certs/dns1.sami/server.pfx new file mode 100644 index 0000000..fde3fcc Binary files /dev/null and b/dashcaddy-api/generated-certs/dns1.sami/server.pfx differ diff --git a/dashcaddy-api/generated-certs/dns2.sami/ca.srl b/dashcaddy-api/generated-certs/dns2.sami/ca.srl new file mode 100644 index 0000000..1a1afe3 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/ca.srl @@ -0,0 +1 @@ +04751A03AABA191E9D2760D5CB20ED183D9178E8 diff --git a/dashcaddy-api/generated-certs/dns2.sami/fullchain.pem b/dashcaddy-api/generated-certs/dns2.sami/fullchain.pem new file mode 100644 index 0000000..1d25c75 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/fullchain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIClzCCAjygAwIBAgIUBHUaA6q6GR6dJ2DVyyDtGD2ReOgwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxNDExNVoXDTI3MDIxMTIxNDExNVowFDESMBAGA1UEAwwJZG5zMi5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnxohNydjwNtMkAJr +BnNQHgA2McFkylJgq2hbOymPQLivezm3q0t+3wstVcp5l+91UQ+0mrIlDBrAu5Vf +UcaG/YUCgePSKxvdzBRsZsoa16VMoKBh58OTv5wiebAx0zALuNYjx/OVFYnxxmQl +B7eEham79Qz/8pKMUEoyB6IfzpsA/69kwmONgJeynW8hsYlEZlBm3VbZ80gWKG1g +nm2m1kg1FJnnljQF2WXPnpw/0TZIw4PX65fNpue09uShr68RFgpVxvI4LsQy+K4M +as334HPJBSzAUxBnOuItYnmcAOkejvTAygVSa0bEWNr4DADJ03+OqtpeJqh0h8t8 +w35OowIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMyLnNhbWmCCyouZG5zMi5zYW1pMB0GA1UdDgQWBBTr +68PJ406T+VqAoNNxIL7KBHkXCTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNJADBGAiEAoAYtlylMbBdRINOUmxGY3c9gqsQWpqBh +NIwNbHCnZMACIQCJ6Qy0dCOiW5R95C8U3zEjssUSDuoSIdm0nf6clE30cw== +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/openssl.cnf b/dashcaddy-api/generated-certs/dns2.sami/openssl.cnf new file mode 100644 index 0000000..8b3a355 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/openssl.cnf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = dns2.sami + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment, digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = dns2.sami +DNS.2 = *.dns2.sami \ No newline at end of file diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.crt b/dashcaddy-api/generated-certs/dns2.sami/server.crt new file mode 100644 index 0000000..e169ddf --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/server.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIIClzCCAjygAwIBAgIUBHUaA6q6GR6dJ2DVyyDtGD2ReOgwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxNDExNVoXDTI3MDIxMTIxNDExNVowFDESMBAGA1UEAwwJZG5zMi5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnxohNydjwNtMkAJr +BnNQHgA2McFkylJgq2hbOymPQLivezm3q0t+3wstVcp5l+91UQ+0mrIlDBrAu5Vf +UcaG/YUCgePSKxvdzBRsZsoa16VMoKBh58OTv5wiebAx0zALuNYjx/OVFYnxxmQl +B7eEham79Qz/8pKMUEoyB6IfzpsA/69kwmONgJeynW8hsYlEZlBm3VbZ80gWKG1g +nm2m1kg1FJnnljQF2WXPnpw/0TZIw4PX65fNpue09uShr68RFgpVxvI4LsQy+K4M +as334HPJBSzAUxBnOuItYnmcAOkejvTAygVSa0bEWNr4DADJ03+OqtpeJqh0h8t8 +w35OowIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMyLnNhbWmCCyouZG5zMi5zYW1pMB0GA1UdDgQWBBTr +68PJ406T+VqAoNNxIL7KBHkXCTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNJADBGAiEAoAYtlylMbBdRINOUmxGY3c9gqsQWpqBh +NIwNbHCnZMACIQCJ6Qy0dCOiW5R95C8U3zEjssUSDuoSIdm0nf6clE30cw== +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.csr b/dashcaddy-api/generated-certs/dns2.sami/server.csr new file mode 100644 index 0000000..2eab83d --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/server.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICWTCCAUECAQAwFDESMBAGA1UEAwwJZG5zMi5zYW1pMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAnxohNydjwNtMkAJrBnNQHgA2McFkylJgq2hbOymP +QLivezm3q0t+3wstVcp5l+91UQ+0mrIlDBrAu5VfUcaG/YUCgePSKxvdzBRsZsoa +16VMoKBh58OTv5wiebAx0zALuNYjx/OVFYnxxmQlB7eEham79Qz/8pKMUEoyB6If +zpsA/69kwmONgJeynW8hsYlEZlBm3VbZ80gWKG1gnm2m1kg1FJnnljQF2WXPnpw/ +0TZIw4PX65fNpue09uShr68RFgpVxvI4LsQy+K4Mas334HPJBSzAUxBnOuItYnmc +AOkejvTAygVSa0bEWNr4DADJ03+OqtpeJqh0h8t8w35OowIDAQABoAAwDQYJKoZI +hvcNAQELBQADggEBAHX+ZBbKH5ZEMrjZwFGjay2KWY7/5XeMOwXPUOLR1kvCcRG4 +SqijZWrFnSCD3zhtMmgkCfTk4cWt8kInrQOYIGIg+sReQH2PKMci8JZnzQNfTuh/ +4l6ZdEk6OLoZnDjDdqq0rdyLUdBIvgQZ79Rz5exDwKR4ASYZ4djvyh3/WifXk6Qk +CybM7Gnr2ExzcUslYyx2Ml30j49TdORZJ14vfTPy5I2PAr0gfrkmNbhsAoq/FsdC +R651/B7naFtaOQcSsp6V+BTnSpfCnL//Qn8AgnAYgJSjSy95p9g5N1ywApKVNmhq +RjVHjKqoCyNzhV505y+GScDZsGbUKIrI2u3B0sE= +-----END CERTIFICATE REQUEST----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.key b/dashcaddy-api/generated-certs/dns2.sami/server.key new file mode 100644 index 0000000..641e5cd --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfGiE3J2PA20yQ +AmsGc1AeADYxwWTKUmCraFs7KY9AuK97OberS37fCy1VynmX73VRD7SasiUMGsC7 +lV9Rxob9hQKB49IrG93MFGxmyhrXpUygoGHnw5O/nCJ5sDHTMAu41iPH85UVifHG +ZCUHt4SFqbv1DP/ykoxQSjIHoh/OmwD/r2TCY42Al7KdbyGxiURmUGbdVtnzSBYo +bWCebabWSDUUmeeWNAXZZc+enD/RNkjDg9frl82m57T25KGvrxEWClXG8jguxDL4 +rgxqzffgc8kFLMBTEGc64i1ieZwA6R6O9MDKBVJrRsRY2vgMAMnTf46q2l4mqHSH +y3zDfk6jAgMBAAECggEALy90L2POuVkwrTsSW7DiT2t3pyz4g+qHgf5qpDKwqhy7 +6ntVJge3YlXxxZJNmoppjDdwtDpmM19RzrA/u0R6L4D9m+EhqShUENzzw05oRJFh +FvhJ4Q8HaBnZvaZMOJp0t4ZGyVvL2L33BwQXWWELfAq5VDVdSSc+VueSS+JAtaiZ +zNDEtn95EOCB37gFsGsZR/CYxaQQv/b8uuMFjXwNmEi0eG1n2S75Hv5QIq/luQrK +5sUfeztmo7Vkt9VwsZVce0VnU3PsQtEQx8Gf6Cir9+FX0NCbmPJgJKYYWrAOvEhi +1FjhxEyL1TgFeyCcXvTwrp+k/DHj7N3sa7yId4gMXQKBgQDZLPil5eZ84MuV9zeP +oRrO+egN3SBPTySs5rOfw1TpYe5cLtbKdjzjXVuUNJAXdBgNiLhX8kg6rW8ysJQG +tiJHTNvUBnG7NLYdUAx9S5cNP4zOjSDPFoGPIv+6HpyPP9I5molECv8n5G30yeAt +W7/L9B1zwLzE5Rgv3LhxaojWNwKBgQC7i2ylBB4vpOTAcSpO+K4huHxHgaklC6dH +EPTQNphIBD3BrNTsjiaIkUrat/lN72IGE67bJWcSsyL7vEr5cAXywmgaTt1wvNg6 +CiUBI+9xH94V180wlrv3lBdpQ7aSTFAZ/d+6L90C5DSftLoP+xF0k4Ueerqzr+ZZ +yTeQqhsU9QKBgBne6JPiqi1QHhB0TbMXbvke/gzAvbuU4vqR1O4AN9pBZ0Kl9cJS +iXhQQ7uI821H0CG0mrknTIFo7aktLcUK51R1DG7agavaYKNnSMWYPps+acilOTZS +KQbjFXGXefD6mlFwXk+zu6eF569UaRceKd/i6atDV3lhDRuOgI5KMZjLAoGAYID2 +79a1nbiQyQGyTp6iI9HliXoLync7pVLxVm6xX2VnTdCcY/kllOKGjRNb4qGKZCwe +rr+JUVMCblzOi5n8RZWJsffg9JEIBp2Puw19uU03ny/DcvwtTtFSVqU0PgWstiMv +y4OzizhYZ9G8aFq9+amrNyraBXo+4vaqc/NEKj0CgYEAqQCsnZuf/ButQH9L15qd +0jaRkB/AaZ5iPDyjLGXPeq/WGrAXoqiqnLVdgk9tcxpvke7KimWPqVMzfS1A1y8Q +RQ852Sw1mtQCbhIHfOBFXIaLkPPR+m34nvbBQqiUuafg6fxSp1L0p7w4DchPD6mU +eU9fTRaIZrhBnaJD4uEGPlc= +-----END PRIVATE KEY----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.pem b/dashcaddy-api/generated-certs/dns2.sami/server.pem new file mode 100644 index 0000000..62fa6d7 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns2.sami/server.pem @@ -0,0 +1,58 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfGiE3J2PA20yQ +AmsGc1AeADYxwWTKUmCraFs7KY9AuK97OberS37fCy1VynmX73VRD7SasiUMGsC7 +lV9Rxob9hQKB49IrG93MFGxmyhrXpUygoGHnw5O/nCJ5sDHTMAu41iPH85UVifHG +ZCUHt4SFqbv1DP/ykoxQSjIHoh/OmwD/r2TCY42Al7KdbyGxiURmUGbdVtnzSBYo +bWCebabWSDUUmeeWNAXZZc+enD/RNkjDg9frl82m57T25KGvrxEWClXG8jguxDL4 +rgxqzffgc8kFLMBTEGc64i1ieZwA6R6O9MDKBVJrRsRY2vgMAMnTf46q2l4mqHSH +y3zDfk6jAgMBAAECggEALy90L2POuVkwrTsSW7DiT2t3pyz4g+qHgf5qpDKwqhy7 +6ntVJge3YlXxxZJNmoppjDdwtDpmM19RzrA/u0R6L4D9m+EhqShUENzzw05oRJFh +FvhJ4Q8HaBnZvaZMOJp0t4ZGyVvL2L33BwQXWWELfAq5VDVdSSc+VueSS+JAtaiZ +zNDEtn95EOCB37gFsGsZR/CYxaQQv/b8uuMFjXwNmEi0eG1n2S75Hv5QIq/luQrK +5sUfeztmo7Vkt9VwsZVce0VnU3PsQtEQx8Gf6Cir9+FX0NCbmPJgJKYYWrAOvEhi +1FjhxEyL1TgFeyCcXvTwrp+k/DHj7N3sa7yId4gMXQKBgQDZLPil5eZ84MuV9zeP +oRrO+egN3SBPTySs5rOfw1TpYe5cLtbKdjzjXVuUNJAXdBgNiLhX8kg6rW8ysJQG +tiJHTNvUBnG7NLYdUAx9S5cNP4zOjSDPFoGPIv+6HpyPP9I5molECv8n5G30yeAt +W7/L9B1zwLzE5Rgv3LhxaojWNwKBgQC7i2ylBB4vpOTAcSpO+K4huHxHgaklC6dH +EPTQNphIBD3BrNTsjiaIkUrat/lN72IGE67bJWcSsyL7vEr5cAXywmgaTt1wvNg6 +CiUBI+9xH94V180wlrv3lBdpQ7aSTFAZ/d+6L90C5DSftLoP+xF0k4Ueerqzr+ZZ +yTeQqhsU9QKBgBne6JPiqi1QHhB0TbMXbvke/gzAvbuU4vqR1O4AN9pBZ0Kl9cJS +iXhQQ7uI821H0CG0mrknTIFo7aktLcUK51R1DG7agavaYKNnSMWYPps+acilOTZS +KQbjFXGXefD6mlFwXk+zu6eF569UaRceKd/i6atDV3lhDRuOgI5KMZjLAoGAYID2 +79a1nbiQyQGyTp6iI9HliXoLync7pVLxVm6xX2VnTdCcY/kllOKGjRNb4qGKZCwe +rr+JUVMCblzOi5n8RZWJsffg9JEIBp2Puw19uU03ny/DcvwtTtFSVqU0PgWstiMv +y4OzizhYZ9G8aFq9+amrNyraBXo+4vaqc/NEKj0CgYEAqQCsnZuf/ButQH9L15qd +0jaRkB/AaZ5iPDyjLGXPeq/WGrAXoqiqnLVdgk9tcxpvke7KimWPqVMzfS1A1y8Q +RQ852Sw1mtQCbhIHfOBFXIaLkPPR+m34nvbBQqiUuafg6fxSp1L0p7w4DchPD6mU +eU9fTRaIZrhBnaJD4uEGPlc= +-----END PRIVATE KEY----- + +-----BEGIN CERTIFICATE----- +MIIClzCCAjygAwIBAgIUBHUaA6q6GR6dJ2DVyyDtGD2ReOgwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxNDExNVoXDTI3MDIxMTIxNDExNVowFDESMBAGA1UEAwwJZG5zMi5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnxohNydjwNtMkAJr +BnNQHgA2McFkylJgq2hbOymPQLivezm3q0t+3wstVcp5l+91UQ+0mrIlDBrAu5Vf +UcaG/YUCgePSKxvdzBRsZsoa16VMoKBh58OTv5wiebAx0zALuNYjx/OVFYnxxmQl +B7eEham79Qz/8pKMUEoyB6IfzpsA/69kwmONgJeynW8hsYlEZlBm3VbZ80gWKG1g +nm2m1kg1FJnnljQF2WXPnpw/0TZIw4PX65fNpue09uShr68RFgpVxvI4LsQy+K4M +as334HPJBSzAUxBnOuItYnmcAOkejvTAygVSa0bEWNr4DADJ03+OqtpeJqh0h8t8 +w35OowIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMyLnNhbWmCCyouZG5zMi5zYW1pMB0GA1UdDgQWBBTr +68PJ406T+VqAoNNxIL7KBHkXCTAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNJADBGAiEAoAYtlylMbBdRINOUmxGY3c9gqsQWpqBh +NIwNbHCnZMACIQCJ6Qy0dCOiW5R95C8U3zEjssUSDuoSIdm0nf6clE30cw== +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns2.sami/server.pfx b/dashcaddy-api/generated-certs/dns2.sami/server.pfx new file mode 100644 index 0000000..48fc39e Binary files /dev/null and b/dashcaddy-api/generated-certs/dns2.sami/server.pfx differ diff --git a/dashcaddy-api/generated-certs/dns3.sami/ca.srl b/dashcaddy-api/generated-certs/dns3.sami/ca.srl new file mode 100644 index 0000000..c7b1bcc --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/ca.srl @@ -0,0 +1 @@ +276D2F2E212A363897488BE6A0B941BCE836BAD3 diff --git a/dashcaddy-api/generated-certs/dns3.sami/fullchain.pem b/dashcaddy-api/generated-certs/dns3.sami/fullchain.pem new file mode 100644 index 0000000..fd47d9c --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/fullchain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUJ20vLiEqNjiXSIvmoLlBvOg2utMwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzgwNloXDTI3MDIxMTIxMzgwNlowFDESMBAGA1UEAwwJZG5zMy5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4CmJnD34danEr45b +JayyM87UgvRjbsJJHYRWgp5sRjmNKr174VtzQ+1deXOe0kWyfjnSqI/Em2mJgGDR +4S2bhBEX5MiXqEHQATws5oHoXPuGmbnm0Y0B9ePU1Xz+ix0tz3vOHc6z62XWZquF +HE7TEhzM9OlMj/5sMPKxR4FHSDLWIXF6NUknBM6wX7uyJEcoXnf2vYm5ygqd/1vu +ncWBeybKFS4hsnEIsp1wcAAw1GH9x7fTyOn/7SLzTk7S6FkLt+upHCXqkYPUqNfG +sRFcmuBZr1RXRjxilgFJ9VQ4NEAvHRdXQmaCsGh3Ah6JioOjlpLJxcAPgyYeAoPz +xEVn6QIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMzLnNhbWmCCyouZG5zMy5zYW1pMB0GA1UdDgQWBBTC +Nw7MUUA4YxBFua8YzXZ2ciBmbzAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiBdq9UEw1H+/sidEXaf6D6d3z/YymJLNycN +xP4afkkExgIhAOV44DZsuGBckiIOJfNWpwbcA92GTXg/XlcALEcOtX5g +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/openssl.cnf b/dashcaddy-api/generated-certs/dns3.sami/openssl.cnf new file mode 100644 index 0000000..c72dc31 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/openssl.cnf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = dns3.sami + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment, digitalSignature +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = dns3.sami +DNS.2 = *.dns3.sami \ No newline at end of file diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.crt b/dashcaddy-api/generated-certs/dns3.sami/server.crt new file mode 100644 index 0000000..1369ebd --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/server.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUJ20vLiEqNjiXSIvmoLlBvOg2utMwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzgwNloXDTI3MDIxMTIxMzgwNlowFDESMBAGA1UEAwwJZG5zMy5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4CmJnD34danEr45b +JayyM87UgvRjbsJJHYRWgp5sRjmNKr174VtzQ+1deXOe0kWyfjnSqI/Em2mJgGDR +4S2bhBEX5MiXqEHQATws5oHoXPuGmbnm0Y0B9ePU1Xz+ix0tz3vOHc6z62XWZquF +HE7TEhzM9OlMj/5sMPKxR4FHSDLWIXF6NUknBM6wX7uyJEcoXnf2vYm5ygqd/1vu +ncWBeybKFS4hsnEIsp1wcAAw1GH9x7fTyOn/7SLzTk7S6FkLt+upHCXqkYPUqNfG +sRFcmuBZr1RXRjxilgFJ9VQ4NEAvHRdXQmaCsGh3Ah6JioOjlpLJxcAPgyYeAoPz +xEVn6QIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMzLnNhbWmCCyouZG5zMy5zYW1pMB0GA1UdDgQWBBTC +Nw7MUUA4YxBFua8YzXZ2ciBmbzAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiBdq9UEw1H+/sidEXaf6D6d3z/YymJLNycN +xP4afkkExgIhAOV44DZsuGBckiIOJfNWpwbcA92GTXg/XlcALEcOtX5g +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.csr b/dashcaddy-api/generated-certs/dns3.sami/server.csr new file mode 100644 index 0000000..b294090 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/server.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICWTCCAUECAQAwFDESMBAGA1UEAwwJZG5zMy5zYW1pMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA4CmJnD34danEr45bJayyM87UgvRjbsJJHYRWgp5s +RjmNKr174VtzQ+1deXOe0kWyfjnSqI/Em2mJgGDR4S2bhBEX5MiXqEHQATws5oHo +XPuGmbnm0Y0B9ePU1Xz+ix0tz3vOHc6z62XWZquFHE7TEhzM9OlMj/5sMPKxR4FH +SDLWIXF6NUknBM6wX7uyJEcoXnf2vYm5ygqd/1vuncWBeybKFS4hsnEIsp1wcAAw +1GH9x7fTyOn/7SLzTk7S6FkLt+upHCXqkYPUqNfGsRFcmuBZr1RXRjxilgFJ9VQ4 +NEAvHRdXQmaCsGh3Ah6JioOjlpLJxcAPgyYeAoPzxEVn6QIDAQABoAAwDQYJKoZI +hvcNAQELBQADggEBAIPDdVB4pt4JVg1O+IYoFLUASN0sTYtSIPqQCg9f3afxTNkQ +NNqgcegoZC+kfcOqhW1EGENjjzP2+U2A5clsMJlIhgumVSs/huuhLGs+2bY5ERuN +l9oc8zWAQvDl7NLGX/HEXw9OjBE7EV/aKmdogp7Z9SJXUJk6i4bC5BfnYm+Eabw/ +iM3nm+nBFSnYhm6nQQaddYvLUqPS2WrFTSTbHJZRx5FSR6u6ShTfqy1vZg7QB51j +NtPLUJx3dansUPEGUOtolk2AeRHvRmLI5MmtcoUt3TFM/XCzDDFM5r8MYXwJfTPI +Bm8hl2Ob+rxZhfSB2tMJbEIAiPwIsWmVfSesVW4= +-----END CERTIFICATE REQUEST----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.key b/dashcaddy-api/generated-certs/dns3.sami/server.key new file mode 100644 index 0000000..b4dcaa4 --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDgKYmcPfh1qcSv +jlslrLIzztSC9GNuwkkdhFaCnmxGOY0qvXvhW3ND7V15c57SRbJ+OdKoj8SbaYmA +YNHhLZuEERfkyJeoQdABPCzmgehc+4aZuebRjQH149TVfP6LHS3Pe84dzrPrZdZm +q4UcTtMSHMz06UyP/mww8rFHgUdIMtYhcXo1SScEzrBfu7IkRyhed/a9ibnKCp3/ +W+6dxYF7JsoVLiGycQiynXBwADDUYf3Ht9PI6f/tIvNOTtLoWQu366kcJeqRg9So +18axEVya4FmvVFdGPGKWAUn1VDg0QC8dF1dCZoKwaHcCHomKg6OWksnFwA+DJh4C +g/PERWfpAgMBAAECggEAArQf+WOsrne2ZmNaee0L+005ybGlSW22q5YuEpHuIXGr +QUBNiWyD/QFO2c8Dcdd4THU7I8Ubpqih6l0nXsK0f7tSrtTMGr2U81jNYM3swpPp +3W26SI0R/5qxwQ3ZFQDJgCEm+KSMcpYur4aoOEQ7Dj2FYgqi+M6pWkY0xe24vQ3n +0//wWpUW5WokTfyQFe+/XtFK3XzD4LKfqeVDXf2/kaR7zEdqbiKdGuEW8dR0J/wQ +U0/Aj44FSMTr/BIK3zUcuMxmYdQ30x8PFQjOXfoAFqn7NRlcdD0RWQS5QY2c/oHq +xtYxhxGz27eDryVibvOXvj9rr8lE9T99iQfS5lo0+wKBgQD0ufl1IKUN3+uwhroW +0sldJRI2m1pJIyPXzt5NkUZTgm9vw1qgsH0dBszP5kuctFJ7M4s20re5gxkh5Shk +HKZqDvRmZYD7FF+IpMNogrWxRXTV3yk0WoP7ul2o56ZrgM/le2sgkC2Wh9IDR1B9 +W1wp2yuu3UouZLbcd9tvZADX3wKBgQDqfQ1fHpsrVqH5BQCaFPoJQ3pa8j1dsqsV +p3VVmk4LbmgRZyqx2zdot4YsnktUgnIrp7G4mi+WyZUFDQtastVm2PtfiFFml7th +Yr1qgStn/S6SHG137xbgFc9ISDqaqcBRtbufL9vPBseR9Gl1x6J/exsGz2Y3GdLq +mGKN3kXZNwKBgFYwPUvj8F1flFk6ScWJ/QMB8FUtB3IknxX9NEurM1Jr6KU67usS +3S1g3LcHi2+oDjh3obrwIWaDZlGKrFv9vxoxJB//9Zn3xeGQ7YUcK7NAG/LKwm8+ +xabdLukylGjeF9nhSoxQWs3eDbe74PwVNfNDzjGqm9qU+9XPnIexd56PAoGBAI8Z +r95btEB3hzOMPPjLUmfy2SnFaXPUBJqbbnzGRViukS4FssWFzcPHNEfodnplfT1t +AoVw+xfff2TsuAAq4Rb51jZP37VnZcAh3QyxNYcpuJEMbjXiICiyxqQLCFC4xZhX ++t9D+rXWIlbNPMrXATEhNLYsPenkhZYLVyHTAvdzAoGBAPPHGBN9DW7gFoHrxLf6 +J1kLcjFOxwNDXnMk9LMzyFYmvn7S/YXOIzkgGzH+dwnwigwCqi0hxkOfoGHF1cXM +k9N5tB6G4nnHPFi/MOVmyiRw3RiF2J1CU1u2xUN+ddIRvgpsvYI1dl3PjyKuStVA +45AxheULl0CfRqo7YhUG5E0l +-----END PRIVATE KEY----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.pem b/dashcaddy-api/generated-certs/dns3.sami/server.pem new file mode 100644 index 0000000..c4dd8bd --- /dev/null +++ b/dashcaddy-api/generated-certs/dns3.sami/server.pem @@ -0,0 +1,58 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDgKYmcPfh1qcSv +jlslrLIzztSC9GNuwkkdhFaCnmxGOY0qvXvhW3ND7V15c57SRbJ+OdKoj8SbaYmA +YNHhLZuEERfkyJeoQdABPCzmgehc+4aZuebRjQH149TVfP6LHS3Pe84dzrPrZdZm +q4UcTtMSHMz06UyP/mww8rFHgUdIMtYhcXo1SScEzrBfu7IkRyhed/a9ibnKCp3/ +W+6dxYF7JsoVLiGycQiynXBwADDUYf3Ht9PI6f/tIvNOTtLoWQu366kcJeqRg9So +18axEVya4FmvVFdGPGKWAUn1VDg0QC8dF1dCZoKwaHcCHomKg6OWksnFwA+DJh4C +g/PERWfpAgMBAAECggEAArQf+WOsrne2ZmNaee0L+005ybGlSW22q5YuEpHuIXGr +QUBNiWyD/QFO2c8Dcdd4THU7I8Ubpqih6l0nXsK0f7tSrtTMGr2U81jNYM3swpPp +3W26SI0R/5qxwQ3ZFQDJgCEm+KSMcpYur4aoOEQ7Dj2FYgqi+M6pWkY0xe24vQ3n +0//wWpUW5WokTfyQFe+/XtFK3XzD4LKfqeVDXf2/kaR7zEdqbiKdGuEW8dR0J/wQ +U0/Aj44FSMTr/BIK3zUcuMxmYdQ30x8PFQjOXfoAFqn7NRlcdD0RWQS5QY2c/oHq +xtYxhxGz27eDryVibvOXvj9rr8lE9T99iQfS5lo0+wKBgQD0ufl1IKUN3+uwhroW +0sldJRI2m1pJIyPXzt5NkUZTgm9vw1qgsH0dBszP5kuctFJ7M4s20re5gxkh5Shk +HKZqDvRmZYD7FF+IpMNogrWxRXTV3yk0WoP7ul2o56ZrgM/le2sgkC2Wh9IDR1B9 +W1wp2yuu3UouZLbcd9tvZADX3wKBgQDqfQ1fHpsrVqH5BQCaFPoJQ3pa8j1dsqsV +p3VVmk4LbmgRZyqx2zdot4YsnktUgnIrp7G4mi+WyZUFDQtastVm2PtfiFFml7th +Yr1qgStn/S6SHG137xbgFc9ISDqaqcBRtbufL9vPBseR9Gl1x6J/exsGz2Y3GdLq +mGKN3kXZNwKBgFYwPUvj8F1flFk6ScWJ/QMB8FUtB3IknxX9NEurM1Jr6KU67usS +3S1g3LcHi2+oDjh3obrwIWaDZlGKrFv9vxoxJB//9Zn3xeGQ7YUcK7NAG/LKwm8+ +xabdLukylGjeF9nhSoxQWs3eDbe74PwVNfNDzjGqm9qU+9XPnIexd56PAoGBAI8Z +r95btEB3hzOMPPjLUmfy2SnFaXPUBJqbbnzGRViukS4FssWFzcPHNEfodnplfT1t +AoVw+xfff2TsuAAq4Rb51jZP37VnZcAh3QyxNYcpuJEMbjXiICiyxqQLCFC4xZhX ++t9D+rXWIlbNPMrXATEhNLYsPenkhZYLVyHTAvdzAoGBAPPHGBN9DW7gFoHrxLf6 +J1kLcjFOxwNDXnMk9LMzyFYmvn7S/YXOIzkgGzH+dwnwigwCqi0hxkOfoGHF1cXM +k9N5tB6G4nnHPFi/MOVmyiRw3RiF2J1CU1u2xUN+ddIRvgpsvYI1dl3PjyKuStVA +45AxheULl0CfRqo7YhUG5E0l +-----END PRIVATE KEY----- + +-----BEGIN CERTIFICATE----- +MIICljCCAjygAwIBAgIUJ20vLiEqNjiXSIvmoLlBvOg2utMwCgYIKoZIzj0EAwIw +LDEqMCgGA1UEAxMhU2FtaSBIb21lIE5ldHdvcmsgSW50ZXJtZWRpYXRlIENBMB4X +DTI2MDIxMTIxMzgwNloXDTI3MDIxMTIxMzgwNlowFDESMBAGA1UEAwwJZG5zMy5z +YW1pMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4CmJnD34danEr45b +JayyM87UgvRjbsJJHYRWgp5sRjmNKr174VtzQ+1deXOe0kWyfjnSqI/Em2mJgGDR +4S2bhBEX5MiXqEHQATws5oHoXPuGmbnm0Y0B9ePU1Xz+ix0tz3vOHc6z62XWZquF +HE7TEhzM9OlMj/5sMPKxR4FHSDLWIXF6NUknBM6wX7uyJEcoXnf2vYm5ygqd/1vu +ncWBeybKFS4hsnEIsp1wcAAw1GH9x7fTyOn/7SLzTk7S6FkLt+upHCXqkYPUqNfG +sRFcmuBZr1RXRjxilgFJ9VQ4NEAvHRdXQmaCsGh3Ah6JioOjlpLJxcAPgyYeAoPz +xEVn6QIDAQABo4GIMIGFMAsGA1UdDwQEAwIEsDATBgNVHSUEDDAKBggrBgEFBQcD +ATAhBgNVHREEGjAYgglkbnMzLnNhbWmCCyouZG5zMy5zYW1pMB0GA1UdDgQWBBTC +Nw7MUUA4YxBFua8YzXZ2ciBmbzAfBgNVHSMEGDAWgBRvN+rmvteWGd3Gj1ek/5lJ +Wq5MXzAKBggqhkjOPQQDAgNIADBFAiBdq9UEw1H+/sidEXaf6D6d3z/YymJLNycN +xP4afkkExgIhAOV44DZsuGBckiIOJfNWpwbcA92GTXg/XlcALEcOtX5g +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/generated-certs/dns3.sami/server.pfx b/dashcaddy-api/generated-certs/dns3.sami/server.pfx new file mode 100644 index 0000000..8858fe9 Binary files /dev/null and b/dashcaddy-api/generated-certs/dns3.sami/server.pfx differ diff --git a/dashcaddy-api/logger-utils.js b/dashcaddy-api/logger-utils.js deleted file mode 100644 index 30beae1..0000000 --- a/dashcaddy-api/logger-utils.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Logger Utilities - Sanitize sensitive data before logging - * Created: 2026-03-21 - * Purpose: Prevent credential/token/password leakage in logs - */ - -/** - * List of sensitive field names that should be redacted - */ -const SENSITIVE_FIELDS = [ - 'password', - 'passwd', - 'pwd', - 'secret', - 'token', - 'apiKey', - 'api_key', - 'apikey', - 'auth', - 'authorization', - 'bearer', - 'credential', - 'credentials', - 'key', - 'privateKey', - 'private_key', - 'accessToken', - 'access_token', - 'refreshToken', - 'refresh_token', - 'sessionId', - 'session_id', - 'cookie', - 'cookies', - 'cert', - 'certificate', - 'masterKey', - 'master_key', - 'encryptionKey', - 'encryption_key' -]; - -/** - * Recursively sanitize an object by redacting sensitive fields - * @param {any} data - Data to sanitize - * @param {Array} additionalSensitiveKeys - Additional field names to redact - * @returns {any} Sanitized copy of the data - */ -function sanitizeForLog(data, additionalSensitiveKeys = []) { - // Handle null/undefined - if (data === null || data === undefined) { - return data; - } - - // Handle primitives - if (typeof data !== 'object') { - return data; - } - - // Handle arrays - if (Array.isArray(data)) { - return data.map(item => sanitizeForLog(item, additionalSensitiveKeys)); - } - - // Handle objects - const sensitiveKeys = [...SENSITIVE_FIELDS, ...additionalSensitiveKeys]; - const sanitized = {}; - - for (const [key, value] of Object.entries(data)) { - const lowerKey = key.toLowerCase(); - const isSensitive = sensitiveKeys.some(sk => lowerKey.includes(sk.toLowerCase())); - - if (isSensitive) { - // Redact sensitive fields - sanitized[key] = '[REDACTED]'; - } else if (value && typeof value === 'object') { - // Recursively sanitize nested objects - sanitized[key] = sanitizeForLog(value, additionalSensitiveKeys); - } else { - sanitized[key] = value; - } - } - - return sanitized; -} - -/** - * Redact a credential value for logging (show first/last 4 chars only) - * @param {string} value - Credential value - * @returns {string} Partially redacted value (e.g., "abcd****xyz") - */ -function redactCredential(value) { - if (!value || typeof value !== 'string') { - return '[REDACTED]'; - } - - if (value.length <= 8) { - return '[REDACTED]'; - } - - const start = value.slice(0, 4); - const end = value.slice(-4); - const middle = '*'.repeat(Math.min(value.length - 8, 10)); - - return `${start}${middle}${end}`; -} - -/** - * Create a safe log message object (strips sensitive data) - * @param {string} message - Log message - * @param {object} data - Data to log - * @param {Array} additionalSensitiveKeys - Additional field names to redact - * @returns {object} Safe log object - */ -function safeLog(message, data = {}, additionalSensitiveKeys = []) { - return { - message, - data: sanitizeForLog(data, additionalSensitiveKeys), - timestamp: new Date().toISOString() - }; -} - -module.exports = { - sanitizeForLog, - redactCredential, - safeLog, - SENSITIVE_FIELDS -}; diff --git a/dashcaddy-api/package-lock.json b/dashcaddy-api/package-lock.json index 485f768..b8943c5 100644 --- a/dashcaddy-api/package-lock.json +++ b/dashcaddy-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "dashcaddy-api", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dashcaddy-api", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "compression": "^1.8.1", "cors": "^2.8.6", @@ -24,7 +24,9 @@ "validator": "^13.11.0" }, "devDependencies": { + "eslint": "^8.57.1", "jest": "^29.7.0", + "prettier": "^3.8.1", "supertest": "^6.3.4" } }, @@ -59,7 +61,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -551,6 +552,89 @@ "tslib": "^2.4.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -600,6 +684,44 @@ "node": ">=6" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -1353,6 +1475,44 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@otplib/core": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", @@ -1619,6 +1779,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1632,6 +1799,46 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1981,7 +2188,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2502,6 +2708,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2620,6 +2833,19 @@ "node": ">= 8.0" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2774,6 +3000,193 @@ "node": ">=8" } }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -2788,6 +3201,52 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2923,6 +3382,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2930,6 +3396,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -2937,6 +3410,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2947,6 +3430,19 @@ "bser": "2.1.1" } }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3006,6 +3502,28 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -3195,6 +3713,48 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3213,6 +3773,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3341,6 +3908,43 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -3421,6 +4025,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3440,6 +4054,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3450,6 +4077,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4184,6 +4821,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -4191,6 +4835,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4259,6 +4917,16 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -4279,6 +4947,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4340,6 +5022,13 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -4658,6 +5347,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/otplib": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", @@ -4721,6 +5428,19 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -4866,6 +5586,32 @@ "node": ">=12.13.0" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -4966,6 +5712,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -5090,6 +5846,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -5213,6 +5990,58 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5787,6 +6616,13 @@ "node": ">=8" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/thirty-two": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", @@ -5837,6 +6673,19 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -5919,6 +6768,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6012,6 +6871,16 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index e5598fb..f380ab2 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -7,7 +7,10 @@ "start": "node server.js", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write '**/*.{js,json,md}'" }, "dependencies": { "compression": "^1.8.1", @@ -26,7 +29,9 @@ "validator": "^13.11.0" }, "devDependencies": { + "eslint": "^8.57.1", "jest": "^29.7.0", + "prettier": "^3.8.1", "supertest": "^6.3.4" } } diff --git a/dashcaddy-api/pki/intermediate.crt b/dashcaddy-api/pki/intermediate.crt new file mode 100644 index 0000000..d992ac2 --- /dev/null +++ b/dashcaddy-api/pki/intermediate.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx +MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr +IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8 +bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J +N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ +AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1 +JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp +OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy +HjpIXBIn9ix7 +-----END CERTIFICATE----- diff --git a/dashcaddy-api/pki/intermediate.key b/dashcaddy-api/pki/intermediate.key new file mode 100644 index 0000000..c0ff910 --- /dev/null +++ b/dashcaddy-api/pki/intermediate.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIOKHTAa6a6M/FCVOQ+sUzRe0uzHWCmlHx8pIzOtg2YZxoAoGCCqGSM49 +AwEHoUQDQgAEvdcwdLxtsaAewaiM9YiAOocfrmfGb/K9+sD/Dmtd7VA1nShB9d2F +Q4O67WhON1CIZI7Gnkk3v6vvcMgiMOg7VA== +-----END EC PRIVATE KEY----- diff --git a/dashcaddy-api/pki/root.crt b/dashcaddy-api/pki/root.crt new file mode 100644 index 0000000..97a4ce4 --- /dev/null +++ b/dashcaddy-api/pki/root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi +MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0 +NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr +IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk +wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu +49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV +HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5 +d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C +yam9Y42t1K8Fx5q5iy+bs8w= +-----END CERTIFICATE----- diff --git a/dashcaddy-api/pki/root.key b/dashcaddy-api/pki/root.key new file mode 100644 index 0000000..45aca90 --- /dev/null +++ b/dashcaddy-api/pki/root.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDbA5DxGcb/sbvBD+Hoijk2+90EQurCJSXbJwvh7h+P3oAoGCCqGSM49 +AwEHoUQDQgAE7PCuYb4e6gu+5HRYJMMiLukr8xLayuNepS5EJAvhO/cSMBkSrGe0 +/4HftAZNDTFGQcS8MOBdkZf5Ld3hruPa0g== +-----END EC PRIVATE KEY----- diff --git a/dashcaddy-api/scripts/build-release.sh b/dashcaddy-api/scripts/build-release.sh deleted file mode 100644 index 1c0eda3..0000000 --- a/dashcaddy-api/scripts/build-release.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -# DashCaddy Release Builder -# Triggered by Gitea webhook on push to main. -# Clones repo, builds tarball, writes version.json, deploys to web root. - -set -euo pipefail - -readonly REPO_URL="http://sami7777:2728bb667201841b08cb35ac101ffe52838f7d11@100.98.123.59:3000/sami7777/dashcaddy.git" -readonly RELEASE_DIR="/var/www/get.dashcaddy.net/release" -readonly BUILD_DIR="/tmp/dashcaddy-build-$$" -readonly MIRROR_HOST="root@100.98.123.59" # Contabo DE -readonly BRANCH="main" - -log() { echo "[build-release] $(date '+%Y-%m-%d %H:%M:%S') $*"; } - -cleanup() { rm -rf "$BUILD_DIR"; } -trap cleanup EXIT - -main() { - log "=== Starting release build ===" - - # 1. Clone latest - mkdir -p "$BUILD_DIR" - log "Cloning ${BRANCH}..." - git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$BUILD_DIR/repo" 2>&1 - cd "$BUILD_DIR/repo" - - local commit - commit=$(git rev-parse --short HEAD) - log "Commit: ${commit}" - - # 2. Read version from package.json - local version - version=$(python3 -c "import json; print(json.load(open('dashcaddy-api/package.json'))['version'])") - log "Version: ${version}" - - # 3. Build changelog (last 10 commits, one-liner) - local changelog - changelog=$(git log --oneline -10 --no-decorate 2>/dev/null || echo "${commit} (no log)") - - # 4. Assemble tarball contents - local staging="$BUILD_DIR/dashcaddy" - mkdir -p "$staging/dashcaddy-api/routes" "$staging/status" "$staging/scripts" - - # API files - cp -f dashcaddy-api/*.js "$staging/dashcaddy-api/" 2>/dev/null || true - cp -rf dashcaddy-api/routes/* "$staging/dashcaddy-api/routes/" 2>/dev/null || true - cp -f dashcaddy-api/package.json "$staging/dashcaddy-api/" - cp -f dashcaddy-api/package-lock.json "$staging/dashcaddy-api/" 2>/dev/null || true - cp -f dashcaddy-api/Dockerfile "$staging/dashcaddy-api/" - cp -f dashcaddy-api/openapi.yaml "$staging/dashcaddy-api/" 2>/dev/null || true - - # Dashboard files - cp -f status/index.html "$staging/status/" - cp -f status/sw.js "$staging/status/" 2>/dev/null || true - for dir in css js dist vendor assets; do - [ -d "status/${dir}" ] && cp -rf "status/${dir}" "$staging/status/" - done - - # Updater scripts - cp -f dashcaddy-api/scripts/dashcaddy-update.sh "$staging/scripts/" 2>/dev/null || true - cp -f dashcaddy-api/scripts/dashcaddy-updater.path "$staging/scripts/" 2>/dev/null || true - cp -f dashcaddy-api/scripts/dashcaddy-updater.service "$staging/scripts/" 2>/dev/null || true - - # 5. Create tarball - local tarball="dashcaddy-${version}.tar.gz" - cd "$BUILD_DIR" - tar czf "$tarball" dashcaddy/ - log "Tarball: ${tarball} ($(du -h "$tarball" | cut -f1))" - - # 6. Compute SHA-256 - local sha256 - sha256=$(sha256sum "$tarball" | cut -d' ' -f1) - log "SHA-256: ${sha256}" - - # 7. Write version.json - cat > version.json </dev/null; then - log "Syncing to mirror..." - rsync -az --timeout=30 "$RELEASE_DIR/" "$MIRROR_HOST:/var/www/get2.dashcaddy.net/release/" 2>&1 || { - log "WARNING: Mirror sync failed (non-fatal)" - } - log "Mirror synced" - else - log "WARNING: Mirror host unreachable, skipping sync" - fi - - log "=== Release build complete: v${version} (${commit}) ===" -} - -main "$@" diff --git a/dashcaddy-api/scripts/dashcaddy-update.sh b/dashcaddy-api/scripts/dashcaddy-update.sh deleted file mode 100644 index 29ab64f..0000000 --- a/dashcaddy-api/scripts/dashcaddy-update.sh +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env bash -# DashCaddy Host-Side Updater -# Triggered by systemd path unit when the API container writes trigger.json. -# Handles API container rebuild + restart with automatic rollback on failure. - -set -euo pipefail - -readonly UPDATES_DIR="/opt/dashcaddy/updates" -readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json" -readonly RESULT_FILE="${UPDATES_DIR}/result.json" -readonly BACKUP_BASE="${UPDATES_DIR}/backups" -readonly HEALTH_URL="http://localhost:3001/health" -readonly HEALTH_TIMEOUT=60 -readonly MAX_BACKUPS=3 - -log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; } - -write_result() { - local success="$1" message="$2" version="$3" duration="$4" - cat > "$RESULT_FILE" </dev/null || true - cp -rf "${api_dir}/routes/"* "$backup_dir/routes/" 2>/dev/null || true - cp -f "${api_dir}/package.json" "$backup_dir/" 2>/dev/null || true - cp -f "${api_dir}/package-lock.json" "$backup_dir/" 2>/dev/null || true - cp -f "${api_dir}/Dockerfile" "$backup_dir/" 2>/dev/null || true - cp -f "${api_dir}/openapi.yaml" "$backup_dir/" 2>/dev/null || true - - log "Backed up version ${version} to ${backup_dir}" -} - -restore_backup() { - local api_dir="$1" version="$2" - local backup_dir="${BACKUP_BASE}/${version}" - - if [ ! -d "$backup_dir" ]; then - log "ERROR: No backup found for version ${version}" - return 1 - fi - - cp -f "${backup_dir}"/*.js "$api_dir/" 2>/dev/null || true - cp -rf "${backup_dir}/routes/"* "${api_dir}/routes/" 2>/dev/null || true - cp -f "${backup_dir}/package.json" "$api_dir/" 2>/dev/null || true - cp -f "${backup_dir}/package-lock.json" "$api_dir/" 2>/dev/null || true - cp -f "${backup_dir}/Dockerfile" "$api_dir/" 2>/dev/null || true - cp -f "${backup_dir}/openapi.yaml" "$api_dir/" 2>/dev/null || true - - log "Restored version ${version} from ${backup_dir}" -} - -copy_new_files() { - local staging_dir="$1" api_dir="$2" - - cp -f "${staging_dir}"/*.js "$api_dir/" 2>/dev/null || true - [ -d "${staging_dir}/routes" ] && cp -rf "${staging_dir}/routes/"* "${api_dir}/routes/" 2>/dev/null || true - cp -f "${staging_dir}/package.json" "$api_dir/" 2>/dev/null || true - cp -f "${staging_dir}/package-lock.json" "$api_dir/" 2>/dev/null || true - cp -f "${staging_dir}/Dockerfile" "$api_dir/" 2>/dev/null || true - cp -f "${staging_dir}/openapi.yaml" "$api_dir/" 2>/dev/null || true - - log "Copied new files from ${staging_dir} to ${api_dir}" -} - -wait_for_health() { - local attempt=0 - local max_attempts=$((HEALTH_TIMEOUT / 2)) - - while (( attempt < max_attempts )); do - if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then - log "Health check passed (attempt $((attempt+1)))" - return 0 - fi - sleep 2 - attempt=$((attempt + 1)) - done - - log "Health check FAILED after ${HEALTH_TIMEOUT}s" - return 1 -} - -find_compose_dir() { - # Find the docker-compose.yml for dashcaddy-api - for dir in /etc/dashcaddy/sites/dashcaddy-api /etc/dashcaddy/sites/caddy-api; do - if [ -f "${dir}/docker-compose.yml" ] || [ -f "${dir}/docker-compose.yaml" ]; then - echo "$dir" - return 0 - fi - done - # Fallback: same as api source - echo "$1" -} - -cleanup_old_backups() { - if [ ! -d "$BACKUP_BASE" ]; then return; fi - local count - count=$(ls -1d "${BACKUP_BASE}"/*/ 2>/dev/null | wc -l) - if (( count > MAX_BACKUPS )); then - local to_remove=$((count - MAX_BACKUPS)) - ls -1d "${BACKUP_BASE}"/*/ 2>/dev/null | head -n "$to_remove" | while read -r dir; do - rm -rf "$dir" - log "Cleaned old backup: $dir" - done - fi -} - -main() { - if [ ! -f "$TRIGGER_FILE" ]; then - log "No trigger file found, exiting" - exit 0 - fi - - local start_time - start_time=$(date +%s) - - # Parse trigger file - local action version from_version staging_dir api_dir commit - action=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('action','update'))" 2>/dev/null || echo "update") - version=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('version','unknown'))" 2>/dev/null || echo "unknown") - from_version=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('fromVersion','unknown'))" 2>/dev/null || echo "unknown") - staging_dir=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('stagingDir',''))" 2>/dev/null || echo "") - api_dir=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('apiSourceDir','/opt/dashcaddy'))" 2>/dev/null || echo "/opt/dashcaddy") - commit=$(python3 -c "import json,sys; d=json.load(open('$TRIGGER_FILE')); print(d.get('commit','unknown'))" 2>/dev/null || echo "unknown") - - log "=== DashCaddy ${action} started: ${from_version} → ${version} (${commit}) ===" - - if [ -z "$staging_dir" ] || [ ! -d "$staging_dir" ]; then - log "ERROR: Staging directory not found: ${staging_dir}" - write_result "false" "Staging directory not found" "$version" "0" - rm -f "$TRIGGER_FILE" - exit 1 - fi - - local compose_dir - compose_dir=$(find_compose_dir "$api_dir") - - # Step 1: Backup current version - log "Step 1: Backing up current version (${from_version})" - backup_current "$api_dir" "$from_version" - - # Step 2: Copy new files - log "Step 2: Copying new files" - copy_new_files "$staging_dir" "$api_dir" - - # Write commit hash to VERSION file - echo "$commit" > "${api_dir}/VERSION" - - # Step 3: Rebuild container - log "Step 3: Building new container image" - cd "$compose_dir" - if ! docker compose build --build-arg DASHCADDY_COMMIT="$commit" --quiet 2>&1; then - log "ERROR: docker compose build failed, rolling back" - restore_backup "$api_dir" "$from_version" - local elapsed=$(( $(date +%s) - start_time )) - write_result "false" "Build failed, rolled back to ${from_version}" "$version" "$((elapsed * 1000))" - rm -f "$TRIGGER_FILE" - exit 1 - fi - - # Step 4: Restart container - log "Step 4: Restarting container" - if ! docker compose up -d 2>&1; then - log "ERROR: docker compose up failed, rolling back" - restore_backup "$api_dir" "$from_version" - docker compose build --quiet 2>&1 || true - docker compose up -d 2>&1 || true - local elapsed=$(( $(date +%s) - start_time )) - write_result "false" "Container start failed, rolled back to ${from_version}" "$version" "$((elapsed * 1000))" - rm -f "$TRIGGER_FILE" - exit 1 - fi - - # Step 5: Health check - log "Step 5: Waiting for health check (${HEALTH_TIMEOUT}s timeout)" - if wait_for_health; then - local elapsed=$(( $(date +%s) - start_time )) - log "=== Update to ${version} SUCCESSFUL (${elapsed}s) ===" - write_result "true" "Update successful" "$version" "$((elapsed * 1000))" - else - log "Health check failed — ROLLING BACK to ${from_version}" - restore_backup "$api_dir" "$from_version" - cd "$compose_dir" - docker compose build --quiet 2>&1 || true - docker compose up -d 2>&1 || true - - if wait_for_health; then - local elapsed=$(( $(date +%s) - start_time )) - log "Rollback to ${from_version} succeeded" - write_result "false" "Health check failed after update. Rolled back to ${from_version}." "$version" "$((elapsed * 1000))" - else - local elapsed=$(( $(date +%s) - start_time )) - log "CRITICAL: Rollback also failed. Manual intervention required." - write_result "false" "CRITICAL: Both update and rollback failed. Manual intervention required." "$version" "$((elapsed * 1000))" - fi - fi - - # Cleanup - rm -f "$TRIGGER_FILE" - rm -rf "${UPDATES_DIR}/staging" - cleanup_old_backups - - log "Update process complete" -} - -main "$@" diff --git a/dashcaddy-api/scripts/dashcaddy-updater.path b/dashcaddy-api/scripts/dashcaddy-updater.path deleted file mode 100644 index e65498b..0000000 --- a/dashcaddy-api/scripts/dashcaddy-updater.path +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Watch for DashCaddy update trigger -Documentation=https://dashcaddy.net - -[Path] -PathChanged=/opt/dashcaddy/updates/trigger.json - -[Install] -WantedBy=multi-user.target diff --git a/dashcaddy-api/scripts/dashcaddy-updater.service b/dashcaddy-api/scripts/dashcaddy-updater.service deleted file mode 100644 index d554574..0000000 --- a/dashcaddy-api/scripts/dashcaddy-updater.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=DashCaddy auto-update handler -Documentation=https://dashcaddy.net -After=docker.service -Requires=docker.service - -[Service] -Type=oneshot -ExecStart=/opt/dashcaddy/scripts/dashcaddy-update.sh -TimeoutStartSec=300 -StandardOutput=journal -StandardError=journal -SyslogIdentifier=dashcaddy-update diff --git a/dashcaddy-api/scripts/install-ca.ps1.template b/dashcaddy-api/scripts/install-ca.ps1.template deleted file mode 100644 index bfb74ec..0000000 --- a/dashcaddy-api/scripts/install-ca.ps1.template +++ /dev/null @@ -1,120 +0,0 @@ -#Requires -RunAsAdministrator - -<# -.SYNOPSIS - Installs the DashCaddy Root CA certificate to the Trusted Root Certification Authorities store. - -.DESCRIPTION - This script downloads the root CA certificate from your DashCaddy instance, verifies its fingerprint, - and installs it to the local machine's trusted root store. -#> - -$ErrorActionPreference = "Stop" - -# ========================================== -# CONFIGURATION (Injected by DashCaddy API) -# ========================================== -$CertUrl = "{{CERT_URL}}" -$ExpectedFingerprint = "{{CERT_FINGERPRINT}}" -# ========================================== - -$TempFile = "$env:TEMP\dashcaddy-root-ca.crt" - -# Colors -$Red = [System.ConsoleColor]::Red -$Green = [System.ConsoleColor]::Green -$Cyan = [System.ConsoleColor]::Cyan -$Yellow = [System.ConsoleColor]::Yellow - -Write-Host "" -Write-Host "========================================" -ForegroundColor $Cyan -Write-Host " DashCaddy Certificate Installer" -ForegroundColor $Cyan -Write-Host "========================================" -ForegroundColor $Cyan -Write-Host "" - -# Step 1: Download certificate -Write-Host "[1/4] Downloading certificate..." -ForegroundColor $Cyan -try { - $ProgressPreference = 'SilentlyContinue' - - # Bypass SSL validation — the user doesn't trust the CA yet, that's the whole point - if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy').Type) { - Add-Type @" -using System.Net; -using System.Security.Cryptography.X509Certificates; -public class TrustAllCertsPolicy : ICertificatePolicy { - public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; } -} -"@ - } - [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy - - Invoke-WebRequest -Uri $CertUrl -OutFile $TempFile -UseBasicParsing -ErrorAction Stop - Write-Host " OK Certificate downloaded" -ForegroundColor $Green -} catch { - Write-Host " FAIL Failed to download certificate from $CertUrl" -ForegroundColor $Red - Write-Host " Error: $_" -ForegroundColor $Red - exit 1 -} - -# Step 2: Verify fingerprint -Write-Host "[2/4] Verifying certificate fingerprint..." -ForegroundColor $Cyan -try { - $Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($TempFile) - $Fingerprint = $Cert.Thumbprint - - $NormalizedExpected = $ExpectedFingerprint -replace '[:\s]', '' - $NormalizedActual = $Fingerprint -replace '[:\s]', '' - - if ($NormalizedActual -ne $NormalizedExpected) { - Write-Host " FAIL Fingerprint mismatch!" -ForegroundColor $Red - Write-Host " Expected: $ExpectedFingerprint" -ForegroundColor $Yellow - Write-Host " Got: $Fingerprint" -ForegroundColor $Red - Remove-Item $TempFile -Force - Write-Host "" - Write-Host "SECURITY WARNING: The downloaded certificate does not match the expected fingerprint." -ForegroundColor $Red - exit 1 - } - Write-Host " OK Fingerprint verified" -ForegroundColor $Green -} catch { - Write-Host " FAIL Failed to verify fingerprint: $_" -ForegroundColor $Red - Remove-Item $TempFile -Force -ErrorAction SilentlyContinue - exit 1 -} - -# Step 3: Check if already installed -Write-Host "[3/4] Checking for existing certificate..." -ForegroundColor $Cyan -$ExistingCert = Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Fingerprint } -if ($ExistingCert) { - Write-Host " INFO Certificate already installed" -ForegroundColor $Yellow - Remove-Item $TempFile -Force - Write-Host "" - Write-Host "The DashCaddy Root CA is already trusted on this system." -ForegroundColor $Green - Start-Sleep -Seconds 3 - exit 0 -} -Write-Host " OK Not yet installed, proceeding..." -ForegroundColor $Green - -# Step 4: Install certificate -Write-Host "[4/4] Installing to Trusted Root store..." -ForegroundColor $Cyan -try { - $ImportedCert = Import-Certificate -FilePath $TempFile -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction Stop - Write-Host " OK Certificate installed successfully" -ForegroundColor $Green -} catch { - Write-Host " FAIL Failed to install certificate. Ensure you are running as Administrator." -ForegroundColor $Red - Remove-Item $TempFile -Force -ErrorAction SilentlyContinue - exit 1 -} - -# Cleanup -Remove-Item $TempFile -Force -ErrorAction SilentlyContinue - -Write-Host "" -Write-Host "========================================" -ForegroundColor $Green -Write-Host " SUCCESS!" -ForegroundColor $Green -Write-Host "========================================" -ForegroundColor $Green -Write-Host "" -Write-Host "Your browser will now trust DashCaddy apps." -ForegroundColor $Green -Write-Host "You may need to restart your browser for changes to take effect." -ForegroundColor $Yellow -Write-Host "" -Start-Sleep -Seconds 3 diff --git a/dashcaddy-api/scripts/install-ca.sh.template b/dashcaddy-api/scripts/install-ca.sh.template deleted file mode 100644 index 06f2b92..0000000 --- a/dashcaddy-api/scripts/install-ca.sh.template +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ========================================== -# CONFIGURATION (Injected by DashCaddy API) -# ========================================== -CERT_URL="{{CERT_URL}}" -EXPECTED_FP="{{CERT_FINGERPRINT}}" -# ========================================== - -TMPFILE="$(mktemp /tmp/dashcaddy-root-ca.XXXXXX.crt)" - -RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'; NC='\033[0m' - -echo "" -echo -e "${CYAN}========================================" -echo " DashCaddy Certificate Installer" -echo -e "========================================${NC}" -echo "" - -# Step 1: Download certificate (skip TLS verification — we verify the fingerprint instead) -echo -e "${CYAN}[1/4] Downloading certificate...${NC}" -if command -v curl &>/dev/null; then - curl -fsSk -o "$TMPFILE" "$CERT_URL" -elif command -v wget &>/dev/null; then - wget -q --no-check-certificate -O "$TMPFILE" "$CERT_URL" -else - echo -e "${RED} FAIL Neither curl nor wget found${NC}" - exit 1 -fi -echo -e "${GREEN} OK Certificate downloaded${NC}" - -# Step 2: Verify fingerprint -echo -e "${CYAN}[2/4] Verifying certificate fingerprint...${NC}" -ACTUAL_FP=$(openssl x509 -in "$TMPFILE" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//; s/://g') -CLEAN_EXPECTED=$(echo "$EXPECTED_FP" | tr -d ': ') - -if [ "$ACTUAL_FP" != "$CLEAN_EXPECTED" ]; then - echo -e "${RED} FAIL Fingerprint mismatch!${NC}" - echo -e "${YELLOW} Expected: $EXPECTED_FP${NC}" - echo -e "${RED} Got: $ACTUAL_FP${NC}" - rm -f "$TMPFILE" - echo -e "${RED}SECURITY WARNING: Certificate does not match expected fingerprint.${NC}" - exit 1 -fi -echo -e "${GREEN} OK Fingerprint verified${NC}" - -# Step 3: Detect OS and install -echo -e "${CYAN}[3/4] Installing certificate...${NC}" - -install_debian() { - sudo cp "$TMPFILE" /usr/local/share/ca-certificates/dashcaddy-root-ca.crt - sudo update-ca-certificates -} - -install_redhat() { - sudo cp "$TMPFILE" /etc/pki/ca-trust/source/anchors/dashcaddy-root-ca.crt - sudo update-ca-trust extract -} - -install_arch() { - sudo cp "$TMPFILE" /etc/ca-certificates/trust-source/anchors/dashcaddy-root-ca.crt - sudo trust extract-compat -} - -install_macos() { - sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$TMPFILE" -} - -if [ "$(uname)" = "Darwin" ]; then - install_macos -elif [ -f /etc/debian_version ]; then - install_debian -elif [ -f /etc/redhat-release ]; then - install_redhat -elif [ -f /etc/arch-release ]; then - install_arch -elif command -v update-ca-certificates &>/dev/null; then - install_debian -elif command -v update-ca-trust &>/dev/null; then - install_redhat -else - echo -e "${RED} FAIL Could not detect package manager. Install manually:${NC}" - echo " Copy $TMPFILE to your system's CA trust store" - exit 1 -fi -echo -e "${GREEN} OK Certificate installed${NC}" - -# Step 4: Cleanup -rm -f "$TMPFILE" - -echo "" -echo -e "${GREEN}========================================" -echo " SUCCESS!" -echo -e "========================================${NC}" -echo "" -echo -e "${GREEN}Your system now trusts the DashCaddy Root CA.${NC}" -echo -e "${YELLOW}Restart your browser for changes to take effect.${NC}" -echo "" diff --git a/dashcaddy-api/scripts/webhook-handler.js b/dashcaddy-api/scripts/webhook-handler.js deleted file mode 100644 index 2b14fd6..0000000 --- a/dashcaddy-api/scripts/webhook-handler.js +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env node -/** - * DashCaddy Release Webhook Handler - * Receives push webhooks from Gitea, verifies HMAC signature, - * and triggers build-release.sh. - * - * Usage: node webhook-handler.js - * Env vars: - * WEBHOOK_SECRET — Gitea webhook secret (required) - * WEBHOOK_PORT — Listen port (default: 9090) - * BUILD_SCRIPT — Path to build script (default: /opt/dashcaddy-release/build-release.sh) - */ - -const http = require('http'); -const crypto = require('crypto'); -const { spawn } = require('child_process'); -const fs = require('fs'); - -const PORT = parseInt(process.env.WEBHOOK_PORT || '9090', 10); -const SECRET = process.env.WEBHOOK_SECRET; -const BUILD_SCRIPT = process.env.BUILD_SCRIPT || '/opt/dashcaddy-release/build-release.sh'; -const LOG_FILE = '/var/log/dashcaddy-release.log'; - -if (!SECRET) { - console.error('WEBHOOK_SECRET environment variable is required'); - process.exit(1); -} - -let buildRunning = false; - -function log(msg) { - const line = `[webhook] ${new Date().toISOString()} ${msg}`; - console.log(line); - fs.appendFileSync(LOG_FILE, line + '\n'); -} - -function verifySignature(body, signature) { - if (!signature) return false; - const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex'); - return crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(hmac) - ); -} - -function triggerBuild() { - if (buildRunning) { - log('Build already in progress, skipping'); - return; - } - buildRunning = true; - log('Triggering build...'); - - const child = spawn('bash', [BUILD_SCRIPT], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, PATH: process.env.PATH }, - }); - - child.stdout.on('data', (data) => { - const lines = data.toString().trim().split('\n'); - lines.forEach(line => log(`[build] ${line}`)); - }); - - child.stderr.on('data', (data) => { - const lines = data.toString().trim().split('\n'); - lines.forEach(line => log(`[build:err] ${line}`)); - }); - - child.on('close', (code) => { - buildRunning = false; - if (code === 0) { - log('Build completed successfully'); - } else { - log(`Build FAILED with exit code ${code}`); - } - }); -} - -const server = http.createServer((req, res) => { - // Health check - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', buildRunning })); - return; - } - - // Only accept POST to /webhook - if (req.method !== 'POST' || req.url !== '/webhook') { - res.writeHead(404); - res.end('Not found'); - return; - } - - let body = ''; - req.on('data', chunk => { body += chunk; }); - req.on('end', () => { - // Verify Gitea HMAC signature - const sig = req.headers['x-gitea-signature'] || ''; - if (!verifySignature(body, sig)) { - log('Signature verification FAILED'); - res.writeHead(403); - res.end('Invalid signature'); - return; - } - - try { - const payload = JSON.parse(body); - const ref = payload.ref || ''; - const branch = ref.replace('refs/heads/', ''); - - if (branch !== 'main') { - log(`Ignoring push to ${branch} (not main)`); - res.writeHead(200); - res.end('Ignored (not main branch)'); - return; - } - - const pusher = payload.pusher?.login || 'unknown'; - const commits = payload.commits?.length || 0; - log(`Push to main by ${pusher}: ${commits} commit(s)`); - - triggerBuild(); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ accepted: true })); - } catch (e) { - log('Failed to parse webhook payload: ' + e.message); - res.writeHead(400); - res.end('Invalid payload'); - } - }); -}); - -server.listen(PORT, '0.0.0.0', () => { - log(`Webhook handler listening on 127.0.0.1:${PORT}`); -}); diff --git a/deploy.ps1 b/deploy.ps1 index 554a5e5..8eaaa57 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -33,7 +33,21 @@ Write-Host " All files pass syntax check." -ForegroundColor Green # 2. Update Frontend Write-Host "Updating Dashboard UI..." -ForegroundColor Yellow if (Test-Path "$ProdRoot\sites\status") { + # Build frontend bundles + Write-Host "Building frontend JavaScript..." -ForegroundColor Yellow + Set-Location "$DevRoot\status" + & npm install + & node build.js + if ($LASTEXITCODE -ne 0) { + Write-Error "Frontend build failed!" + exit 1 + } + Write-Host " Frontend build complete." -ForegroundColor Green + + # Copy all necessary files Copy-Item "$DevRoot\status\index.html" "$ProdRoot\sites\status\index.html" -Force + Copy-Item "$DevRoot\status\dist\*" "$ProdRoot\sites\status\dist\" -Force + Set-Location $ProdRoot } else { Write-Warning "Target status folder not found. Skipping UI update." } diff --git a/status/dist/core.js b/status/dist/core.js new file mode 100644 index 0000000..f0a0a7f --- /dev/null +++ b/status/dist/core.js @@ -0,0 +1,727 @@ +const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS: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"}},_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 p=await fetch("/api/v1/config");if(p.ok){const r=await p.json();if(r.tld&&(SITE.tld=r.tld.startsWith(".")?r.tld:"."+r.tld),r.dns&&(SITE.dnsIp=r.dns.ip||"",SITE.dnsPort=r.dns.port||DC.DEFAULTS.DNS_PORT),r.dnsServers&&typeof r.dnsServers=="object")for(const[f,t]of Object.entries(r.dnsServers))f!=="__proto__"&&f!=="constructor"&&f!=="prototype"&&(SITE.dnsServers[f]=t);r.configurationType&&(SITE.configurationType=r.configurationType),r.domain&&(SITE.domain=r.domain),r.defaults&&(SITE.defaults=r.defaults),r.routingMode&&(SITE.routingMode=r.routingMode),SITE.onboardingCompleted=r.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const E=document.getElementById("manage-tokens");E&&(E.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(p=>p.textContent=SITE.tld);const i=document.getElementById("edit-tld-suffix");i&&(i.textContent=SITE.tld);const g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.placeholder=SITE.dnsIp)})();function buildDomain(n){return n+SITE.tld}function buildServiceUrl(n){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+n:SITE.configurationType==="public"&&SITE.domain?"https://"+n+"."+SITE.domain:"https://"+buildDomain(n)}function getDnsServerAddr(n){const i=SITE.dnsServers[n];return i?`${i.ip}:${i.port}`:buildDomain(n)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[n,i]of Object.entries(SITE.dnsServers))if(i.ip===SITE.dnsIp)return n;return null}function renderDnsCards(){const n=document.querySelector(".top");if(!n)return;const i=Object.keys(SITE.dnsServers);if(!i.length)return;const g='',p=n.firstElementChild;i.forEach(r=>{const E=escapeHtml(r),f=escapeHtml((SITE.dnsServers[r].name||r).toUpperCase()),t=document.createElement("div");t.className="card",t.setAttribute("data-app",r),t.setAttribute("data-status","off"),t.innerHTML=`
${g}
${f}OFF
--
--
`,n.insertBefore(t,p)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const n=await fetch("/api/v1/csrf-token");if(!n.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await n.json()).token,csrfToken}catch(n){throw console.error("Failed to get CSRF token:",n),n}}async function secureFetch(n,i={}){const g=(i.method||"GET").toUpperCase();if(!["GET","HEAD","OPTIONS"].includes(g))try{const p=await getCSRFToken();i.headers={...i.headers,"X-CSRF-Token":p}}catch(p){console.error("Failed to add CSRF token to request:",p)}return i.signal||(i={...i,signal:AbortSignal.timeout(15e3)}),fetch(n,i)}async function postJSON(n,i){const g=await secureFetch(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),p=await g.json();if(!g.ok||p.success===!1)throw new Error(p.error||`Request failed (${g.status})`);return p}async function getJSON(n){const i=await secureFetch(n);if(!i.ok){let g=`Request failed (${i.status})`;try{g=(await i.json()).error||g}catch{}throw new Error(g)}return i.json()}async function deleteAPI(n){const i=await secureFetch(n,{method:"DELETE"}),g=await i.json();if(!i.ok||g.success===!1)throw new Error(g.error||`Delete failed (${i.status})`);return g}async function withButton(n,i,g,p={}){const r=n.innerHTML,{successText:E="\u2705",resetDelay:f=DC.DELAYS.BTN_RESET}=p;n.disabled=!0,n.innerHTML=i;try{const t=await g();return n.innerHTML=E,setTimeout(()=>{n.innerHTML=r,n.disabled=!1},f),t}catch(t){throw n.innerHTML=r,n.disabled=!1,t}}function openModal(n){document.getElementById(n)?.classList.add("show")}function closeModal(n){document.getElementById(n)?.classList.remove("show")}function wireModal(n,...i){n&&(n.addEventListener("click",g=>{g.target===n&&n.classList.remove("show")}),i.forEach(g=>g?.addEventListener("click",()=>n.classList.remove("show"))))}function showNotification(n,i="info",g=3e3){const p=document.querySelector(".deploy-notification");p&&p.remove();const r={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},E=r[i]||r.info,f=document.createElement("div");f.className="deploy-notification",f.textContent=n,f.style.cssText=` + position: fixed; top: 20px; right: 20px; + background: ${E.bg}; color: ${E.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(f),g>0&&setTimeout(()=>f.remove(),g)}function timeAgo(n){const i=Date.now()-new Date(n).getTime();return i<6e4?"just now":i<36e5?Math.floor(i/6e4)+"m ago":i<864e5?Math.floor(i/36e5)+"h ago":Math.floor(i/864e5)+"d ago"}function safeGet(n,i=null){try{const g=localStorage.getItem(n);return g!==null?g:i}catch{return i}}function safeSet(n,i){try{localStorage.setItem(n,i)}catch{}}function safeRemove(n){try{localStorage.removeItem(n)}catch{}}function safeSessionGet(n,i=null){try{const g=sessionStorage.getItem(n);return g!==null?g:i}catch{return i}}function safeSessionSet(n,i){try{sessionStorage.setItem(n,i)}catch{}}function safeGetJSON(n,i=null){try{const g=localStorage.getItem(n);return g?JSON.parse(g):i}catch{return i}}function escapeHtml(n){return String(n??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(n,i){document.getElementById(n)||document.body.insertAdjacentHTML("beforeend",i)}const DC_BUS={_handlers:{},on(n,i){var g;((g=this._handlers)[n]||(g[n]=[])).push(i)},off(n,i){this._handlers[n]=this._handlers[n]?.filter(g=>g!==i)},emit(n,i){this._handlers[n]?.forEach(g=>g(i))}},AppState={_apps:[],getApps(){return this._apps},setApps(n){this._apps=n,window.APPS=n,DC_BUS.emit("apps:changed",n)},findApp(n){return this._apps.find(i=>i.id===n)},addApp(n){this._apps.push(n),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(n){const i=this._apps.findIndex(g=>g.id===n);return i>-1&&(this._apps.splice(i,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),i>-1},updateApp(n,i){const g=this._apps.find(p=>p.id===n);if(g){for(const[p,r]of Object.entries(i))p!=="__proto__"&&p!=="constructor"&&p!=="prototype"&&(g[p]=r);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function n(){const p=document.createElement("div");return p.className="skeleton-card",p.innerHTML='
',p}function i(p){const r=document.getElementById("cards");if(!(!r||r.querySelector(".card"))){p=p||6;for(let E=0;E.4,P={};return P.hover=C?y(l,T,.35):y(l,$,.08),P["card-hover"]=y(l,P.hover,.5),P.base=y(T,l,.6),P["fg-muted"]=y(b,T,.35),P.success=S,P.error=x,P.warning=C?"#d68a00":"#f39c12",P}function d(h,T){var $=T.lightBg||T.bg&&k(T.bg)>.4,b=T.accent||T["accent-strong"]||"#888888",l=s(b);return $?":root."+h+` body { + background: + radial-gradient(1200px 800px at 10% -10%, rgba(`+l.r+","+l.g+","+l.b+`, .08), transparent 60%), + radial-gradient(1000px 700px at 110% 10%, rgba(`+l.r+","+l.g+","+l.b+`, .05), transparent 55%), + var(--bg); +} +`:":root."+h+` body { + background: + radial-gradient(1200px 900px at 8% -12%, rgba(`+l.r+","+l.g+","+l.b+`, .10), transparent 60%), + radial-gradient(1000px 700px at 110% -10%, rgba(`+l.r+","+l.g+","+l.b+`, .07), transparent 55%), + var(--bg); +} +`}function v(h,T){var $=T.lightBg||T.bg&&k(T.bg)>.4;return $?":root."+h+` 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."+h+` button:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} +`}function o(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function u(){E.forEach(function(h){document.documentElement.style.removeProperty("--"+h)})}function w(h,T){var $=h.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),p.indexOf($)!==-1&&($=$+"-custom");for(var b=safeGetJSON(i,{}),l=$,S=2;b[$]&&$!==T;)$=l+"-"+S++;return $}function a(h){var T=document.getElementById("user-theme-styles");T&&T.remove(),r.length=p.length,Object.keys(m).forEach(function(x){p.indexOf(x)===-1&&delete m[x]});var $=h||safeGetJSON(i,{}),b=Object.keys($);if(b=b.filter(function(x){return p.indexOf(x)===-1}),!!b.length){var l="";b.forEach(function(x){var C=$[x];r.indexOf(x)===-1&&r.push(x);var P={};E.forEach(function(D){C[D]&&(P[D]=C[D])}),P["card-bg"]=C["card-base"]||C.bg,C.lightBg&&(P.lightBg=!0);var O=e(P);t.forEach(function(D){!P[D]&&O[D]&&(P[D]=O[D])}),m[x]=P,l+=":root."+x+` { +`,E.forEach(function(D){P[D]&&(l+=" --"+D+": "+P[D]+`; +`)}),l+=`} +`,l+=d(x,P),l+=v(x,P)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=l,document.head.appendChild(S)}}function I(){secureFetch("/api/v1/themes").then(function(h){return h.json()}).then(function(h){if(!(!h.success||!h.themes)){var T=h.themes,$=safeGetJSON(i,{});if(JSON.stringify(T)!==JSON.stringify($)){safeSet(i,JSON.stringify(T)),a(T);var b=safeGet(n);b&&r.indexOf(b)!==-1&&L(b)}}}).catch(function(){})}function B(){var h=safeGetJSON(g);if(h){var T=h.name||"Custom",$=w(T),b={name:T};E.forEach(function(x){h[x]&&(b[x]=h[x])});var l=safeGetJSON(i,{});l[$]=b,safeSet(i,JSON.stringify(l)),safeGet(n)==="custom"&&safeSet(n,$),safeRemove(g);var S={};E.forEach(function(x){b[x]&&(S[x]=b[x])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,colors:S})}).catch(function(){})}}function L(h){document.documentElement.classList.add("theme-transitioning"),r.forEach(function(l){l!=="dark"&&document.documentElement.classList.remove(l)}),u(),h!=="dark"&&document.documentElement.classList.add(h),safeSet(n,h);var T=m[h],$=document.querySelector('meta[name="theme-color"]');$&&T&&$.setAttribute("content",T.bg);var b=T&&T.lightBg;!b&&T&&T.bg&&(b=k(T.bg)>.4),b?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}B(),a();var A=safeGet(n);A==="red"&&(A="black",safeSet(n,"black")),A&&A!=="dark"&&r.indexOf(A)===-1&&(A=null),L(A||o()),I(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(h){safeGet(n)||L(h.matches?"dark":"light")}),window.THEMES=r,window.BUILTIN_THEMES=p,window.THEME_COLORS=m,window.THEME_PROPS=E,window.BASE_PROPS=f,window.DERIVED_PROPS=t,window.USER_THEMES_KEY=i,window.applyTheme=L,window.clearCustomProperties=u,window.injectUserThemeStyles=a,window.syncThemesFromServer=I,window.slugifyThemeName=w,window.getActiveTheme=function(){return safeGet(n)||o()},window.deriveExtendedColors=e,window.hexToRgb=s,window.rgbToHex=c,window.blendColors=y})(),(function(){function n(){const f=document.querySelector(".totp-card");if(!f)return;const m=getComputedStyle(f).backgroundColor.match(/\d+/g);if(!m)return;const s=(.299*+m[0]+.587*+m[1]+.114*+m[2])/255,c=f.querySelector(".totp-logo-dark"),y=f.querySelector(".totp-logo-light");c&&(c.style.display=s>.5?"none":""),y&&(y.style.display=s>.5?"":"none")}function i(){const f=document.getElementById("totp-overlay");if(f){f.classList.add("show"),setTimeout(n,50);const t=f.querySelector(".totp-digits input");t&&setTimeout(()=>t.focus(),100)}}function g(){const f=document.getElementById("totp-overlay");f&&f.classList.remove("show")}const p=document.getElementById("totp-digits");if(p){const f=p.querySelectorAll("input");f.forEach((t,m)=>{t.addEventListener("input",s=>{const c=s.target.value.replace(/\D/g,"");s.target.value=c.slice(0,1),c&&mk.value).join("");y.length===6&&r(y)}),t.addEventListener("keydown",s=>{s.key==="Backspace"&&!s.target.value&&m>0&&(f[m-1].focus(),f[m-1].value="")}),t.addEventListener("paste",s=>{s.preventDefault();const c=(s.clipboardData.getData("text")||"").replace(/\D/g,"");c.length>=6&&(f.forEach((y,k)=>{y.value=c[k]||""}),f[5].focus(),r(c.slice(0,6)))})})}async function r(f){const t=document.getElementById("totp-error");t.textContent="Verifying...",t.className="totp-error verifying";try{const s=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();if(s.success){t.textContent="",g();const c=safeSessionGet("totp_redirect");if(c){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=c;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{t.textContent=s.error||"Invalid code",t.className="totp-error";const c=document.querySelectorAll("#totp-digits input");c.forEach(y=>{y.value=""}),c[0]?.focus()}}catch{t.textContent="Connection error",t.className="totp-error"}}const E=new URLSearchParams(window.location.search);if(E.get("auth")==="required"){const f=E.get("return");if(f)try{const t=new URL(f,window.location.origin),m=t.hostname,s=t.origin===window.location.origin,c=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,y=m.endsWith(c)||m===c.substring(1);(s||y)&&safeSessionSet("totp_redirect",f)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=i})(),(function(){injectModal("folder-browser-modal",`
+
+

\u{1F4C2} Browse for Media Folders

+ +
+ / +
+ +
+
Loading...
+
+ + + +
+ +
+ + +
+
+
+
`),injectModal("service-creds-modal",`
+
+

Service Credentials

+

Credentials are injected automatically when accessing this service.

+ + +
+ + No credentials stored +
+ + + + + + + + + + + +
+ + + +
+
+
`);const n=document.getElementById("service-creds-modal");let i=null;const g=["sonarr","radarr","prowlarr","overseerr"];window.openServiceCredsModal=async function(r){i=r;const E=document.getElementById("svc-creds-title"),f=document.getElementById("svc-creds-desc"),t=document.getElementById("svc-creds-seedhost"),m=document.getElementById("svc-creds-apikey"),s=document.getElementById("svc-creds-basic");E.textContent=r.name+" Credentials";const c=!!r.isExternal,y=g.includes(r.id)||g.includes(r.appTemplate);t.style.display=c?"":"none",m.style.display=y?"":"none",s.style.display=c?"none":"",c?(f.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${r.name}`):y?f.textContent="API key bypasses the app login screen automatically.":f.textContent="Credentials are injected automatically when accessing this service.",await p(r),n.classList.add("show")};async function p(r){const E=document.getElementById("svc-creds-dot"),f=document.getElementById("svc-creds-status"),t=document.getElementById("svc-creds-clear");let m=!1;if(r.isExternal){try{const c=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();c.success?(document.getElementById("svc-seedhost-user").value=c.username||"",c.hasCredentials&&(m=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const c=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();c.success&&(c.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",m=!0):document.getElementById("svc-apikey-input").value="",c.hasBasicAuth&&!r.isExternal?(document.getElementById("svc-basic-user").value=c.username||"",m=!0):document.getElementById("svc-basic-user").value="")}catch{}if(document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value=""),m){E.style.background="var(--ok-fg, #74dfc4)",f.style.color="var(--ok-fg, #74dfc4)",f.textContent="Credentials stored",t.style.display="";const s=document.getElementById(`creds-btn-${r.id}`);s&&s.classList.add("has-creds")}else E.style.background="var(--muted)",f.style.color="var(--muted)",f.textContent="No credentials stored",t.style.display="none"}document.getElementById("svc-creds-save")?.addEventListener("click",async()=>{if(!i)return;const r=document.getElementById("svc-creds-save");r.textContent="Saving...",r.disabled=!0;try{if(i.isExternal){const t=document.getElementById("svc-seedhost-user").value.trim(),m=document.getElementById("svc-seedhost-pass").value;t&&await secureFetch("/api/v1/seedhost-creds",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t,password:m||void 0,serviceId:i.id})})}const f=document.getElementById("svc-apikey-input").value.trim();if(f&&f!=="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"&&await secureFetch(`/api/v1/services/${i.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:f})}),!i.isExternal){const t=document.getElementById("svc-basic-user").value.trim(),m=document.getElementById("svc-basic-pass").value;t&&m&&await secureFetch(`/api/v1/services/${i.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t,password:m})})}await p(i)}catch(E){console.error("Failed to save credentials:",E)}r.textContent="Save",r.disabled=!1}),document.getElementById("svc-creds-clear")?.addEventListener("click",async()=>{if(i&&confirm(`Remove stored credentials for ${i.name}?`))try{i.isExternal&&await secureFetch(`/api/v1/seedhost-creds?serviceId=${i.id}`,{method:"DELETE"}),await secureFetch(`/api/v1/services/${i.id}/credentials`,{method:"DELETE"});const r=document.getElementById(`creds-btn-${i.id}`);r&&r.classList.remove("has-creds"),await p(i)}catch(r){console.error("Failed to clear credentials:",r)}}),document.getElementById("svc-creds-close")?.addEventListener("click",()=>{n.classList.remove("show"),i=null}),n?.addEventListener("click",r=>{r.target===n&&(n.classList.remove("show"),i=null)}),window.refreshCredsButtons=async function(){try{for(const r of window.APPS||[]){if(!r.isExternal&&!r.appTemplate&&!r.url)continue;let E=!1;if(r.isExternal)try{const m=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();m.success&&m.hasCredentials&&(E=!0)}catch{}try{const m=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();m.success&&(m.hasApiKey||m.hasBasicAuth)&&(E=!0)}catch{}const f=document.getElementById(`creds-btn-${r.id}`);f&&f.classList.toggle("has-creds",E)}}catch{}}})(),(function(){injectModal("totp-settings-modal",`
+
+

Authentication Settings

+ + +
+ + TOTP is not configured +
+ + +
+ +
+
+ or +
+
+
+ +
+ + +
+
+
+ + + + + + + + + + + +
+ +
+
+
`);async function n(){try{const r=await(await fetch("/api/v1/totp/config")).json();if(!r.success)return;const{enabled:E,sessionDuration:f,isSetUp:t}=r.config,m=document.getElementById("totp-status-dot"),s=document.getElementById("totp-status-text"),c=document.getElementById("totp-status-banner"),y=document.getElementById("totp-setup-section"),k=document.getElementById("totp-qr-section"),e=document.getElementById("totp-duration-section"),d=document.getElementById("totp-disable-section");E&&t?(m.style.background="var(--ok-fg, #7ef2ff)",c.style.borderColor="var(--ok-fg, #7ef2ff)",c.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",s.textContent="TOTP is active",s.style.color="var(--ok-fg, #7ef2ff)",y.style.display="none",k.style.display="none",e.style.display="block",d.style.display="block",document.getElementById("totp-duration-select").value=f):(m.style.background="var(--muted)",c.style.borderColor="var(--border)",c.style.background="transparent",s.textContent="TOTP is not configured",s.style.color="var(--muted)",y.style.display="block",k.style.display="none",e.style.display="none",d.style.display="none"),g(E&&t,f)}catch(p){console.warn("Failed to load TOTP settings:",p)}}const i={"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 g(p,r){const E=document.getElementById("auth-card"),f=document.getElementById("auth-pill"),t=document.getElementById("auth-dot"),m=document.getElementById("auth-status-text");E&&(p?(E.setAttribute("data-status","on"),f.className="badge on",f.textContent="YES",t.className="dot ok at-bl",m.textContent="Session: "+(i[r]||r)):(E.setAttribute("data-status","off"),f.className="badge off",f.textContent="NO",t.className="dot bad at-bl",m.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();r.success&&(document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.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(p){console.error("TOTP setup failed:",p)}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const p=document.getElementById("totp-import-key").value.trim();if(p)try{const E=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:p})})).json();E.success?(document.getElementById("totp-qr-image").src=E.qrCode,document.getElementById("totp-manual-key").textContent=E.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()):(document.getElementById("totp-import-key").style.borderColor="var(--bad-fg)",setTimeout(()=>{document.getElementById("totp-import-key").style.borderColor=""},2e3))}catch(r){console.error("TOTP import failed:",r)}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const p=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(p).then(()=>{const r=document.getElementById("totp-copy-key");r.textContent="\u2705",setTimeout(()=>{r.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const p=document.getElementById("totp-setup-code").value,r=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(p)){r.textContent="Enter a 6-digit code";return}try{const f=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:p})})).json();f.success?(r.textContent="",n()):r.textContent=f.error||"Invalid code"}catch{r.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",p=>{p.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async p=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:p.target.value})}),n()}catch(r){console.error("Failed to update session duration:",r)}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"})).json()).success&&n()}catch(p){console.error("Failed to disable TOTP:",p)}}),document.getElementById("auth-settings-btn")?.addEventListener("click",()=>{n(),openModal("totp-settings-modal")}),document.getElementById("totp-modal-close")?.addEventListener("click",()=>{closeModal("totp-settings-modal")}),document.getElementById("totp-settings-modal")?.addEventListener("click",p=>{p.target.id==="totp-settings-modal"&&closeModal("totp-settings-modal")}),window._updateAuthCard=g,(async()=>{try{const r=await(await fetch("/api/v1/totp/config")).json();if(r.success){const E=r.config.enabled&&r.config.isSetUp;g(E,r.config.sessionDuration)}}catch(p){console.error("[AuthCard] Failed to update:",p)}})()})(),(function(){injectModal("token-management-modal",` +
+
+

\u{1F511} DNS Credentials

+ +

+ Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates. +

+ +
+ + +
+
+ `);function n(){return Object.keys(SITE.dnsServers||{})}function i(o){return(SITE.dnsServers||{})[o]?.name||o.toUpperCase()}function g(){const o=document.getElementById("dns-cred-sections");if(!o)return;o.innerHTML="";const u=n();if(u.length===0){o.innerHTML='

No DNS servers configured.

';return}for(const w of u)o.insertAdjacentHTML("beforeend",` +
+

${i(w)}

+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ `)}function p(){let o=safeSessionGet("dashcaddy-encryption-key");if(o)return o;const u=safeGet("dashcaddy-encryption-key");if(u)return safeSessionSet("dashcaddy-encryption-key",u),safeRemove("dashcaddy-encryption-key"),u;const w=new Uint8Array(32);return crypto.getRandomValues(w),o=Array.from(w,a=>a.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",o),o}const r=p();function E(o,u){if(!o)return"";const w=crypto.getRandomValues(new Uint8Array(8)),a=Array.from(w,L=>L.toString(16).padStart(2,"0")).join(""),I=new TextEncoder().encode(u+a);let B="";for(let L=0;LparseInt($,16))),A=atob(o.substring(17)),h=new TextEncoder().encode(u+B);let T="";for(let $=0;${["readonly","admin"].forEach(u=>{["token","username"].forEach(w=>{safeRemove(`${o}-${u}-${w}-enc`)})}),safeRemove(`${o}-token-enc`),safeRemove(`${o}-username-enc`)})}function v(o){const u=s(o,"readonly"),w=c(o,"readonly"),a=s(o,"admin"),I=c(o,"admin"),B=f(safeGet(`${o}-token-enc`),r),L=f(safeGet(`${o}-username-enc`),r);return{username:I||w||L,token:a||u||B,readonlyToken:u||B,readonlyUsername:w||L,adminToken:a||B,adminUsername:I||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const o=document.getElementById("token-management-modal"),u=e();n().forEach(w=>{const a=u[w];document.getElementById(`${w}-readonly-username`).value=a.readonly.username,document.getElementById(`${w}-readonly-token`).value=a.readonly.token,document.getElementById(`${w}-admin-username`).value=a.admin.username,document.getElementById(`${w}-admin-token`).value=a.admin.token,document.getElementById(`${w}-token-status`).textContent=""}),o.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",o=>{const u=o.target.closest(".token-toggle");if(u){const w=u.dataset.target,a=document.getElementById(w);a.type==="password"?(a.type="text",u.textContent="\u{1F648}"):(a.type="password",u.textContent="\u{1F441}");return}o.target.id==="token-management-modal"&&o.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const o=n();o.forEach(a=>{k(a,"readonly",document.getElementById(`${a}-readonly-username`).value.trim()),y(a,"readonly",document.getElementById(`${a}-readonly-token`).value.trim()),k(a,"admin",document.getElementById(`${a}-admin-username`).value.trim()),y(a,"admin",document.getElementById(`${a}-admin-token`).value.trim())});const u={};let w=!1;if(o.forEach(a=>{const I={},B=document.getElementById(`${a}-readonly-username`).value.trim(),L=document.getElementById(`${a}-readonly-token`).value.trim(),A=document.getElementById(`${a}-admin-username`).value.trim(),h=document.getElementById(`${a}-admin-token`).value.trim();B&&L&&(I.readonly={username:B,password:L},w=!0),A&&h&&(I.admin={username:A,password:h},w=!0),Object.keys(I).length>0&&(u[a]=I)}),w){o.forEach(a=>{u[a]&&(document.getElementById(`${a}-token-status`).textContent="Verifying...",document.getElementById(`${a}-token-status`).className="token-status")});try{const I=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:u})})).json();I.results?o.forEach(B=>{const L=document.getElementById(`${B}-token-status`);if(!u[B]){L.textContent="";return}const A=I.results[B];A?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):A?.partial?(L.textContent="\u2713 "+A.partial,L.className="token-status success"):(L.textContent="\u2717 "+(A?.error||"Login failed"),L.className="token-status error")}):I.success?o.forEach(B=>{u[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2713 Saved",document.getElementById(`${B}-token-status`).className="token-status success")}):o.forEach(B=>{u[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2717 "+(I.error||"Failed"),document.getElementById(`${B}-token-status`).className="token-status error")})}catch(a){console.error("Failed to sync DNS credentials to backend:",a),o.forEach(I=>{u[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2713 Saved locally (sync failed)",document.getElementById(`${I}-token-status`).className="token-status")})}}else o.forEach(a=>{document.getElementById(`${a}-token-status`).textContent=""});setTimeout(()=>{o.every(I=>{const B=document.getElementById(`${I}-token-status`)?.textContent;return!B||B.includes("\u2713")})&&closeModal("token-management-modal")},1500)}),document.getElementById("token-cancel")?.addEventListener("click",()=>{closeModal("token-management-modal")}),document.getElementById("token-clear-all")?.addEventListener("click",async()=>{if(confirm("Clear all stored DNS credentials? This cannot be undone.")){d(),n().forEach(o=>{document.getElementById(`${o}-readonly-username`).value="",document.getElementById(`${o}-readonly-token`).value="",document.getElementById(`${o}-admin-username`).value="",document.getElementById(`${o}-admin-token`).value="",document.getElementById(`${o}-token-status`).textContent="\u2713 Cleared",document.getElementById(`${o}-token-status`).className="token-status success"});try{await secureFetch("/api/v1/dns/credentials",{method:"DELETE"})}catch{}}}),window.getToken=s,window.getUsername=c,window.setToken=y,window.setUsername=k,window.getAllCredentials=e,window.getCredential=t,window.setCredential=m,window.getEncryptionKey=p,window.getDnsIds=n,window.getDnsDisplayName=i})(),(function(){function n(y,k,e=null){const d=document.getElementById(y+"-dot"),v=document.getElementById(y+"-pill"),o=document.getElementById(y+"-time"),u=document.querySelector(`[data-app="${y}"]`);d&&(d.classList.toggle("ok",k),d.classList.toggle("bad",!k)),v&&(v.textContent=k?"ON":"OFF",v.classList.toggle("on",k),v.classList.toggle("off",!k)),o&&e!==null&&(o.textContent=k?`${e}ms`:"timeout",o.className=`response-time ${i(e,k)}`),u&&u.setAttribute("data-status",k?"on":"off")}function i(y,k){return k?y<200?"excellent":y<500?"good":y<1e3?"fair":"slow":"timeout"}async function g(y){const k=performance.now();try{const e=await fetch("/probe/"+y,{cache:"no-store"}),d=performance.now(),v=Math.round(d-k);return{isUp:e.status>=200&&e.status<400||e.status===401||e.status===403,responseTime:v}}catch{const e=performance.now();return{isUp:!1,responseTime:Math.round(e-k)}}}window.APPS=[];let p=null,r=!1;async function E(){try{window.SkeletonLoader&&window.SkeletonLoader.show(6);const y=await fetch("/api/v1/services",{cache:"no-store"});y.ok?(window.APPS=await y.json(),window.SkeletonLoader&&window.SkeletonLoader.hide()):(console.error("Failed to load services:",y.status),window.SkeletonLoader&&window.SkeletonLoader.hide())}catch(y){console.error("Failed to load services:",y),window.SkeletonLoader&&window.SkeletonLoader.hide()}}function f(y){return buildServiceUrl(y)}function t(y,k,e){const d=document.createElement(y);return k&&(d.className=k),e&&(d.textContent=e),d}function m(){const y=document.getElementById("cards");y.innerHTML="";for(let k=0;k{P.stopPropagation(),window.openContainerLogsModal(e.containerId,e.name)},l.appendChild(x);const C=t("button","update-btn","\u2B06\uFE0F");C.title="Update container to latest version",C.id=`update-btn-${e.id}`,C.onclick=P=>{P.stopPropagation(),window.updateContainer(e.containerId,e.name,e.id)},l.appendChild(C)}if(e.logPath&&!e.containerId){const x=t("button","logs-btn","\u{1F4CB}");x.title="View application logs",x.onclick=C=>{C.stopPropagation(),window.openFileLogsModal(e.logPath,e.name)},l.appendChild(x)}if(e.isExternal||e.appTemplate||e.url){const x=t("button","creds-btn","\u{1F511}");x.title="Auto-login credentials",x.id=`creds-btn-${e.id}`,x.onclick=C=>{C.stopPropagation(),window.openServiceCredsModal(e)},l.appendChild(x)}if(e.id!=="internet"){const x=t("button","options-btn","\u2699\uFE0F");x.title="Edit service settings",x.onclick=C=>{C.stopPropagation(),window.openServiceEditModal(e)},l.appendChild(x)}if(e.id!=="internet"){const x=t("button","delete-btn","\u{1F5D1}\uFE0F");x.title="Delete this service",x.onclick=C=>{C.stopPropagation(),window.deleteService(e.id,e.name)},l.appendChild(x)}const S=t("button",null,"Open");S.onclick=()=>window.open(f(e.id),"_blank","noopener"),l.appendChild(S),d.appendChild(l),d.style.transitionDelay=`${Math.min(k*45,270)}ms`,y.appendChild(d)}requestAnimationFrame(()=>{y.querySelectorAll(".card").forEach(k=>k.classList.add("loaded"))}),window.groupRecipeCards&&requestAnimationFrame(()=>window.groupRecipeCards())}function s(y,k,e=null){const d=document.getElementById("dot-"+y+"-grid"),v=document.getElementById("badge-"+y),o=document.getElementById("time-"+y),u=document.querySelector(`[data-app="${y}"]`);d&&(d.classList.toggle("ok",k),d.classList.toggle("bad",!k)),v&&(v.textContent=k?"ON":"OFF",v.classList.toggle("on",k),v.classList.toggle("off",!k)),o&&e!==null&&(o.textContent=k?`${e}ms`:"timeout",o.className=`response-time ${i(e,k)}`),u&&u.setAttribute("data-status",k?"on":"off")}async function c(){if(p)return r=!0,p;function y(d,v=new Date){const o=document.getElementById("stamp");o&&(o.textContent=`${d}: ${new Date(v).toLocaleTimeString()}`)}function k(d){Object.keys(SITE.dnsServers).forEach(o=>{const u=d[o];u&&n(o,u.isUp,u.responseTime)}),d.internet&&n("internet",d.internet.isUp,d.internet.responseTime),window.APPS.forEach(o=>{const u=d[o.id];u&&s(o.id,u.isUp,u.responseTime)})}async function e(){const d=Object.keys(SITE.dnsServers),v=d.map(a=>g(a));v.push(g("internet"));const o=await Promise.all(v);d.forEach((a,I)=>n(a,o[I].isUp,o[I].responseTime));const u=o[o.length-1];n("internet",u.isUp,u.responseTime),(await Promise.all(window.APPS.map(async a=>{const I=await g(a.id);return{id:a.id,...I}}))).forEach(a=>{s(a.id,a.isUp,a.responseTime)})}return p=(async()=>{try{const d=await fetch("/api/v1/services/status",{cache:"no-store"});if(!d.ok)throw new Error(`Status refresh failed (${d.status})`);const v=await d.json();k(v.statuses||{}),y("last check",v.checkedAt||new Date)}catch(d){console.warn("Batched status refresh failed, falling back to direct probes:",d);try{await e(),y("last check")}catch(v){console.error("Dashboard refresh failed:",v),y("last failed")}}finally{p=null,r&&(r=!1,setTimeout(()=>{window.refreshAll()},0))}})(),p}document.querySelector(".top")?.addEventListener("click",y=>{const k=y.target.closest('[id$="-open"]');if(!k)return;const e=k.id.replace("-open","");SITE.dnsServers[e]&&window.open(f(e),"_blank","noopener")}),document.getElementById("ca-open")?.addEventListener("click",()=>window.open(f("ca"),"_blank","noopener")),document.getElementById("creds-btn-ca")?.addEventListener("click",y=>{y.stopPropagation();const k=window.APPS.find(e=>e.id==="ca");k&&window.openServiceCredsModal&&window.openServiceCredsModal(k)}),document.getElementById("options-btn-ca")?.addEventListener("click",y=>{y.stopPropagation();const k=window.APPS.find(e=>e.id==="ca");k&&window.openServiceEditModal&&window.openServiceEditModal(k)}),document.getElementById("delete-btn-ca")?.addEventListener("click",y=>{y.stopPropagation(),window.deleteService&&window.deleteService("ca","DashCA")}),window.loadServices=E,window.buildGrid=m,window.refreshAll=c,window.setQuick=n,window.setBadge=s,window.getResponseTimeClass=i,window.checkServiceWithTiming=g,window.serviceUrl=f,window.el=t})(),(function(){async function n(t){const s=await(await secureFetch(`/api/v1/dns/restart/${t}`,{method:"POST"})).json();if(!s.success)throw new Error(s.error||"Restart failed");return s}document.querySelector(".top")?.addEventListener("click",async t=>{const m=t.target.closest('[id$="-restart"]');if(!m)return;const s=m.id.replace("-restart","");if(SITE.dnsServers[s]&&confirm(`Restart ${s.toUpperCase()} service?`))try{await withButton(m,"...",()=>n(s)),setTimeout(window.refreshAll,DC.DELAYS.RELOAD)}catch(c){showNotification("Restart failed: "+c.message,"error")}});async function i(t,m){const s=document.getElementById(`${t}-update`),c=s?.textContent||"\u2B06\uFE0F";try{s.textContent="\u{1F50D}",s.disabled=!0,s.title="Checking for updates...";const k=await(await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(m)}`)).json();if(!k.success)throw new Error(k.error||"Failed to check for updates");if(!k.updateAvailable){s.textContent="\u2705",s.title=`Already on latest version (${k.currentVersion})`,showNotification(`${t.toUpperCase()} is already up to date! Current version: ${k.currentVersion}`,"info"),setTimeout(()=>{s.textContent=c,s.disabled=!1,s.title="Update DNS server"},3e3);return}if(!confirm(`Update available for ${t.toUpperCase()}! + +Current: ${k.currentVersion} +New: ${k.updateVersion} + +`+(k.updateTitle?`${k.updateTitle} + +`:"")+`The DNS server will restart during the update. +Proceed?`)){s.textContent=c,s.disabled=!1,s.title="Update DNS server";return}s.textContent="\u{1F504}",s.title="Updating...";const v=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(m)}`,{method:"POST"})).json();if(!v.success)throw new Error(v.error||"Update failed");if(v.manualUpdateRequired){s.textContent="\u2B06\uFE0F",s.title=`Update available: ${v.newVersion}`;const o=v.downloadLink?` +Download: ${v.downloadLink}`:"",u=v.instructionsLink?` +Instructions: ${v.instructionsLink}`:"";showNotification(`${t.toUpperCase()} update requires manual installation. Current: ${v.previousVersion} \u2192 ${v.newVersion}. Please update manually on the host machine.`,"warning",8e3),s.disabled=!1;return}s.textContent="\u2705",s.title="Updated successfully!",showNotification(`${t.toUpperCase()} updated successfully! ${v.previousVersion} \u2192 ${v.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{s.textContent=c,s.disabled=!1,s.title="Update DNS server",window.refreshAll()},1e4)}catch(y){console.error("DNS update error:",y),s.textContent="\u274C",s.title="Update failed",showNotification(`Failed to update ${t.toUpperCase()}: ${y.message}`,"error"),setTimeout(()=>{s.textContent=c,s.disabled=!1,s.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",t=>{const m=t.target.closest('[id$="-update"]');if(!m)return;const s=m.id.replace("-update","");SITE.dnsServers[s]&&i(s,SITE.dnsServers[s]?.ip)}),injectModal("dns-settings-modal",` +
+
+

DNS Settings

+ +
+
+ + +
+
+ + +
+
+ + +
+
Manage credentials via Tokens in the toolbar
+
+ +
+ + + +
+
+
`);let g=null;function p(t){g=t;const m=SITE.dnsServers[t]||{},s=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(m.name||t).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=m.ip||"",document.getElementById("dns-edit-port").value=m.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=m.name||"",s.classList.add("show")}async function r(){if(!g)return;const t=document.getElementById("dns-edit-ip").value.trim(),m=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,s=document.getElementById("dns-edit-name").value.trim();if(!t){showNotification("Server IP is required","warning");return}const c={dnsServers:{}};c.dnsServers[g]={ip:t,port:String(m)},s&&(c.dnsServers[g].name=s);try{const k=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)})).json();k.success?(SITE.dnsServers[g]=c.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),f(),window.refreshAll()):showNotification(k.error||"Failed to save settings","error")}catch(y){showNotification("Failed to save: "+y.message,"error")}}async function E(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const m=await(await secureFetch("/api/v1/config")).json();m.dnsServers&&delete m.dnsServers[g];const c=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:m.dnsServers||{}})})).json();if(c.success){delete SITE.dnsServers[g];const y=document.querySelector(`.top [data-app="${g}"]`);y&&y.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),f()}else showNotification(c.error||"Failed to remove","error")}catch(t){showNotification("Failed to remove: "+t.message,"error")}}function f(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",f),document.getElementById("dns-settings-save")?.addEventListener("click",r),document.getElementById("dns-settings-delete")?.addEventListener("click",E),document.getElementById("dns-settings-modal")?.addEventListener("click",t=>{t.target.id==="dns-settings-modal"&&f()}),document.querySelector(".top")?.addEventListener("click",t=>{const m=t.target.closest('[id$="-settings"]');if(!m)return;const s=m.id.replace("-settings","");SITE.dnsServers[s]&&(t.stopPropagation(),p(s))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",` +
+
+
+

DNS Logs

+
+ + + + + +
+
+
+
+
Loading logs...
+
+
+
+
`);let n=null,i=null,g=!1,p=null,r=null,E=!1,f=null,t=null,m=!1,s=null,c=!1;async function y(b,l=25){try{const S=getDnsServerAddr(b),x=await fetch(`/api/v1/dns/logs?server=${S}&limit=${l}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.json();return C.success&&C.logs?{logs:C.logs,count:C.count,server:C.server}:{error:C.error||"Failed to fetch logs"}}else return x.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${x.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function k(b){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"}[b]||"var(--fg)"}function e(b){const l=document.createElement("div");if(l.className="log-entry",l.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;",b.parsed===!1)return l.style.gridTemplateColumns="1fr",l.innerHTML=`${escapeHtml(b.raw)}`,l;const S=k(b.rcode),x=b.rcode==="Refused"||b.rcode==="REFUSED";return l.innerHTML=` + ${escapeHtml(b.timestamp)} + ${escapeHtml(b.client)} + ${escapeHtml(b.domain)} + ${escapeHtml(b.type)} + ${escapeHtml(b.rcode)} + `,l}async function d(){if(m){await T();return}if(E){await B();return}if(g||!n)return;const b=parseInt(document.getElementById("log-lines").value),l=document.getElementById("logs-content");try{const S=await y(n,b);if(S.error){l.innerHTML=` +
+
\u26A0\uFE0F Error
+
${escapeHtml(S.error)}
+
`;return}l.innerHTML=` +
+ Time + Client + Domain + Type + Status +
`,S.logs&&S.logs.length>0?S.logs.forEach(x=>{const C=e(x);l.appendChild(C)}):l.innerHTML+=` +
+ No DNS queries logged yet +
`}catch(S){l.innerHTML=` +
+ Failed to fetch logs: ${escapeHtml(S.message)} +
`}}function v(b){n=b,g=!1,E=!1;const l=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),x=document.getElementById("logs-pause"),C=document.getElementById("logs-stream");S.textContent=`${b.toUpperCase()} DNS Logs`,x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),C&&(C.style.display="none"),l.classList.add("show"),d(),i=setInterval(d,DC.POLL.LOGS)}function o(){document.getElementById("logs-modal").classList.remove("show"),i&&(clearInterval(i),i=null),w(),n=null,E=!1,p=null,r=null,m=!1,f=null,t=null,g=!1}function u(b){s&&w();const l=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),x=document.getElementById("logs-content");i&&(clearInterval(i),i=null);try{s=new EventSource(`/api/v1/logs/stream/${b}`),c=!0,l.classList.add("active"),l.textContent="\u{1F534} Live",l.title="Streaming - click to stop",S.style.display="none";const C=document.getElementById("logs-title");C.textContent.includes("\u{1F534}")||(C.innerHTML=C.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),s.onmessage=P=>{try{const O=JSON.parse(P.data);if(O.error){console.error("Stream error:",O.error),w();return}const D=document.createElement("div");D.className="log-entry",D.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(O.stream||"stdout")==="stderr",U=R?"var(--bad-fg)":"var(--fg)",F=`${R?"STDERR":"STDOUT"}`;for(D.innerHTML=` +
${F}
+
${escapeHtml(O.text)}
+ `,x.appendChild(D),x.scrollTop=x.scrollHeight;x.children.length>500;)x.removeChild(x.firstChild)}catch(O){console.error("Error parsing stream data:",O)}},s.onerror=P=>{console.error("EventSource error:",P),w()}}catch(C){console.error("Failed to start streaming:",C),w()}}function w(){s&&(s.close(),s=null),c=!1;const b=document.getElementById("logs-stream"),l=document.getElementById("logs-pause"),S=document.getElementById("logs-title");b&&(b.classList.remove("active"),b.textContent="\u{1F4E1} Live",b.title="Enable real-time streaming"),l&&(l.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),E&&p&&!i&&(i=setInterval(B,DC.POLL.LOGS))}async function a(b,l=100){try{const S=`/api/v1/logs/container/${b}?tail=${l}×tamps=true`,x=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.json();return C.success&&C.logs?{logs:C.logs,count:C.count,containerName:C.containerName,containerId:C.containerId}:{error:C.error||"Failed to fetch container logs"}}else return{error:`HTTP ${x.status}: ${x.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function I(b){const l=document.createElement("div");l.className="log-entry",l.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=b.stream==="stderr"?"var(--bad-fg)":"var(--fg)",x=b.stream==="stderr"?'STDERR':'STDOUT';return l.innerHTML=` +
${x}
+
${escapeHtml(b.text)}
+ `,l}async function B(){if(g||!p||!E)return;const b=parseInt(document.getElementById("log-lines").value),l=document.getElementById("logs-content");try{const S=await a(p,b);if(S.error){l.innerHTML=` +
+
\u26A0\uFE0F Error
+
${escapeHtml(S.error)}
+
`;return}l.innerHTML=` +
+ Stream + Log Output +
`,S.logs&&S.logs.length>0?(S.logs.forEach(x=>{const C=I(x);l.appendChild(C)}),l.scrollTop=l.scrollHeight):l.innerHTML+=` +
+ No logs available for this container +
`}catch(S){l.innerHTML=` +
+ Failed to fetch logs: ${escapeHtml(S.message)} +
`}}function L(b,l){p=b,r=l,E=!0,m=!1,g=!1,w();const S=document.getElementById("logs-modal"),x=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");x.textContent=`\u{1F4CB} ${l} - Container Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display=""),S.classList.add("show"),B(),i=setInterval(B,DC.POLL.LOGS)}async function A(b,l=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(b)}&tail=${l}`,x=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.json();return C.success&&C.logs?{logs:C.logs,count:C.count,logPath:C.logPath,totalLines:C.totalLines}:{error:C.error||"Failed to fetch file logs"}}else return{error:(await x.json().catch(()=>({}))).error||`HTTP ${x.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function h(b){const l=document.createElement("div");l.className="log-entry",l.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=b.text;let x="INFO",C="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(x="ERROR",C="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(x="WARN",C="#f39c12"):S.match(/DEBUG/i)&&(x="DEBUG",C="var(--muted)");const O=`${x}`;return l.innerHTML=` +
${O}
+
${escapeHtml(S)}
+ `,l}async function T(){if(g||!f||!m)return;const b=parseInt(document.getElementById("log-lines").value),l=document.getElementById("logs-content");try{const S=await A(f,b);if(S.error){l.innerHTML=` +
+
\u26A0\uFE0F Error
+
${escapeHtml(S.error)}
+
`;return}l.innerHTML=` +
+ Log Output (${S.count} of ${S.totalLines} lines) +
`,S.logs&&S.logs.length>0?(S.logs.forEach(x=>{const C=h(x);l.appendChild(C)}),l.scrollTop=l.scrollHeight):l.innerHTML+=` +
+ No logs available in this file +
`}catch(S){l.innerHTML=` +
+ Failed to fetch logs: ${escapeHtml(S.message)} +
`}}function $(b,l){f=b,t=l,m=!0,E=!1,g=!1;const S=document.getElementById("logs-modal"),x=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");x.textContent=`\u{1F4CB} ${l} - Application Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display="none"),S.classList.add("show"),T(),i=setInterval(T,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",b=>{const l=b.target.closest('[id$="-logs"]');if(!l)return;const S=l.id.replace("-logs","");SITE.dnsServers[S]&&v(S)}),document.getElementById("logs-close")?.addEventListener("click",o),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const b=document.getElementById("logs-pause");g?(b.textContent="\u25B6\uFE0F Resume",b.classList.add("paused")):(b.textContent="\u23F8\uFE0F Pause",b.classList.remove("paused"),d())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||d()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!E||!p||(c?w():u(p))}),document.getElementById("logs-modal")?.addEventListener("click",b=>{b.target.id==="logs-modal"&&o()}),document.addEventListener("keydown",b=>{b.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&o()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=v})(),(function(){injectModal("service-edit-modal",` +
+
+

Edit Service

+ +
+ +
+ +
+
+
+
+
+ + +
+ +
+ + .home +
+
+ + +
+ + +
+ The port Caddy will proxy to (container's exposed port) +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ Enter a URL or upload an image file (PNG, JPG, SVG) +
+
+
+ +
+ + +
+
+
`),injectModal("delete-service-modal",` +
+
+

Delete Service

+ +
+ +
+ + + +
+ + + +
+ + +
+
`),injectModal("add-service-modal",` +
+
+

Add Service

+ + +
+ + +
+ + + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ + + +
+
+
+ + +
+ Options +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + + + + +
+ Checking Tailscale... +
+ + + + +
+ +
+
+ + + + +
+
+ + + + + + +
+
+ +
+
+
+ + + + +
+ + +
+
+
`)})(),(function(){async function n(E){try{const f=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(E)}`);if(!f.ok)throw new Error(`Failed to load CAs: ${f.status}`);const t=await f.json();if(t.status==="success"){const m=document.getElementById("existing-ca-select");return m.innerHTML="",t.data.cas.length===0?m.innerHTML='':(m.innerHTML='',t.data.cas.forEach(s=>{const c=document.createElement("option");typeof s=="object"?(c.value=s.id,c.textContent=s.displayName||s.name):(c.value=s,c.textContent=s),m.appendChild(c)})),t.data.cas}else throw new Error(t.message)}catch(f){console.error("Error loading CAs:",f);const t=document.getElementById("existing-ca-select");return t.innerHTML='',[]}}function i(E){const{subdomain:f,port:t,ip:m,sslType:s,caName:c,existingCa:y,enableAuth:k,enableCors:e,customHeaders:d,upstreamPath:v,healthCheck:o,timeout:u,tailscaleOnly:w}=E;let a=`${buildDomain(f)} { +`;switch(w&&(a+=` @blocked not remote_ip 100.64.0.0/10 +`,a+=` respond @blocked "Access denied. Tailscale connection required." 403 +`),s){case"letsencrypt":break;case"caddy-managed":a+=` tls internal +`;break;case"existing-ca":y&&(a+=` tls { + ca ${y} + } +`);break;case"custom-ca":c&&(a+=` tls { + ca ${c} + } +`);break}if(k&&(a+=` basicauth { + admin $2a$14$hashed_password_here + } +`),e&&(a+=` header { +`,a+=` Access-Control-Allow-Origin "*" +`,a+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" +`,a+=` Access-Control-Allow-Headers "Content-Type, Authorization" +`,a+=` } +`),d)try{const I=JSON.parse(d);a+=` header { +`,Object.entries(I).forEach(([B,L])=>{a+=` ${B} "${L}" +`}),a+=` } +`}catch{console.warn("Invalid JSON in custom headers")}return o&&(a+=` health_uri ${o} +`),a+=` reverse_proxy ${m}:${t} { +`,v&&v!=="/"&&(a+=` rewrite ${v} +`),u&&u!==30&&(a+=` transport http { +`,a+=` dial_timeout ${u}s +`,a+=` response_header_timeout ${u}s +`,a+=` } +`),a+=` } +`,a+=`} +`,a}async function g(E,f,t=DC.DEFAULTS.TTL){const m=window.getToken(getPrimaryDnsId(),"admin");if(!m)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const s=buildDomain(E),c=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:s,ip:f,ttl:t,token:m,server:SITE.dnsIp})});if(!c.ok){const k=await c.text();throw new Error(`DNS API Error: ${c.status} - ${k}`)}const y=await c.json();if(!y.success)throw new Error(`DNS Error: ${y.error||"Unknown error"}`);return y}async function p(E){const f={id:E.subdomain,name:E.name,logo:E.logo||`/assets/${E.subdomain}.png`};try{const t=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)});if(!t.ok){const m=await t.json();throw new Error(m.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),f}catch(t){throw console.error("Failed to add service to config:",t),t}}async function r(E){const f=document.getElementById("service-subdomain-input").value.trim(),t=document.getElementById("service-ip-input").value.trim()||"localhost",m=document.getElementById("service-port-input").value.trim()||"80",s=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(f),upstream:`${t}:${m}`,config:E})}),c=await s.json();if(!s.ok||!c.success)throw new Error(c.error||`Caddy API Error: ${s.status}`);return c}window.loadExistingCAs=n,window.generateCaddyConfig=i,window.createDnsRecord=g,window.addServiceToConfig=p,window.addToCaddyfile=r})(),(function(){let n=null;function i(t){n=t;const m=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${t.name}`,document.getElementById("edit-service-name-display").textContent=t.name,document.getElementById("edit-service-url-display").textContent=t.url||buildServiceUrl(t.id),document.getElementById("edit-service-logo-preview").src=t.logo||`/assets/${t.id}.png`,document.getElementById("edit-subdomain").value=t.id,document.getElementById("edit-port").value=t.port||"",document.getElementById("edit-ip").value=t.ip||"localhost",document.getElementById("edit-tailscale-only").checked=t.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=t.logo||"",m.classList.add("show")}function g(){closeModal("service-edit-modal"),n=null}async function p(){if(!n)return;const t=document.getElementById("edit-subdomain").value.trim().toLowerCase(),m=document.getElementById("edit-port").value.trim(),s=document.getElementById("edit-ip").value.trim()||"localhost",c=document.getElementById("edit-tailscale-only").checked,y=document.getElementById("edit-logo-url").value.trim();if(!t){showNotification("Subdomain is required","warning");return}const k=n.id,e=[];if(t!==k&&e.push("subdomain"),m&&m!==String(n.port)&&e.push("port"),s!==n.ip&&e.push("ip"),c!==(n.tailscaleOnly||!1)&&e.push("tailscale"),y!==n.logo&&e.push("logo"),e.length===0){g();return}const d=document.getElementById("service-edit-save");d.textContent="Saving...",d.disabled=!0;try{if(e.includes("subdomain")||e.includes("port")||e.includes("ip")||e.includes("tailscale")){const u=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:k,newSubdomain:t,port:m||n.port,ip:s,tailscaleOnly:c})})).json();if(!u.success)throw new Error(u.error||"Failed to update service")}const v=window.APPS.findIndex(o=>o.id===k);v!==-1&&(window.APPS[v]={...window.APPS[v],id:t,port:m||window.APPS[v].port,ip:s,tailscaleOnly:c,logo:y||window.APPS[v].logo}),await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:t,name:n.name,port:m||n.port,ip:s,logo:y||n.logo,tailscaleOnly:c,containerId:n.containerId,appTemplate:n.appTemplate})}),t!==k&&await secureFetch(`/api/v1/services/${k}`,{method:"DELETE"}),g(),window.buildGrid(),window.refreshAll()}catch(v){console.error("Error saving service changes:",v),showNotification(`Error saving changes: ${v.message}`,"error")}finally{d.textContent="Save Changes",d.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async t=>{const m=t.target.files[0];if(!m)return;if(!m.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const s=new FileReader;s.onload=async c=>{const y=c.target.result;if(document.getElementById("edit-service-logo-preview").src=y,document.getElementById("edit-logo-url").value=y,n)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${n.id}.png`,data:y})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},s.readAsDataURL(m)}),document.getElementById("service-edit-cancel")?.addEventListener("click",g),document.getElementById("service-edit-save")?.addEventListener("click",p),document.getElementById("service-edit-modal")?.addEventListener("click",t=>{t.target.id==="service-edit-modal"&&g()});function r(t,m,s){return new Promise(c=>{const y=document.getElementById("delete-service-modal"),k=document.getElementById("delete-modal-title"),e=document.getElementById("delete-modal-message"),d=document.getElementById("delete-modal-container-info"),v=document.getElementById("delete-modal-container-name"),o=document.getElementById("delete-modal-help"),u=document.getElementById("delete-modal-cancel"),w=document.getElementById("delete-modal-remove"),a=document.getElementById("delete-modal-delete");k.textContent=`Delete "${t}"`,m?(e.innerHTML="This service has an associated Docker container.
Choose how to proceed:",d.style.display="block",v.textContent=`Container ID: ${s?.slice(0,12)||"Unknown"}`,o.style.display="block",a.style.display="block"):(e.textContent="Remove this service from the dashboard?",d.style.display="none",o.style.display="none",a.style.display="none");const I=()=>{y.classList.remove("show"),u.removeEventListener("click",B),w.removeEventListener("click",L),a.removeEventListener("click",A),y.removeEventListener("click",h)},B=()=>{I(),c(null)},L=()=>{I(),c(!1)},A=()=>{I(),c(!0)},h=T=>{T.target===y&&(I(),c(null))};u.addEventListener("click",B),w.addEventListener("click",L),a.addEventListener("click",A),y.addEventListener("click",h),y.classList.add("show")})}async function E(t,m,s){const c=document.getElementById(`update-btn-${s}`),y=c?.textContent;if(confirm(`Update ${m} to the latest version? + +This will: +1. Pull the latest image +2. Stop the container +3. Recreate with same settings + +The service will be briefly unavailable.`))try{c&&(c.textContent="\u{1F504}",c.disabled=!0,c.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${t}/update`,{method:"POST"})).json();if(e.success){const d=window.APPS.find(v=>v.id===s);d&&e.newContainerId&&(d.containerId=e.newContainerId),c&&(c.textContent="\u2705",c.title="Updated successfully!",setTimeout(()=>{c.textContent=y,c.disabled=!1,c.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${m} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(k){console.error("Update error:",k),c&&(c.textContent="\u274C",c.title="Update failed",setTimeout(()=>{c.textContent=y,c.disabled=!1,c.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${m}: ${k.message}`,"error")}}async function f(t,m){const s=window.APPS.find(a=>a.id===t),c=s?buildDomain(s.id):null,y=s?.containerId,k=await r(m||t,y,s?.containerId);if(k===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(k&&y)try{const a=new URLSearchParams({containerId:s.containerId,subdomain:s.id,ip:s.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(s.id)}?${a.toString()}`,{method:"DELETE"})).json();B.success?e={...e,...B.results,dashboard:!1}:console.error("App removal failed:",B.error)}catch(a){console.error("App removal error:",a)}else if(k&&c){try{const a=s?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(c)}&type=A&ipAddress=${encodeURIComponent(a)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=B.success?"deleted":B.error||"failed"}catch(a){e.dns=a.message}try{const I=await(await secureFetch(`/api/v1/site/${encodeURIComponent(c)}`,{method:"DELETE"})).json();e.caddy=I.success||I.error&&I.error.includes("not found")?"removed":I.error||"failed"}catch(a){e.caddy=a.message}}const d=window.APPS.findIndex(a=>a.id===t);d>-1&&(window.APPS.splice(d,1),e.dashboard=!0);try{const a=safeGetJSON("custom-apps",[]),I=a.findIndex(B=>B.id===t);I>-1&&(a.splice(I,1),safeSet("custom-apps",JSON.stringify(a)))}catch{}try{const I=await(await secureFetch(`/api/v1/services/${encodeURIComponent(t)}`,{method:"DELETE"})).json();e.service=I.success?"removed":I.error||"failed"}catch(a){e.service=a.message}window.buildGrid(),window.refreshAll();let v=!1,o=[];e.dashboard||(v=!0,o.push("\u2717 Failed to remove from dashboard"));const u=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],w=a=>!a||u.some(I=>a.toLowerCase().includes(I.toLowerCase()));e.container&&!w(e.container)&&(v=!0,o.push(`\u26A0 Container: ${e.container}`)),e.dns&&!w(e.dns)&&(v=!0,o.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!w(e.caddy)&&(v=!0,o.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!w(e.service)&&(v=!0,o.push(`\u26A0 Service File: ${e.service}`)),v&&showNotification(`Error deleting "${m||t}": ${o.join(", ")}`,"error",6e3)}window.openServiceEditModal=i,window.showDeleteModal=r,window.updateContainer=E,window.deleteService=f})(),(function(){function n(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function i(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",d=document.getElementById("service-ip-input").value||p.lan||"localhost",v=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,o=document.getElementById("ssl-type-select").value,u=document.getElementById("ca-name-input").value||"sami-ca",w=document.getElementById("existing-ca-select").value,a=document.getElementById("enable-auth").checked,I=document.getElementById("enable-cors").checked,B=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,h=document.getElementById("timeout-input").value||30,T=document.getElementById("dns-preview");T&&(T.textContent=`${buildDomain(e)} \u2192 ${d}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const b={subdomain:e,port:v,ip:d,sslType:o,caName:u,existingCa:w,enableAuth:a,enableCors:I,customHeaders:B,upstreamPath:L,healthCheck:A,timeout:h},l=window.generateCaddyConfig(b),S=document.getElementById("caddy-config-preview");S&&(S.value=l)}const p={localhost:"127.0.0.1",lan:"",tailscale:""};async function r(){try{const o=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(o.ok){const u=await o.json();u.lan&&(p.lan=u.lan),u.tailscale&&(p.tailscale=u.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),d=document.getElementById("quick-ip-tailscale");e&&(p.lan?(e.dataset.ip=p.lan,e.textContent=`LAN (${p.lan})`,e.title=`LAN IP: ${p.lan}`):e.style.display="none"),d&&(p.tailscale?(d.dataset.ip=p.tailscale,d.textContent=`Tailscale (${p.tailscale})`,d.title=`Tailscale IP: ${p.tailscale}`):d.style.display="none");const v=document.getElementById("service-ip-input");v&&!v.value&&p.lan&&(v.value=p.lan)}function E(){document.querySelectorAll(".quick-ip-btn").forEach(e=>{e.addEventListener("click",()=>{const d=e.dataset.ip;d&&(document.getElementById("service-ip-input").value=d,document.querySelectorAll(".quick-ip-btn").forEach(v=>v.classList.remove("active")),e.classList.add("active"),g())})}),document.getElementById("service-ip-input")?.addEventListener("input",e=>{const d=e.target.value;document.querySelectorAll(".quick-ip-btn").forEach(v=>{v.classList.toggle("active",v.dataset.ip===d)})})}async function f(){const e=document.getElementById("add-service-modal");e.classList.add("show");const d=e.querySelector(".weather-modal-content");d&&(d.scrollTop=0),document.body.style.overflow="hidden";const v=document.getElementById("ssl-type-select");v&&(v.value=i()),await r();const o=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(o);const u=document.getElementById("manual-tailscale-status"),w=document.getElementById("manual-tailscale-only");try{const I=await(await fetch("/api/v1/tailscale/status")).json();I.success&&I.installed&&I.connected?(u.innerHTML=` + \u2713 Connected + ${I.self?.hostname} (${I.self?.ip}) + `,w.disabled=!1):I.installed?(u.innerHTML='\u26A0 Not connected',w.disabled=!0):(u.innerHTML='Not available',w.disabled=!0)}catch{u.innerHTML='Could not check',w.disabled=!0}w.checked=!1,g()}function t(){const e=document.getElementById("service-type-local"),d=document.getElementById("service-type-external"),v=document.getElementById("local-service-config"),o=document.getElementById("external-service-config"),u=document.getElementById("tab-local"),w=document.getElementById("tab-external");function a(){e.checked?(v.style.display="grid",o.style.display="none",u&&(u.style.background="var(--accent)",u.style.color="var(--bg)"),w&&(w.style.background="transparent",w.style.color="var(--muted)")):(v.style.display="none",o.style.display="block",w&&(w.style.background="var(--accent)",w.style.color="var(--bg)"),u&&(u.style.background="transparent",u.style.color="var(--muted)"))}e?.addEventListener("change",a),d?.addEventListener("change",a)}function m(){const e=document.getElementById("service-name-input"),d=document.getElementById("service-subdomain-input"),v=document.getElementById("subdomain-preview");let o=!1;e?.addEventListener("input",()=>{const L=n(e.value);!o&&d&&(d.value=L),v&&(v.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),d?.addEventListener("input",()=>{o=d.value!==n(e?.value||"");const L=d.value.trim()||n(e?.value||"");v&&(v.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const u=document.getElementById("external-service-name"),w=document.getElementById("external-service-subdomain"),a=document.getElementById("external-subdomain-preview"),I=document.getElementById("external-domain-preview");let B=!1;u?.addEventListener("input",()=>{const L=n(u.value);!B&&w&&(w.value=L);const A=w?.value||L;a&&(a.textContent=A?`\u2192 ${buildDomain(A)}`:""),I&&(I.textContent=A?buildDomain(A):"")}),w?.addEventListener("input",()=>{B=w.value!==n(u?.value||"");const L=w.value.trim()||n(u?.value||"");a&&(a.textContent=L?`\u2192 ${buildDomain(L)}`:""),I&&(I.textContent=L?buildDomain(L):"")})}async function s(){const e=document.getElementById("external-service-name").value.trim(),d=document.getElementById("external-service-url").value.trim(),v=(document.getElementById("external-service-subdomain").value.trim()||n(e)).toLowerCase(),o=document.getElementById("external-service-logo").value.trim(),u=document.getElementById("external-service-icon").value.trim(),w=document.getElementById("external-create-dns").checked,a=document.getElementById("external-create-caddy").checked,I=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",B=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked;if(!e||!d){showNotification("Please fill in Name and External URL","warning");return}if(!v){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!d.startsWith("http://")&&!d.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const A=buildDomain(v);try{const h={dns:null,caddy:null,dashboard:!1};if(w)if(window.getToken(getPrimaryDnsId(),"admin"))try{const C=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:A,ip:I,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();h.dns=C.success?"created":C.error||"failed"}catch(x){h.dns=x.message}else h.dns="no admin token (configure in \u{1F511} Tokens)";if(a)try{const S={subdomain:v,externalUrl:d,preserveHost:B,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},C=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)})).json();h.caddy=C.success?"created":C.error||"failed"}catch(S){h.caddy=S.message}const T={id:v,name:e,url:`https://${A}`,externalUrl:d,logo:o||u||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(T),h.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],b=window.APPS.filter(S=>!$.includes(S.id));safeSet("custom-services",JSON.stringify(b));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(S){console.warn("Failed to save to services.json:",S)}window.buildGrid(),window.refreshAll(),c();const l=[`External service "${e}" added!`];w&&l.push(`DNS: ${h.dns==="created"?"\u2713":"\u26A0 "+h.dns}`),a&&l.push(`Caddy: ${h.caddy==="created"?"\u2713":"\u26A0 "+h.caddy}`),l.push(`Access at: https://${A}`),showNotification(l.join(" | "),"success",6e3)}catch(h){console.error("Failed to create external service:",h),showNotification(`Failed to create external service: ${h.message}`,"error")}}function c(){closeModal("add-service-modal"),document.body.style.overflow="",document.getElementById("service-name-input").value="",document.getElementById("service-subdomain-input").value="",document.getElementById("service-port-input").value="",document.getElementById("service-ip-input").value=p.lan||"",document.getElementById("service-logo-input").value="",document.getElementById("dns-ttl-input").value=DC.DEFAULTS.TTL,document.getElementById("ssl-type-select").value=i(),document.getElementById("ca-name-input").value="",document.getElementById("enable-auth").checked=!1,document.getElementById("enable-cors").checked=!1,document.getElementById("custom-headers-input").value="",document.getElementById("upstream-path-input").value="/",document.getElementById("health-check-input").value="",document.getElementById("timeout-input").value="30";const e=document.getElementById("subdomain-preview");e&&(e.textContent="");const d=document.getElementById("external-subdomain-preview");d&&(d.textContent="");const v=document.getElementById("external-service-name");v&&(v.value="");const o=document.getElementById("external-service-subdomain");o&&(o.value="");const u=document.getElementById("external-service-url");u&&(u.value="");const w=document.getElementById("external-service-logo");w&&(w.value="");const a=document.getElementById("external-service-icon");a&&(a.value="");const I=document.getElementById("local-advanced-options");I&&I.removeAttribute("open");const B=document.getElementById("external-advanced-options");B&&B.removeAttribute("open");const L=document.getElementById("service-type-local");L&&(L.checked=!0);const A=document.getElementById("local-service-config"),h=document.getElementById("external-service-config");A&&(A.style.display="grid"),h&&(h.style.display="none");const T=document.getElementById("tab-local"),$=document.getElementById("tab-external");T&&(T.style.background="var(--accent)",T.style.color="var(--bg)"),$&&($.style.background="transparent",$.style.color="var(--muted)")}async function y(){const e=document.getElementById("service-name-input").value.trim(),d=(document.getElementById("service-subdomain-input").value.trim()||n(e)).toLowerCase(),v=document.getElementById("service-port-input").value.trim(),o=document.getElementById("service-ip-input").value.trim(),u=document.getElementById("service-logo-input").value.trim(),w=document.getElementById("create-dns-record").checked,a=parseInt(document.getElementById("dns-ttl-input").value)||DC.DEFAULTS.TTL,I=document.getElementById("manual-tailscale-only")?.checked||!1,B=document.getElementById("ssl-type-select")?.value||"caddy-managed",L=document.getElementById("ca-name-input")?.value||"",A=document.getElementById("existing-ca-select")?.value||"",h=document.getElementById("enable-auth")?.checked||!1,T=document.getElementById("enable-cors")?.checked||!1,$=document.getElementById("custom-headers-input")?.value||"",b=document.getElementById("upstream-path-input")?.value||"/",l=document.getElementById("health-check-input")?.value||"",S=document.getElementById("timeout-input")?.value||30,x=window.getToken(getPrimaryDnsId(),"admin");if(!e||!v||!o){showNotification("Please fill in Name, Port, and IP Address","warning");return}if(!d){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(w&&!x){showNotification("DNS Admin token required. Configure it in the Tokens menu first.","warning");return}const C={dns:null,caddy:null,dashboard:!1};try{if(w)try{await window.createDnsRecord(d,o,a),C.dns="created"}catch(N){throw console.error("DNS creation failed:",N),C.dns=N.message,new Error(`DNS creation failed: ${N.message}`)}else C.dns="skipped";const P=window.generateCaddyConfig({subdomain:d,port:v,ip:o,sslType:B,caName:L,existingCa:A,enableAuth:h,enableCors:T,customHeaders:$,upstreamPath:b,healthCheck:l,timeout:S,tailscaleOnly:I});try{const R=await(await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(d),upstream:`${o}:${v}`,config:P})})).json();if(R.success)C.caddy="added & reloaded";else throw console.error("Caddy configuration failed:",R.error),C.caddy=R.error||"failed",new Error(`Caddy configuration failed: ${R.error}`)}catch(N){throw console.error("Caddy API error:",N),C.caddy=N.message,new Error(`Caddy API error: ${N.message}`)}const O={name:e,subdomain:d,port:v,ip:o,logo:u||`/assets/${d}.png`,tailscaleOnly:I||!1};await window.addServiceToConfig(O),C.dashboard=!0;const D=[`DNS: ${C.dns==="created"?"\u2713":C.dns==="skipped"?"\u25CB":"\u2717"}`,`Caddy: ${C.caddy==="added & reloaded"?"\u2713":"\u2717"}`,`Dashboard: ${C.dashboard?"\u2713":"\u2717"}`];showNotification(`Service "${e}" created! ${D.join(" | ")} \u2014 ${buildServiceUrl(d)}${I?" (Tailscale)":""}`,"success",6e3),c(),window.buildGrid(),window.refreshAll()}catch(P){console.error("Error creating service:",P),showNotification(`Error creating "${e}": ${P.message}`,"error",6e3)}}document.getElementById("add-service")?.addEventListener("click",f),document.getElementById("add-service-cancel")?.addEventListener("click",c),document.getElementById("add-service-create")?.addEventListener("click",()=>{document.querySelector('input[name="service-type"]:checked')?.value==="external"?s():y()}),t(),m(),E(),document.getElementById("ssl-type-select")?.addEventListener("change",e=>{const d=document.getElementById("existing-ca-config"),v=document.getElementById("custom-ca-config");d.style.display="none",v.style.display="none",e.target.value==="existing-ca"?d.style.display="block":e.target.value==="custom-ca"&&(v.style.display="block"),g()}),document.getElementById("refresh-cas")?.addEventListener("click",async()=>{const e=document.getElementById("refresh-cas"),d=e.textContent;e.textContent="\u231B Loading...",e.disabled=!0;try{const v=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(v),e.textContent="\u2705 Refreshed"}catch(v){e.textContent="\u274C Failed",console.error("Failed to refresh CAs:",v)}setTimeout(()=>{e.textContent=d,e.disabled=!1},2e3)}),document.getElementById("create-dns-record")?.addEventListener("change",e=>{const d=document.getElementById("dns-config");d.style.display=e.target.checked?"block":"none"}),["service-subdomain-input","service-ip-input","service-port-input","ca-name-input","existing-ca-select","enable-auth","enable-cors","custom-headers-input","upstream-path-input","health-check-input","timeout-input"].forEach(e=>{const d=document.getElementById(e);d&&(d.addEventListener("input",g),d.addEventListener("change",g))});function k(){const e=safeGet("custom-services");if(e)try{JSON.parse(e).forEach(v=>{window.APPS.find(o=>o.id===v.id)||window.APPS.push(v)})}catch(d){console.warn("Failed to load custom services:",d)}}k(),window.openAddServiceModal=f,window.closeAddServiceModal=c})(); diff --git a/status/dist/features.js b/status/dist/features.js new file mode 100644 index 0000000..0c942b5 --- /dev/null +++ b/status/dist/features.js @@ -0,0 +1,1367 @@ +(function(){injectModal("logo-modal",`
+
+

Dashboard Settings

+

+ Customize your dashboard's appearance and system preferences. +

+ +
+ + +

Shown in browser tab and header (max 50 characters)

+
+ +
+ +
+ +

Separate logos for dark and light themes, or use the same for both.

+
+ +
+
+ Dark theme logo +

Dark themes

+
+
+ Light theme logo +

Light themes

+
+
+

Using default logos

+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + + +
+ +
+ + + +
+
+ +
+ +
+ +
+ Current favicon + Using DashCaddy favicon +
+ +

Upload PNG or SVG - automatically converted to ICO

+
+ +
+ +
+ + +

Used by all deployed containers. Changes apply to new deployments.

+
+ +
+ + + +
+
+
`);const y=document.getElementById("logo-modal"),h=document.getElementById("logo-preview-dark"),L=document.getElementById("logo-preview-light"),T=document.getElementById("logo-status"),B=document.getElementById("logo-same-both"),N=document.getElementById("logo-dual-uploads"),D=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),$=document.getElementById("logo-upload-light"),P=document.getElementById("logo-upload-single"),C=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),x=document.querySelector(".top-row"),O=document.getElementById("dashboard-title"),z=DC.NAME;let S=null,E=null,k=null,b="left",m=z;B?.addEventListener("change",()=>{B.checked?(N.style.display="none",D.style.display="",S=null,E=null):(N.style.display="flex",D.style.display="none",k=null)});function f(o,s){if(!o||!o.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const u=new FileReader;u.onload=l=>s(l.target.result),u.readAsDataURL(o)}A?.addEventListener("change",o=>{f(o.target.files[0],s=>{S=s,h.src=s,T.textContent="New dark logo ready to save"})}),$?.addEventListener("change",o=>{f(o.target.files[0],s=>{E=s,L.src=s,T.textContent="New light logo ready to save"})}),P?.addEventListener("change",o=>{f(o.target.files[0],s=>{k=s,h.src=s,L.src=s,T.textContent="New logo ready to save (both themes)"})});function c(o){x.setAttribute("data-logo-pos",o),document.querySelectorAll(".logo-pos-btn").forEach(s=>{s.style.background=s.dataset.pos===o?"var(--accent)":"var(--card-bg)",s.style.color=s.dataset.pos===o?"white":"var(--fg)"})}function d(o){m=o||z,document.title=m;const s=document.querySelector(".dashboard-title");s&&(s.textContent=m)}async function a(){try{const o=await fetch("/api/v1/logo");if(o.ok){const s=await o.json();s.customLogoDark&&(C.src=s.customLogoDark,h.src=s.customLogoDark),s.customLogoLight&&(H.src=s.customLogoLight,L.src=s.customLogoLight),!s.customLogoDark&&!s.customLogoLight&&s.customLogo&&(C.src=s.customLogo,H.src=s.customLogo,h.src=s.customLogo,L.src=s.customLogo),s.isDefault||(T.textContent="Using custom logo"),s.position&&(b=s.position,c(s.position)),s.dashboardTitle&&d(s.dashboardTitle)}}catch(o){console.warn("Could not load custom logo:",o.message)}}document.querySelectorAll(".logo-pos-btn").forEach(o=>{o.addEventListener("click",()=>{b=o.dataset.pos,c(b)})}),document.getElementById("brand")?.addEventListener("click",()=>{S=null,E=null,k=null,A&&(A.value=""),$&&($.value=""),P&&(P.value=""),B&&(B.checked=!1),N.style.display="flex",D.style.display="none",h.src=C.src,L.src=H.src;const o=C.src.includes("custom-logo")||H.src.includes("custom-logo");T.textContent=o?"Using custom logo":"Using default logos",c(b),O.value=m,y.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const o=O.value.trim()||z,s={position:b,dashboardTitle:o};B?.checked&&k?(s.dataDark=k,s.dataLight=k):(S&&(s.dataDark=S),E&&(s.dataLight=E));const u=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});if(u.ok){const l=await u.json(),g="?t="+Date.now();l.pathDark&&(C.src=l.pathDark+g,h.src=l.pathDark+g),l.pathLight&&(H.src=l.pathLight+g,L.src=l.pathLight+g),c(b),d(o),y.classList.remove("show")}else{const l=await u.json();showNotification("Failed to save: "+l.error,"error")}}catch(o){showNotification("Error saving: "+o.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? + +This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(C.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",h.src="/assets/dashcaddy-logo-dark.png",L.src="/assets/dashcaddy-logo-light.png",T.textContent="Using default logos",S=null,E=null,k=null,O.value=z,d(z),b="left",c("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const u=document.querySelector('link[rel="icon"]'),l=document.getElementById("favicon-preview"),g=document.getElementById("favicon-status");u&&(u.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),l&&(l.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),g&&(g.textContent="Using DashCaddy favicon"),r=null}}catch(o){showNotification("Error resetting branding: "+o.message,"error")}}),wireModal(y,document.getElementById("logo-cancel"));const n=document.getElementById("favicon-preview"),e=document.getElementById("favicon-status"),t=document.getElementById("favicon-upload"),i=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(i.rel="icon",i.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(i));async function p(){try{const o=await fetch("/api/v1/favicon");if(o.ok){const s=await o.json();s.customFavicon&&(i.href=s.customFavicon+"?t="+Date.now(),n.src=s.customFavicon+"?t="+Date.now(),e.textContent="Using custom favicon")}}catch(o){console.warn("Could not load custom favicon:",o.message)}}t?.addEventListener("change",o=>{const s=o.target.files[0];if(!s)return;if(!s.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),t.value="";return}const u=new FileReader;u.onload=l=>{r=l.target.result,n.src=r,e.textContent="New favicon ready to save"},u.readAsDataURL(s)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const o=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(o.ok){const s=await o.json();i.href=s.path+"?t="+Date.now(),n.src=s.path+"?t="+Date.now(),e.textContent="Using custom favicon",r=null}else{const s=await o.json();showNotification("Failed to save favicon: "+s.error,"error")}}catch(o){showNotification("Error saving favicon: "+o.message,"error")}}),p(),a();const v=document.getElementById("settings-timezone");v&&(new MutationObserver(()=>{y.classList.contains("show")&&v.options.length===0&&(async()=>{let s;try{const u=await fetch("/api/v1/config");u.ok&&(s=(await u.json()).timezone)}catch{}window.populateTimezoneSelect(v,s)})()}).observe(y,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const s=v.value;if(s)try{const u=await fetch("/api/v1/config");if(!u.ok)return;const l=await u.json();l.timezone=s,l.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)})}catch(u){console.warn("Failed to save timezone:",u.message)}}))})(),window.populateTimezoneSelect=function(y,h){const L=Intl.supportedValuesOf("timeZone"),T=h||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";y.innerHTML="";for(const B of L){const N=document.createElement("option");N.value=B,N.textContent=B.replace(/_/g," "),B===T&&(N.selected=!0),y.appendChild(N)}},(function(){let y="homelab",h=null;async function L(){try{const f=await fetch("/api/v1/config");if(f.ok&&(h=await f.json(),h&&h.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(f){console.warn("Could not fetch server config, checking localStorage fallback:",f.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}L();const T=document.getElementById("setup-timezone");T&&window.populateTimezoneSelect(T);function B(m){document.querySelectorAll(".setup-step").forEach(c=>{c.style.display="none"});const f=document.getElementById(m);f&&(f.style.display="block")}function N(){const m=document.getElementById("setup-summary-content");if(!m)return;let f='
';if(y==="homelab"){const d=document.getElementById("setup-tld")?.value?.trim()||".home",a=document.getElementById("setup-ca-name")?.value?.trim()||"",n=document.getElementById("setup-dns-ip")?.value?.trim()||"",e=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;f+=` +
+

Home Lab Configuration

+
+
TLD: ${d}
+
Certificate Authority: ${a}
+
DNS Server: ${n}:${e}
+
Example URLs: https://uptime${d}, https://nextcloud${d}
+
+
+ `}else if(y==="simple"){const d=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";f+=` +
+

Simple Setup

+
+
Access Method: IP:Port only
+
Default IP: ${d}
+
SSL: None (HTTP only)
+
Example URLs: http://${d}:8080, http://${d}:3000
+
+
+ `}else if(y==="public"){const d=document.getElementById("setup-public-domain")?.value?.trim()||"",a=document.getElementById("setup-public-email")?.value?.trim()||"",n=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",e=n==="subdirectory"?`https://${d}/sonarr, https://${d}/grafana`:`https://sonarr.${d}, https://grafana.${d}`;f+=` +
+

Public Server

+
+
Domain: ${d}
+
SSL: Let's Encrypt
+
Email: ${a}
+
Routing: ${n==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}
+
Example URLs: ${e}
+
+
+ `}const c=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f+=` +
+
Timezone: ${c.replace(/_/g," ")}
+
+ `,f+="
",m.innerHTML=f,B("setup-step-summary")}async function D(m){try{const f=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)});return f.ok?(await f.json(),!0):(console.error("Failed to save config to server:",f.status),!1)}catch(f){return console.error("Error saving config to server:",f),!1}}async function A(){const m={setupComplete:!0,configurationType:y,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};y==="homelab"?(m.tld=document.getElementById("setup-tld")?.value?.trim()||".home",m.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",m.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},m.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):y==="simple"?(m.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",m.defaults={dnsType:"none",sslType:"none",targetIP:m.defaultIP}):y==="public"&&(m.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",m.email=document.getElementById("setup-public-email")?.value?.trim()||"",m.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m.defaults={dnsType:m.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const f=await D(m);safeSet("dashcaddy-config",JSON.stringify(m)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const c=y==="homelab"?"Professional Home Lab":y==="simple"?"Simple Setup":"Public Server",d=f?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${c}. Settings saved to: ${d}`,"success",5e3),setTimeout(()=>location.reload(),500)}const $=document.getElementById("setup-step-1-next");$&&($.onclick=function(m){m.preventDefault();const f=document.querySelector('input[name="config-type"]:checked');f&&(y=f.value),B(y==="homelab"?"setup-step-homelab":y==="simple"?"setup-step-simple":y==="public"?"setup-step-public":"setup-step-homelab")});const P=document.getElementById("setup-skip");P&&(P.onclick=async function(m){m.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await D({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const C=document.getElementById("setup-tld");C&&(C.oninput=function(m){const f=m.target.value||".home",c=document.getElementById("tld-preview"),d=document.getElementById("tld-preview-2");c&&(c.textContent=f),d&&(d.textContent=f)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(m){m.preventDefault(),B("setup-step-1")});const x=document.getElementById("setup-homelab-next");x&&(x.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-tld")?.value?.trim()||"",c=document.getElementById("setup-ca-name")?.value?.trim()||"",d=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!f||!f.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!c){showNotification("Please enter a Certificate Authority name","warning");return}if(!d){showNotification("Please enter your DNS server IP address","warning");return}N()});const O=document.getElementById("setup-simple-back");O&&(O.onclick=function(m){m.preventDefault(),B("setup-step-1")});const z=document.getElementById("setup-simple-next");z&&(z.onclick=function(m){m.preventDefault(),N()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(m){m.onchange=function(){var f=document.getElementById("dns-requirement-note");f&&(f.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const S=document.getElementById("setup-public-back");S&&(S.onclick=function(m){m.preventDefault(),B("setup-step-1")});const E=document.getElementById("setup-public-next");E&&(E.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-public-domain")?.value?.trim()||"",c=document.getElementById("setup-public-email")?.value?.trim()||"";if(!f){showNotification("Please enter your domain name","warning");return}if(!c||!c.includes("@")){showNotification("Please enter a valid email address","warning");return}N()});const k=document.getElementById("setup-summary-back");k&&(k.onclick=function(m){m.preventDefault(),y==="homelab"?B("setup-step-homelab"):y==="simple"?B("setup-step-simple"):y==="public"&&B("setup-step-public")});const b=document.getElementById("setup-finish");b&&(b.onclick=function(m){m.preventDefault(),A()}),window.getGlobalConfig=async function(){try{const f=await fetch("/api/v1/config");if(f.ok){const c=await f.json();if(c&&c.setupComplete)return c}}catch{console.warn("Could not fetch config from server")}const m=safeGet("dashcaddy-config");return m?JSON.parse(m):{setupComplete:!1,configurationType:"homelab",tld:".home",caName:"",defaults:{dnsType:"private",sslType:"internal",targetIP:"localhost"}}},window.resetSetupWizard=async function(){if(confirm("Reset DashCaddy configuration? This will show the setup wizard again.")){try{await secureFetch("/api/v1/config",{method:"DELETE"})}catch{console.warn("Could not delete server config")}safeRemove("dashcaddy-setup"),safeRemove("dashcaddy-config"),location.reload()}}})(),(function(){injectModal("app-selector-modal",`
+
+

Choose an App

+
+
+ +
+
+
`),injectModal("app-deploy-modal",`
+
+

Deploy Application

+ +
+ +
+ + +
+ Your app will be available at: uptime.home +
+ +
+ + +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+
+ + + + + + + + +
+ +
+ +
+ Checking Tailscale status... +
+
+
+ + +
+ \u2699\uFE0F Advanced Options +
+
+ + +
+
+ + +
+ Use 'localhost' for same-host containers, or specific IP for remote services +
+
+ +
+
+
+ +
+ + +
+
+
`);const y="custom-apps";let h=null,L=null;const T=document.getElementById("app-selector-modal"),B=document.getElementById("app-selector-grid");async function N(){try{const t=await(await fetch("/api/v1/apps/templates")).json();if(t.success)return h=t.templates,L=t.categories,!0}catch(e){console.error("Failed to fetch app templates:",e)}return!1}async function D(e){try{return await(await fetch(`/api/v1/apps/ports/${e}/check`)).json()}catch(t){return console.error("Failed to check port:",t),{available:!0}}}async function A(e){try{const i=await(await fetch(`/api/v1/apps/ports/${e}/suggest`)).json();if(i.success)return i.suggestedPort}catch(t){console.error("Failed to get suggested port:",t)}return e}async function $(){if(B.innerHTML='
Loading app templates...
',!h&&!await N()){B.innerHTML='
Failed to load app templates. Please try again.
';return}B.innerHTML="";const e={};for(const[i,r]of Object.entries(h)){const p=r.category||"Other";e[p]||(e[p]=[]),e[p].push({id:i,...r})}const t=L?Object.keys(L):Object.keys(e).sort();for(const i of t){const r=e[i];if(!r||r.length===0)continue;r.sort((o,s)=>(s.popularity||0)-(o.popularity||0));const p=document.createElement("div");p.className="app-category-header";const v=L?.[i]||{};p.innerHTML=`${escapeHtml(v.icon||"")} ${escapeHtml(i)}`,v.color&&(p.style.borderBottomColor=v.color),B.appendChild(p),r.forEach(o=>{const s=document.createElement("div");s.className="app-option";const u=o.isDashboardWidget,l=u&&safeGet("widget-"+o.id+"-enabled")!=="false",g=u?`
${l?"ON":"OFF"}
`:"",I=!u&&o.difficulty?`
${escapeHtml(o.difficulty)}
`:"";s.innerHTML=` +
${escapeHtml(o.icon||"\u{1F4E6}")}
+
${escapeHtml(o.name)}
+
${escapeHtml(o.description||"")}
+ ${g}${I} + `,u?s.onclick=()=>P(o,s):s.onclick=()=>C(o),B.appendChild(s)})}window.renderRecipeCards&&await window.renderRecipeCards(B)}function P(e,t){const i="widget-"+e.id+"-enabled",p=!(safeGet(i)!=="false");safeSet(i,String(p));const v=e.widgetSelector;if(v){const s=document.querySelector(v);s&&(s.style.display=p?"":"none")}const o=t.querySelector('div[style*="border-radius: 4px"]');o&&(o.textContent=p?"ON":"OFF",o.style.background=p?"#2ecc7130":"#e74c3c30",o.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${e.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function C(e){const t=document.getElementById("app-deploy-modal"),i=document.getElementById("app-deploy-title"),r=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),v=document.getElementById("deploy-ip"),o=document.getElementById("deploy-port"),s=document.getElementById("deploy-tailscale-only"),u=document.getElementById("tailscale-status");try{const U=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e.id})})).json();if(U.success&&U.exists){const J=U.container;confirm(`Found existing ${e.name} container: + +Container: ${J.name} +Status: ${J.status} +Port: ${J.primaryPort||"N/A"} + +Would you like to use this existing container? + +Click OK to configure DNS/Caddy for the existing container. +Click Cancel to deploy a new container.`)&&(e._useExisting=!0,e._existingContainer=J)}}catch{}i.textContent=`Deploy ${e.name}`;const l=e.subdomain||e.id.replace(/-/g,"");r.value=l;const g=document.getElementById("subpath-compat-warning");if(g)if(SITE.routingMode==="subdirectory"){const F=e.subpathSupport||"strip";F==="none"?(g.style.display="block",g.innerHTML=''+e.name+" does not support subdirectory mode. It may not work correctly at a subpath."):F==="strip"?(g.style.display="block",g.innerHTML='ⓘ '+e.name+" has unverified subdirectory support. It may require additional configuration."):g.style.display="none"}else g.style.display="none";const I=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),w=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),M=document.querySelector(`input[name="dns-type"][value="${I}"]`),R=document.querySelector(`input[name="ssl-type"][value="${w}"]`);M?M.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,R?R.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,v.value=SITE.defaults.targetIP||"localhost",s.checked=!1;const q=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),W=_?.querySelector("div");if(_&&W&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const F=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,U=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;F&&!F.dataset.moved&&(W.appendChild(F),F.dataset.moved="1"),U&&!U.dataset.moved&&(W.appendChild(U),U.dataset.moved="1")}const j=document.getElementById("media-path-section"),V=document.getElementById("deploy-media-path"),se=document.getElementById("media-path-description");if(e.mediaMount){j.style.display="block",V.value="",V.placeholder="/media/Movies, /media/TVShows or click Browse";const F=document.getElementById("detected-mounts-container"),U=document.getElementById("detected-mounts-list");try{const G=await(await fetch("/api/v1/media/detected-mounts")).json();if(G.success&&G.mounts.length>0){F.style.display="block",U.innerHTML="";const ee=[...new Set(G.mounts.map(Z=>Z.hostPath))];V.value=ee.join(", "),G.mounts.forEach(Z=>{const Y=document.createElement("button");Y.type="button";const le=ee.includes(Z.hostPath);Y.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Y.innerHTML=`${escapeHtml(Z.folderName)}
from ${escapeHtml(Z.sourceImage)}`,Y.title=`${Z.hostPath} (from ${Z.sourceContainer})`,Y.onclick=()=>{const re=V.value.split(",").map(de=>de.trim()).filter(de=>de),ce=re.indexOf(Z.hostPath);ce>=0?(re.splice(ce,1),Y.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(re.push(Z.hostPath),Y.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),V.value=re.join(", ")},U.appendChild(Y)})}else F.style.display="none"}catch{F.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(V)}}else j.style.display="none",V.value="",document.getElementById("detected-mounts-container").style.display="none";const K=document.getElementById("plex-claim-section");K&&(e.id==="plex"||e.claimToken?(K.style.display="block",document.getElementById("deploy-plex-claim").value=""):K.style.display="none");const Q=document.getElementById("volume-mounts-section"),te=document.getElementById("volume-mounts-list");if(te.innerHTML="",e.docker?.volumes?.length){const F=e.mediaMount?.containerPath,U=e.docker.volumes.filter(J=>!J.includes("{{MEDIA_PATH}}")&&!(F&&J.endsWith(":"+F)));U.length>0?(Q.style.display="block",U.forEach((J,G)=>{const[ee,Z]=J.split(":"),Y=document.createElement("div");Y.style.cssText="display: flex; gap: 6px; align-items: center;",Y.innerHTML=` + + \u2192 ${Z} + + `,te.appendChild(Y),Y.querySelector(".vol-browse-btn").onclick=()=>{const le=Y.querySelector(".vol-host-path");openFolderBrowser(le)}})):Q.style.display="none"}else Q.style.display="none";const ne=e.defaultPort||8080;o.value="",o.placeholder=`Default: ${ne}`;let X=document.getElementById("deploy-port-status");X||(X=document.createElement("div"),X.id="deploy-port-status",X.style.cssText="font-size: 0.8rem; margin-top: 4px;",o.parentNode.appendChild(X));async function oe(){const F=o.value||ne;X.innerHTML='Checking port...';const U=await D(F);if(U.available)X.innerHTML=`Port ${escapeHtml(String(F))} is available`;else{const J=await A(ne);X.innerHTML=` + Port ${escapeHtml(F)} in use by ${escapeHtml(U.conflict?.usedBy||"unknown")} + `;const G=document.createElement("button");G.type="button",G.textContent=`Use ${J}`,G.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",G.onclick=()=>{document.getElementById("deploy-port").value=J,X.innerHTML=`Using suggested port ${escapeHtml(String(J))}`},X.appendChild(G)}}let ie;o.oninput=function(){clearTimeout(ie),ie=setTimeout(oe,500)},oe();try{const U=await(await fetch("/api/v1/tailscale/status")).json();U.success&&U.installed&&U.connected?u.innerHTML=` + Connected + ${U.self?.hostname} (${U.self?.ip}) + | ${U.deviceCount} devices + `:U.installed?u.innerHTML='Not connected':(u.innerHTML='Not available',s.disabled=!0)}catch{u.innerHTML='Could not check status'}function ae(){const F=r.value||"subdomain",U=document.querySelector('input[name="dns-type"]:checked').value,J=document.querySelector('input[name="ssl-type"]:checked').value;let G="";if(SITE.routingMode==="subdirectory"&&SITE.domain)G=`https://${SITE.domain}/${F}`;else if(U==="private")G=`${J==="none"?"http":"https"}://${buildDomain(F)}`;else if(U==="public"){const ee=J==="none"?"http":"https",Z=SITE.domain||F;G=SITE.domain?`${ee}://${F}.${SITE.domain}`:`${ee}://${F}`}else{const ee=o.value||e.defaultPort||DC.DEFAULTS.SERVICE_PORT;G=`http://${v.value}:${ee}`}p.textContent=G}r.oninput=ae,v.oninput=ae,o.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(F=>{F.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(F=>{F.onchange=ae}),ae(),T.classList.remove("show"),t.classList.add("show"),t.dataset.appTemplate=JSON.stringify(e)}async function H(e){const t=e.appTemplate,i=safeGetJSON(y,[]),r=t._useExisting&&t._existingContainer,p=i.find(v=>v.id===e.subdomain);if(!(p&&!r&&!confirm(`An app with subdomain "${e.subdomain}" already exists. Redeploy?`))){if(p){const v=i.indexOf(p);i.splice(v,1),safeSet(y,JSON.stringify(i))}if(r)e.port=t._existingContainer.primaryPort;else{const v=e.port||t.defaultPort||8080;showNotification(`Checking port ${v} availability...`,"info",0);const o=await D(v);if(!o.available){const s=await A(t.defaultPort||8080);if(confirm(`Port ${v} is already in use by ${o.conflict?.usedBy||"another container"}. + +Would you like to use port ${s} instead?`))e.port=s;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(r?`Configuring ${t.name} with existing container...`:`Deploying ${t.name}...`,"info",0);try{const v={appId:t.id,config:{subdomain:e.subdomain,ip:e.ip,createDns:e.dnsType==="private",port:e.port||t.defaultPort||null,sslType:e.sslType,dnsType:e.dnsType,tailscaleOnly:e.tailscaleOnly||!1,mediaPath:e.mediaPath||null,plexClaimToken:e.plexClaimToken||null,customVolumes:e.customVolumes||null}};r&&(v.config.useExisting=!0,v.config.existingContainerId=t._existingContainer.id,v.config.existingPort=t._existingContainer.primaryPort,!e.port&&t._existingContainer.primaryPort&&(v.config.port=t._existingContainer.primaryPort));const s=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)})).json();if(s.success){const u={id:e.subdomain,name:t.name,logo:`/assets/${t.id}.png`,containerId:s.containerId,url:s.url,ip:e.ip,appTemplate:t.id,tailscaleOnly:e.tailscaleOnly||!1};i.push(u),safeSet(y,JSON.stringify(i)),window.APPS&&!window.APPS.some(g=>g.id===t.id)&&(window.APPS.push(u),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let l=s.usedExisting?`${t.name} configured with existing container! +URL: ${s.url}`:`${t.name} deployed successfully! +URL: ${s.url}`;s.warning&&(l+=` + +\u26A0 Warning: ${s.warning}`),showNotification(l,"success",8e3),delete t._useExisting,delete t._existingContainer,s.url&&s.url.startsWith("https://")&&x(s.url,t.name),s.setupInstructions&&s.setupInstructions.length>0&&setTimeout(()=>{const g=s.setupInstructions.join(` +`);showNotification(`Setup Instructions for ${t.name}: ${g}`,"info",1e4)},1e3)}else throw new Error(s.error||"Deployment failed")}catch(v){console.error("Deployment error:",v),showNotification(`Failed to deploy ${t.name}: ${v.message}`,"error",8e3)}}}async function x(e,t){showNotification(`\u23F3 Generating SSL certificate for ${t}...`,"warning",6e4);let i=0;const r=12,p=async()=>{i++;try{const v=await fetch(e,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${t} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return i{window.APPS.some(i=>i.id===t.id)||window.APPS.push(t)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{$(),T.classList.add("show")}),wireModal(T,document.getElementById("app-selector-cancel"));const z=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{z.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const e=JSON.parse(z.dataset.appTemplate),t=document.getElementById("deploy-media-path").value.trim(),i=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{i.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const r={appTemplate:e,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:t||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:i.length>0?i:null};if(!r.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(e.mediaMount?.required&&!t){showNotification("Please enter a media library path for this application","warning");return}z.classList.remove("show"),H(r)}),wireModal(z);const S=document.getElementById("folder-browser-modal"),E=document.getElementById("folder-browser-path"),k=document.getElementById("folder-browser-list"),b=document.getElementById("folder-browser-selected"),m=document.getElementById("folder-browser-selected-list");let f="",c=[],d=null;window.openFolderBrowser=function(e){d=e,c=e.value.split(",").map(t=>t.trim()).filter(t=>t),f="",n(),a(""),S.classList.add("show")};async function a(e){E.textContent=e||"Select a drive...",k.innerHTML='
Loading...
';try{const i=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(e)}`)).json();if(!i.success){k.innerHTML=`
Error: ${escapeHtml(i.error)}
`;return}f=i.path||"",E.textContent=f||"Select a drive...";let r="";i.parent&&i.parent!==i.path&&(r+=`
+ \u2B06\uFE0F + .. Parent Directory +
`),i.items.length===0&&!i.parent?r+='
No browseable drives configured. Check your docker-compose.yml volume mounts.
':i.items.length===0?r+='
No subfolders found
':i.items.forEach(p=>{const v=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",o=c.includes(p.path),s=o?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";r+=`
+ ${v} + ${escapeHtml(p.name)} + ${o?'\u2713':""} +
`}),k.innerHTML=r,k.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{a(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const v=c.includes(p.dataset.path);p.style.background=v?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(t){k.innerHTML=`
Failed to load: ${escapeHtml(t.message)}
`}}function n(){if(c.length===0){b.style.display="none";return}b.style.display="block",m.innerHTML=c.map(e=>` + + ${escapeHtml(e)} + + + `).join("")}window.removeSelectedFolder=function(e){c=c.filter(t=>t!==e),n(),a(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!c.includes(f)&&(c.push(f),n(),a(f))}),wireModal(S,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{d&&(d.value=c.join(", ")),S.classList.remove("show")}),O()})(),(function(){injectModal("recipe-deploy-modal",`
+
+

Deploy Recipe

+ + +
+
1 Components
+
2 Configuration
+
3 Review
+
4 Progress
+
+ + +
+ +
+
+ + + + + + + + + + +
+ + + +
+
+
`);let y=null,h=null,L=null,T=1,B=!1;const N=document.getElementById("recipe-deploy-modal"),D=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),$=document.getElementById("recipe-next");wireModal(N,D);async function P(){try{const c=await fetch("/api/v1/recipes/templates"),d=await c.json();if(d.success)return y=d.templates,h=d.categories,!0;if(c.status===403)return B=!1,!1}catch(c){console.warn("Failed to fetch recipe templates:",c.message)}return!1}async function C(){try{B=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{B=!1}return B}window.renderRecipeCards=async function(c){await C();let d;if(B&&y?d=y:d=H(),!d||d.length===0)return;const a=document.createElement("div");a.className="app-category-header",a.innerHTML="\u{1F9EA} Recipes",a.style.borderBottomColor="#8e44ad",c.appendChild(a);const n=Array.isArray(d)?d:Object.values(d);n.sort((e,t)=>(t.popularity||0)-(e.popularity||0));for(const e of n){const t=document.createElement("div");t.className="app-option",t.style.position="relative";const i=`
${e.componentCount||e.components?.length||"?"} apps
`,r=B?"":'
PREMIUM
';t.innerHTML=` + ${r} +
${escapeHtml(e.icon||"\u{1F9EA}")}
+
${escapeHtml(e.name)}
+
${escapeHtml(e.description||"")}
+ ${i} + `,t.onclick=()=>{if(!B){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}x(e)},c.appendChild(t)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function x(c){L=c,T=1;const d=document.getElementById("app-selector-modal");d&&d.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${c.name}`,O(),z(),N.classList.add("show")}function O(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(c=>{const d=parseInt(c.dataset.step);c.classList.toggle("active",d===T),c.classList.toggle("completed",d1&&T<4?"":"none",T===4?($.style.display="none",D.textContent="Close"):T===3?($.textContent="\u{1F680} Deploy",$.style.display="",D.textContent="Cancel"):($.textContent="Next",$.style.display="",D.textContent="Cancel")}function z(){const c=document.getElementById("recipe-component-list");c.innerHTML="";const d=L.components||[];for(const a of d){const n=document.createElement("div");n.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const e=a.required,t=a.internal;n.innerHTML=` + +
+
${escapeHtml(a.role||a.id)}
+
+ ${a.templateRef?escapeHtml(a.templateRef):"Built-in"} + ${e?'Required':'Optional'} + ${t?'(Internal)':""} +
+ ${a.note?`
\u26A0 ${escapeHtml(a.note)}
`:""} +
+ `,c.appendChild(n)}}function S(){const c=document.getElementById("recipe-volumes-section"),d=document.getElementById("recipe-volume-list"),a=L.sharedVolumes;if(a&&Object.keys(a).length>0){c.style.display="",d.innerHTML="";for(const[n,e]of Object.entries(a)){const t=document.createElement("div");t.style.cssText="display: grid; gap: 4px;",t.innerHTML=` + + +
${escapeHtml(e.description||"")}
+ `,d.appendChild(t)}}else c.style.display="none"}function E(){const c=document.getElementById("recipe-review-content"),d=k(),a=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),n={};a.forEach(r=>{n[r.dataset.volumeKey]=r.value});const e=document.getElementById("recipe-timezone").value||"UTC",t=document.getElementById("recipe-ip").value||"host.docker.internal",i=document.getElementById("recipe-tailscale").checked;c.innerHTML=` +
${escapeHtml(L.name)}
+
${escapeHtml(L.description||"")}
+ +
+ Components (${d.length}): +
+ ${d.map(r=>`
+ \u2022 ${escapeHtml(r.role||r.id)} ${r.internal?'(internal)':""} +
`).join("")} +
+
+ + ${Object.keys(n).length>0?`
+ Volumes: + ${Object.entries(n).map(([r,p])=>`
${r}: ${escapeHtml(p)}
`).join("")} +
`:""} + +
+ Timezone: ${escapeHtml(e)} • IP: ${escapeHtml(t)} ${i?"• Tailscale only":""} +
+ + ${L.network?`
Docker network: ${escapeHtml(L.network.name)}
`:""} + `}function k(){const c=document.querySelectorAll("#recipe-component-list input[data-component-id]"),d=new Set;c.forEach(n=>{n.checked&&d.add(n.dataset.componentId)});const a=L.components||[];return a.filter(n=>n.required).forEach(n=>d.add(n.id)),a.filter(n=>d.has(n.id))}async function b(){const c=document.getElementById("recipe-progress-list"),d=document.getElementById("recipe-deploy-result");d.style.display="none",c.innerHTML="";const a=k();for(const i of a){const r=document.createElement("div");r.id=`recipe-progress-${i.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=` + \u23F3 + ${escapeHtml(i.role||i.id)} + Queued + `,c.appendChild(r)}const n=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),e={};n.forEach(i=>{e[i.dataset.volumeKey]=i.value});const t={selectedComponents:a.map(i=>i.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:e},componentOverrides:{}};for(const i of a)m(i.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:L.id,config:t})})).json();if(r.success){for(const p of r.deployed||[])m(p.id,"success",p.url?`Running \u2192 ${p.url}`:"Running");for(const p of r.errors||[])m(p.componentId,"error",p.error);d.style.display="",d.innerHTML=` +
+
${escapeHtml(r.message||"Deployed!")}
+ ${r.setupInstructions?`
+ Setup tips: +
    ${r.setupInstructions.map(p=>`
  • ${escapeHtml(p)}
  • `).join("")}
+
`:""} +
+ `,showNotification(`${L.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else d.style.display="",d.innerHTML=`
+ Deployment failed: ${escapeHtml(r.error||"Unknown error")} +
`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(i){d.style.display="",d.innerHTML=`
+ Network error: ${escapeHtml(i.message)} +
`}}function m(c,d,a){const n=document.getElementById(`recipe-progress-${c}`);if(!n)return;const e=n.querySelector(".recipe-progress-icon"),t=n.querySelector(".recipe-progress-status");d==="deploying"?(e.textContent="\u23F3",t.style.color="var(--accent)"):d==="success"?(e.textContent="\u2705",t.style.color="var(--ok-fg)"):d==="error"&&(e.textContent="\u274C",t.style.color="var(--bad-fg)"),t.textContent=a}$.addEventListener("click",()=>{if(T===3){T=4,O(),b();return}T<3&&(T++,O(),T===2&&S(),T===3&&E())}),A.addEventListener("click",()=>{T>1&&T<4&&(T--,O())}),window.groupRecipeCards=function(){const c=document.querySelectorAll(".service-card[data-recipe-id]");if(c.length===0)return;const d={};c.forEach(a=>{const n=a.dataset.recipeId;d[n]||(d[n]=[]),d[n].push(a)});for(const[a,n]of Object.entries(d))n.length<2||n.forEach((e,t)=>{if(e.style.borderLeft="3px solid rgba(142,68,173,0.5)",t===0){let i=e.querySelector(".recipe-group-label");i||(i=document.createElement("div"),i.className="recipe-group-label",i.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",i.textContent=a.replace(/-/g," "),e.style.position="relative",e.appendChild(i))}})},window.manageRecipe=async function(c,d){const a=`/api/v1/recipes/${c}/${d}`,n=d==="remove"?"DELETE":"POST",e=d==="remove"?`/api/v1/recipes/${c}`:a;if(!(d==="remove"&&!confirm(`Remove the entire ${c} recipe? This will delete all containers and configuration.`)))try{const i=await(await secureFetch(e,{method:n})).json();i.success?(showNotification(`Recipe ${d}: ${i.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${d} failed: ${i.error}`,"error",5e3)}catch(t){showNotification(`Network error: ${t.message}`,"error",5e3)}};const f=document.createElement("style");f.textContent=` + .recipe-step { + flex: 1; + text-align: center; + padding: 8px 4px; + font-size: 0.78rem; + color: var(--muted); + border-bottom: 2px solid var(--border); + transition: all 0.2s; + } + .recipe-step span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--border); + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + margin-right: 4px; + } + .recipe-step.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + .recipe-step.active span { + background: var(--accent); + color: #fff; + } + .recipe-step.completed { + color: var(--ok-fg); + border-bottom-color: var(--ok-fg); + } + .recipe-step.completed span { + background: var(--ok-fg); + color: #fff; + } + .recipe-step-panel { + min-height: 180px; + } + `,document.head.appendChild(f),C()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const y=document.getElementById("reload-caddy-top"),h=y.textContent;try{y.textContent="\u23F3 Reloading...",y.disabled=!0;const L=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),T=await L.json();if(L.ok&&T.success)y.textContent="\u2705 Reloaded!",setTimeout(()=>{y.textContent=h,y.disabled=!1},2e3);else throw new Error(T.error||"Reload failed")}catch(L){y.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${L.message}`,"error"),setTimeout(()=>{y.textContent=h,y.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'

\u{1F4CB} Error Logs

Loading error logs...
');const y=document.getElementById("error-log-modal"),h=document.getElementById("error-log-content"),L=document.getElementById("view-error-logs"),T=document.getElementById("error-log-refresh"),B=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close");async function D(){h.innerHTML='
Loading error logs...
';try{const P=await(await fetch("/api/v1/error-logs")).json();P.success&&P.logs?P.logs.length===0?h.innerHTML='
\u2705 No errors logged! Everything is working smoothly.
':h.innerHTML=P.logs.map(C=>` +
+ ${new Date(C.timestamp).toLocaleString()} + ERROR +
+ ${escapeHtml(C.context)}: ${escapeHtml(C.error)} + ${C.details?`
${escapeHtml(C.details)}`:""} +
+
+ `).join(""):h.innerHTML='
\u274C Failed to load error logs
'}catch($){h.innerHTML=`
\u274C Error loading logs: ${escapeHtml($.message)}
`}}async function A(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),D()):showNotification("\u274C Failed to clear logs","error",3e3)}catch($){showNotification(`\u274C Error: ${$.message}`,"error",3e3)}}L?.addEventListener("click",()=>{y.classList.add("show"),D()}),T?.addEventListener("click",D),B?.addEventListener("click",A),wireModal(y,N)})(),(function(){injectModal("arr-setup-modal",`
+
+

\u{1F3AC} Smart Arr Connect

+

+ Auto-discover and connect your entire media stack. +

+ + +
+
+ +
Scanning for services...
+
+ +
+ + + + + + + + + + + +
+ Where to find API keys:
+ Radarr/Sonarr/Prowlarr: Settings -> General -> Security -> API Key +
+ + + +
+
`);const y=document.getElementById("arr-setup-modal"),h=document.getElementById("arr-setup-btn"),L=document.getElementById("arr-setup-cancel"),T=document.getElementById("smart-connect-btn"),B=document.getElementById("smart-phase-detect"),N=document.getElementById("smart-phase-credentials"),D=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),$=document.getElementById("smart-detect-results"),P=document.getElementById("smart-credential-inputs"),C=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),x=document.getElementById("smart-plex-libraries"),O=document.getElementById("smart-retry-btn");let z=null;const S={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},E={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function k(n){B.style.display=n==="detect"?"block":"none",N.style.display=n==="credentials"?"block":"none",D.style.display=n==="progress"?"block":"none",A.style.display=n==="results"?"block":"none"}function b(n){const e={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},t=e[n]||e.not_found;return`${t.icon} ${t.text}`}async function m(){k("detect"),$.style.display="none";try{if(z=await(await fetch("/api/v1/arr/smart-detect")).json(),!z.success){$.innerHTML=`
Detection failed: ${escapeHtml(z.error)}
`,$.style.display="block";return}let e='
';for(const[i,r]of Object.entries(z.services)){const p=S[i]||"\u{1F4E6}",v=E[i]||i,o=r.source?`${escapeHtml(r.source)}`:"",s=r.version?`v${escapeHtml(r.version)}`:"",u=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'Key saved':"";e+=`
+ ${p} +
+
${v}
+
+ ${o} ${s} ${u} +
+
+ ${b(r.status)} +
`}e+="
";const t=z.summary;e+=`
+ ${escapeHtml(String(t.fullyConnected))}/${escapeHtml(String(t.totalDetected+(5-t.totalDetected)))} services detected · + ${escapeHtml(String(t.fullyConnected))} connected${t.needsApiKey>0?` · ${escapeHtml(String(t.needsApiKey))} needs API key`:""} +
`,$.innerHTML=e,$.style.display="block",f(z),setTimeout(()=>{k("credentials")},800)}catch(n){$.innerHTML=`
Error: ${escapeHtml(n.message)}
`,$.style.display="block"}}function f(n){let e="";const t=n.services,i=["radarr","sonarr","prowlarr"];for(const v of i){const o=t[v];if(!o||o.status==="not_found"&&!o.url)continue;const s=S[v],u=E[v],l=o.status==="connected";e+=`
+
+ ${s} + ${u} + + ${l?'✓ Connected':""} + +
+
+
+ + +
+
+ + +
+
+ +
`}const r=t.plex;if(r){const v=r.status==="connected";e+=`
+
+ \u{1F3AC} + Plex + ${b(r.status)} + ${escapeHtml(r.source||"")} +
+
`}const p=t.seerr;if(p){const v=p.status==="connected";let o="";if(p.configuredServices){const s=p.configuredServices;o=`
+ Configured: ${s.radarr?"✓ Radarr":"✗ Radarr"} · + ${s.sonarr?"✓ Sonarr":"✗ Sonarr"} · + ${s.plex?"✓ Plex":"✗ Plex"} +
`}e+=`
+
+ \u{1F4CB} + Seerr + ${b(p.status)} +
+ ${o} +
`}P.innerHTML=e}window.smartTestConnection=async function(n){const e=document.getElementById(`smart-${n}-url`),t=document.getElementById(`smart-${n}-key`),i=document.getElementById(`smart-${n}-status`),r=e?.value.trim(),p=t?.value.trim();if(!r||!p){i.innerHTML='Enter URL and API key';return}i.innerHTML='';try{const o=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:n,url:r,apiKey:p})})).json();o.success?i.innerHTML=`✓ ${escapeHtml(o.appName||"Connected")} v${escapeHtml(o.version||"")}`:i.innerHTML=`✗ ${escapeHtml(o.error)}`}catch(v){i.innerHTML=`✗ ${escapeHtml(v.message)}`}};async function c(){k("progress"),C.innerHTML='
Connecting services...
';const n={};for(const t of["radarr","sonarr","prowlarr"]){const i=document.getElementById(`smart-${t}-url`)?.value.trim(),r=document.getElementById(`smart-${t}-key`)?.value.trim();r&&i?n[t]={apiKey:r,url:i}:r&&(n[t]={apiKey:r})}const e={services:Object.keys(n).length>0?n:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const i=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).json();let r="";for(const p of i.steps||[]){const v=p.status==="success"?'':'',o=p.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`
+ ${v} + ${escapeHtml(p.step)} + ${escapeHtml(p.details||"")} +
`}C.innerHTML=r,setTimeout(()=>d(i),500)}catch(t){C.innerHTML=`
Connection error: ${escapeHtml(t.message)}
`}}function d(n){k("results");const e=n.summary||{},t=e.failed===0&&e.succeeded>0,i=t?"var(--ok-fg)":"#f39c12",r=t?"✓":"⚠",p=t?"All Connected!":`${escapeHtml(String(e.succeeded))}/${escapeHtml(String(e.totalSteps))} Steps Succeeded`;let v=`
+
${r}
+
${p}
+
${escapeHtml(String(e.succeeded))} succeeded, ${escapeHtml(String(e.failed))} failed
+
`;v+='
';for(const o of n.steps||[]){const s=o.status==="success"?'':'';v+=`
+ ${s} ${escapeHtml(o.step)} ${escapeHtml(o.details||"")} +
`}v+="
",H.innerHTML=v,O.style.display=e.failed>0?"block":"none",n.steps?.some(o=>o.step.includes("Plex")&&o.status==="success")&&a()}async function a(){try{const e=await(await fetch("/api/v1/plex/libraries")).json();if(e.success&&e.libraries?.length>0){let t=`
+

\u{1F3AC} ${escapeHtml(e.serverName)} Libraries

+
`;for(const i of e.libraries){const r=i.type==="movie"?"\u{1F3AC}":i.type==="show"?"\u{1F4FA}":"\u{1F3B5}";t+=`
+ ${r} ${escapeHtml(i.title)} + ${escapeHtml(String(i.count))} items +
`}t+="
",x.innerHTML=t,x.style.display="block"}}catch{}}h?.addEventListener("click",()=>{y.classList.add("show"),x.style.display="none",m()}),wireModal(y,L),T?.addEventListener("click",c),O?.addEventListener("click",c)})(),(function(){injectModal("notifications-modal",`
+
+

\u{1F514} Notification Settings

+ + +
+ +
+ + +

Notification Providers

+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +

Health Monitoring

+
+ +
+ + + +
+
+ Last check: Never +
+
+ + +

Events to Notify

+
+ + + + +
+ + +

Notification History

+
+
No notifications yet
+
+ + + +
+
`);const y=document.getElementById("notifications-modal"),h=document.getElementById("manage-notifications"),L=document.getElementById("notifications-save"),T=document.getElementById("notifications-cancel");["discord","telegram","ntfy"].forEach(C=>{const H=document.getElementById(`${C}-enabled`),x=document.getElementById(`${C}-config`);H?.addEventListener("change",()=>{x.style.display=H.checked?"block":"none"})});const B=document.getElementById("health-check-enabled"),N=document.getElementById("health-check-config");B?.addEventListener("change",()=>{N.style.opacity=B.checked?"1":"0.5"});async function D(){try{const H=await(await fetch("/api/v1/notifications/config")).json();if(H.success){const x=H.config;document.getElementById("notifications-enabled").checked=x.enabled,document.getElementById("discord-enabled").checked=x.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=x.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=x.providers?.ntfy?.enabled||!1,document.getElementById("discord-config").style.display=x.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=x.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=x.providers?.ntfy?.enabled?"block":"none",x.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=x.providers.ntfy.serverUrl),document.getElementById("health-check-enabled").checked=x.healthCheck?.enabled||!1,x.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=x.healthCheck.intervalMinutes),x.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(x.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=x.events?.containerDown!==!1,document.getElementById("event-container-up").checked=x.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=x.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=x.events?.deploymentFailed!==!1}}catch(C){console.error("Failed to load notification config:",C)}}async function A(){try{const H=await(await fetch("/api/v1/notifications/history?limit=10")).json(),x=document.getElementById("notification-history");H.success&&H.history?.length>0?x.innerHTML=H.history.map(O=>{const z=new Date(O.timestamp).toLocaleString();return` +
+ ${O.type==="success"?"\u2713":O.type==="error"?"\u2717":"\u2139"} +
+
${escapeHtml(O.title)}
+
${z}
+
+
+ `}).join(""):x.innerHTML='
No notifications yet
'}catch(C){console.error("Failed to load notification history:",C)}}async function $(){try{const C={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},x=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(C)})).json();x.success?(showNotification("Notification settings saved","success",3e3),y.classList.remove("show")):showNotification(`Failed to save: ${x.error}`,"error",3e3)}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}}async function P(C){try{const x=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:C})})).json();x.success?showNotification(`Test ${C} notification sent!`,"success",3e3):showNotification(`Test failed: ${x.error}`,"error",3e3)}catch(H){showNotification(`Error: ${H.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>P("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>P("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>P("ntfy")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const H=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();H.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(H.lastCheck).toLocaleString()} (${H.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}}),h?.addEventListener("click",()=>{y.classList.add("show"),D(),A()}),L?.addEventListener("click",$),wireModal(y,T)})(),(function(){document.addEventListener("click",y=>{const h=y.target.closest(".panel-tab");if(!h)return;const L=h.dataset.panel;if(!L)return;const T=h.closest(".panel-tabs"),B=T.closest(".weather-modal-content");T.querySelectorAll(".panel-tab").forEach(D=>D.classList.remove("active")),h.classList.add("active"),B.querySelectorAll(".panel-section").forEach(D=>D.classList.remove("active"));const N=B.querySelector("#"+L);N&&N.classList.add("active")})})(),(function(){var y=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function h(){for(var a={},n=0;n +
+

\u{1F4BE} Backup & Restore

+ + + +
+ + + +
+ + +
+ +
+

\u{1F4E4} Export Backup

+

+ Downloads everything \u2014 services, Caddyfile, credentials, encryption key, themes, and all browser preferences. +

+ +
+ + +
+

\u{1F4E5} Restore Backup

+

+ Upload a backup file to restore your entire configuration \u2014 drag and drop ready. +

+ + + +
+ + + + + + +
+ + +
+
+
+ \u23F0 + Loading backup schedule... +
+
+
+ + +
+
+
+ \u{1F4CB} + Loading backup history... +
+
+
+ + + +
+ `);var N=document.getElementById("backup-modal"),D=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),$=document.getElementById("backup-export-btn"),P=document.getElementById("backup-select-file"),C=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),x=document.getElementById("backup-preview"),O=document.getElementById("backup-preview-content"),z=document.getElementById("backup-do-restore-btn"),S=document.getElementById("backup-result"),E=document.getElementById("backup-schedule-container"),k=document.getElementById("backup-history-container"),b=null;D?.addEventListener("click",function(){N.classList.add("show"),S&&(S.style.display="none"),x&&(x.style.display="none"),H&&(H.style.display="none"),b=null}),wireModal(N,A),$?.addEventListener("click",async function(){$.disabled=!0,$.innerHTML=' Exporting...';try{var a=await fetch("/api/v1/backup/export"),n=await a.json();n.browserState=h();var e=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),t=URL.createObjectURL(e),i=document.createElement("a");i.href=t,i.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(t);var r=Object.keys(n.browserState).length,p=n.themes?Object.keys(n.themes).length:0;S.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(p?" + "+p+" themes":""),S.style.display="block",S.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",S.style.border="1px solid var(--ok-fg)"}catch(v){S.innerHTML="\u274C Export failed: "+escapeHtml(v.message),S.style.display="block",S.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",S.style.border="1px solid var(--bad-fg)"}$.disabled=!1,$.innerHTML="\u2B07\uFE0F Download Full Backup"}),P?.addEventListener("click",function(){C.click()}),C?.addEventListener("change",async function(a){var n=a.target.files[0];if(n){H.textContent="\u{1F4C4} "+n.name,H.style.display="block",S.style.display="none";try{var e=await n.text(),t=JSON.parse(e);if(T(t)){b=t;var i='
Legacy format (v'+escapeHtml(t.version)+")
";i+='
',t.services?.length&&(i+='\u{1F4CB} '+t.services.length+" services"),t.customApps?.length&&(i+='\u{1F4E6} '+t.customApps.length+" custom apps"),t.theme&&(i+='\u{1F3A8} Theme: '+escapeHtml(t.theme)+""),t.userThemes&&(i+='\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes"),i+="
",O.innerHTML=i,x.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),p=await r.json();if(p.success){b=t;var i='
Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.version)+")
";i+='
Server Config
',i+='
';for(var v in p.preview.files){var o=p.preview.files[v],s=o.action==="create"?"\u{1F195}":"\u{1F4DD}";i+=''+s+" "+escapeHtml(o.description)+""}i+="
",p.preview.serviceCount&&(i+='
'+p.preview.serviceCount+" services
"),p.preview.themeCount&&(i+='
\u{1F3A8} '+p.preview.themeCount+" custom themes
"),p.preview.browserStateCount&&(i+='
Browser Preferences
',i+='
\u{1F5A5}\uFE0F '+p.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)
"),O.innerHTML=i,x.style.display="block"}else S.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(p.error),S.style.display="block",S.style.background="color-mix(in srgb, #f39c12 15%, transparent)",S.style.border="1px solid #f39c12",x.style.display="none"}catch(u){S.innerHTML="\u274C Could not read file: "+escapeHtml(u.message),S.style.display="block",S.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",S.style.border="1px solid var(--bad-fg)",x.style.display="none"}}}),z?.addEventListener("click",async function(){if(b&&confirm("This will overwrite your current configuration and browser preferences. Continue?")){z.disabled=!0,z.innerHTML=' Restoring...';try{if(T(b)){B(b),S.innerHTML="\u2705 Legacy backup restored \u2014 browser settings and services imported.",S.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",S.style.border="1px solid var(--ok-fg)",S.style.display="block",setTimeout(function(){location.reload()},2e3),z.disabled=!1,z.innerHTML="\u26A1 Restore Everything";return}var a=document.getElementById("backup-reload-caddy")?.checked??!0,n=await secureFetch("/api/v1/backup/restore",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backup:b,options:{reloadCaddy:a}})}),e=await n.json(),t=0;if(b.browserState&&(t=L(b.browserState)),e.success){var i="\u2705 "+e.message;t>0&&(i+='
'+t+" browser settings restored"),e.results.caddyReloaded&&(i+='
Caddy configuration reloaded'),S.innerHTML=i,S.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",S.style.border="1px solid var(--ok-fg)",setTimeout(function(){location.reload()},2e3)}else S.innerHTML="\u26A0\uFE0F "+escapeHtml(e.message),t>0&&(S.innerHTML+='
'+t+" browser settings were restored"),e.results?.errors?.length>0&&(S.innerHTML+="
"+e.results.errors.map(function(r){return escapeHtml(r.file)+": "+escapeHtml(r.error)}).join(", ")+""),S.style.background="color-mix(in srgb, #f39c12 15%, transparent)",S.style.border="1px solid #f39c12";S.style.display="block"}catch(r){S.innerHTML="\u274C Restore failed: "+escapeHtml(r.message),S.style.display="block",S.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",S.style.border="1px solid var(--bad-fg)"}z.disabled=!1,z.innerHTML="\u26A1 Restore Everything"}});async function m(){if(E)try{var a=await fetch("/api/v1/backups/config"),n=await a.json();if(!n.success)throw new Error(n.error||"Failed to load config");var e=n.config?.backups||{},t=Object.keys(e)[0],i=t?e[t]:null,r='
';r+='

\u23F0 Backup Schedule

',r+='
',r+='
',r+='
",r+='
',r+='
",r+="
",r+='
',r+='
",r+='
',r+=' ',r+=' ',r+="
",r+="
",r+='',E.innerHTML=r,document.getElementById("backup-save-schedule")?.addEventListener("click",f),document.getElementById("backup-run-now")?.addEventListener("click",c)}catch(p){E.innerHTML='
Failed to load schedule: '+escapeHtml(p.message)+"
"}}async function f(){var a=document.getElementById("backup-schedule-select")?.value,n=parseInt(document.getElementById("backup-retention-select")?.value)||5,e=document.getElementById("backup-encrypt-toggle")?.checked??!0,t=document.getElementById("backup-schedule-result");try{var i=await secureFetch("/api/v1/backups/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({backups:{auto:{enabled:a!=="disabled",schedule:a==="disabled"?"daily":a,include:["all"],encrypt:e,verify:!0,retention:{keep:n},destinations:[{type:"local"}]}}})}),r=await i.json();t&&(t.innerHTML=r.success?"\u2705 Schedule saved":"\u26A0\uFE0F "+escapeHtml(r.error),t.style.display="block",t.style.background=r.success?"color-mix(in srgb, var(--ok-fg) 15%, transparent)":"color-mix(in srgb, var(--bad-fg) 15%, transparent)",t.style.border=r.success?"1px solid var(--ok-fg)":"1px solid var(--bad-fg)",setTimeout(function(){t&&(t.style.display="none")},3e3))}catch(p){t&&(t.innerHTML="\u274C "+escapeHtml(p.message),t.style.display="block",t.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",t.style.border="1px solid var(--bad-fg)")}}async function c(){var a=document.getElementById("backup-run-now"),n=document.getElementById("backup-schedule-result");a&&(a.disabled=!0,a.innerHTML=' Running...');try{var e=await secureFetch("/api/v1/backups/execute",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({include:["all"],destinations:[{type:"local"}]})}),t=await e.json();if(n){if(t.success){var i=t.backup?.size?(t.backup.size/1024/1024).toFixed(2):"?";n.innerHTML="\u2705 Backup complete ("+i+" MB)",n.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",n.style.border="1px solid var(--ok-fg)"}else n.innerHTML="\u26A0\uFE0F "+escapeHtml(t.error),n.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",n.style.border="1px solid var(--bad-fg)";n.style.display="block"}d()}catch(r){n&&(n.innerHTML="\u274C "+escapeHtml(r.message),n.style.display="block",n.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",n.style.border="1px solid var(--bad-fg)")}a&&(a.disabled=!1,a.innerHTML="\u25B6\uFE0F Run Backup Now")}async function d(){if(k){k.innerHTML='
Loading...
';try{var a=await fetch("/api/v1/backups/history?limit=50"),n=await a.json();if(!n.success||!n.history?.length){k.innerHTML='
\u{1F4CB} No backup history yet
';return}for(var e='
',t=0;t',e+='
',e+=' '+escapeHtml(i.name||"backup")+"",e+='
',e+=' '+escapeHtml(i.status)+"",i.status==="success"&&(e+=' '),e+="
",e+="
",e+='
',e+=" "+new Date(i.timestamp).toLocaleString()+" | "+r+" MB | "+(i.duration?(i.duration/1e3).toFixed(1)+"s":"--"),i.encrypted&&(e+=" | \u{1F512}"),e+="
",e+="
"}e+="",k.innerHTML=e,k.querySelectorAll(".backup-restore-btn").forEach(function(p){p.addEventListener("click",function(){window.__restoreServerBackup(p.dataset.backupId)})})}catch(p){k.innerHTML='
Failed: '+escapeHtml(p.message)+"
"}}}window.__restoreServerBackup=async function(a){if(confirm("Restore from this server backup? This will overwrite current configuration."))try{var n=await secureFetch("/api/v1/backups/restore/"+a,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restoreServices:!0,restoreConfig:!0})}),e=await n.json();e.success?(showNotification("Restore completed successfully!","success"),location.reload()):showNotification("Restore failed: "+(e.error||"Unknown error"),"error")}catch(t){showNotification("Restore error: "+t.message,"error")}},document.querySelector('[data-panel="backup-automated"]')?.addEventListener("click",m),document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener("click",d)})(),(function(){injectModal("stats-modal",`
+
+

\u{1F4CA} Resource Monitor

+ + + +
+ + + +
+ + +
+
+
+ Loading container stats... +
+
+
+ + +
+
+
+ \u{1F4C8} + Loading 24-hour aggregated metrics... +
+
+
+ + +
+
+
+ \u{1F514} + Loading alert configurations... +
+
+
+ + +
+ + + +
+ + + +
+
`);const y=document.getElementById("stats-modal"),h=document.getElementById("container-stats-btn"),L=document.getElementById("stats-cancel"),T=document.getElementById("stats-refresh-btn"),B=document.getElementById("stats-auto-refresh"),N=document.getElementById("stats-container"),D=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),$=document.getElementById("stats-last-update");let P=null,C=null;function H(m){if(m===0||!m)return"0 B";const f=1024,c=["B","KB","MB","GB"],d=Math.floor(Math.log(m)/Math.log(f));return parseFloat((m/Math.pow(f,d)).toFixed(1))+" "+c[d]}function x(m){return m<30?"#2ecc71":m<70?"#f39c12":"#e74c3c"}function O(m){return m<50?"#2ecc71":m<80?"#f39c12":"#e74c3c"}async function z(){try{let m=null,f=!1;try{const a=await(await fetch("/api/v1/monitoring/stats")).json();a.success&&a.stats&&(m=a.stats,f=!0,C=a.stats)}catch{}if(!f){const a=await(await fetch("/api/v1/stats/containers")).json();if(a.success&&a.stats){m={};for(const n of a.stats)m[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};C=m}}if(!m||Object.keys(m).length===0){N.innerHTML='
No running containers found
';return}let c='
';for(const[d,a]of Object.entries(m)){const n=a.current||a,e=n.cpu?.percent||0,t=n.memory?.percent||0,i=x(e),r=O(t),p=n.memory?.usage||n.memory?.used||0,v=n.memory?.limit||0,o=n.network?.rxBytes||n.network?.rx||0,s=n.network?.txBytes||n.network?.tx||0,u=a.aggregated;c+=` +
+
+ ${a.name||d} + ${u?`avg ${u.cpu?.avg?.toFixed(0)||0}% cpu`:""} + ${a.status||"running"} +
+
+
+
CPU
+
+
+
+
+ ${e.toFixed(1)}% +
+
+
+
Memory
+
+
+
+
+ ${t.toFixed(1)}% +
+
${H(p)} / ${H(v)}
+
+
+
Network
+
+ \u2193 ${H(o)} + / + \u2191 ${H(s)} +
+
+
+
`}c+="
",N.innerHTML=c,$.textContent="Updated: "+new Date().toLocaleTimeString()}catch(m){N.innerHTML=`
\u274C Failed to load stats: ${escapeHtml(m.message)}
`}}async function S(){if(!D)return;const m=C;if(!m||Object.keys(m).length===0){D.innerHTML='
\u{1F4C8}No monitoring data available. Open the Live Stats tab first.
';return}let f='
';for(const[c,d]of Object.entries(m)){const a=d.aggregated;a&&(f+=`
+
${d.name||c}
+
+
${a.cpu?.avg?.toFixed(1)||0}%Avg CPU
+
${a.cpu?.max?.toFixed(1)||0}%Max CPU
+
${a.memory?.avg?.toFixed(1)||0}%Avg Mem
+
${a.memory?.max?.toFixed(1)||0}%Max Mem
+
+ ${a.dataPoints?`
${a.dataPoints} data points over ${a.timeRange||24}h
`:""} +
`)}f+="
",D.innerHTML=f}async function E(){if(!A)return;A.innerHTML='
Loading alerts...
';const m=C;if(!m||Object.keys(m).length===0){A.innerHTML='
\u{1F514}No containers found. Open the Live Stats tab first.
';return}let f='
';for(const[c,d]of Object.entries(m)){const a=d.alertConfig||{};f+=`
+
+ ${d.name||c} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
`}f+="
",A.innerHTML=f,A.querySelectorAll(".alert-save-btn").forEach(c=>{c.addEventListener("click",async()=>{const d=c.dataset.container,a=A.querySelector(`.alert-enabled[data-container="${d}"]`)?.checked||!1,n=parseInt(A.querySelector(`.alert-cpu[data-container="${d}"]`)?.value)||80,e=parseInt(A.querySelector(`.alert-mem[data-container="${d}"]`)?.value)||85,t=parseInt(A.querySelector(`.alert-cooldown[data-container="${d}"]`)?.value)||15,i=A.querySelector(`.alert-autorestart[data-container="${d}"]`)?.checked||!1;try{const p=await(await secureFetch(`/api/v1/monitoring/alerts/${d}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:a,cpuThreshold:n,memoryThreshold:e,cooldownMinutes:t,autoRestart:i})})).json();c.textContent=p.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{c.textContent="Save"},2e3)}catch{c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save"},2e3)}})})}function k(){P&&clearInterval(P),B?.checked&&(P=setInterval(z,DC.POLL.STATS))}function b(){P&&(clearInterval(P),P=null)}h?.addEventListener("click",()=>{y.classList.add("show"),z(),k()}),L?.addEventListener("click",()=>{y.classList.remove("show"),b()}),y?.addEventListener("click",m=>{m.target===y&&(y.classList.remove("show"),b())}),T?.addEventListener("click",z),B?.addEventListener("change",()=>{B.checked?k():b()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",S),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",E)})(),(function(){injectModal("health-modal",`
+
+

\u{1F3E5} Health Check Dashboard

+ + +
+ + + +
+ + +
+
+
Loading health status...
+
+
+ + +
+
+
\u{1F6A8} Loading incidents...
+
+
+ + +
+
+
\u2699\uFE0F Loading configuration...
+
+ + + + +
+ +
+
+ +
+ + +
+ + +
+
`);const y=document.getElementById("health-modal"),h=document.getElementById("health-check-btn"),L=document.getElementById("health-cancel"),T=document.getElementById("health-refresh-btn"),B=document.getElementById("health-status-container"),N=document.getElementById("health-incidents-container"),D=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),$=document.getElementById("health-add-btn"),P=document.getElementById("health-config-form"),C=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),x=document.getElementById("health-form-save");let O=null;function z(c){return c>=99.9?"var(--ok-fg)":c>=95?"#f39c12":"var(--bad-fg)"}function S(c){const d={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`${c}`}async function E(){try{const d=await(await fetch("/api/v1/health-checks/status")).json();if(!d.success||!d.status||Object.keys(d.status).length===0){B.innerHTML='
\u{1F3E5}No health checks configured. Go to the Configure tab to add services.
';return}const a=Object.values(d.status);let n='';n+='',n+='',n+='',n+='';for(const e of a){const t=e.status==="up",i=t?"var(--dot-ok)":"var(--dot-bad)",r=e.uptime?.["24h"]??"-",p=e.uptime?.["7d"]??"-",v=e.avgResponseTime!=null?Math.round(e.avgResponseTime)+"ms":"-",o=e.timestamp?timeAgo(e.timestamp):"-";n+=``,n+=``,n+=``,n+=``,n+=``,n+=``,n+=``,n+="",n+=``}n+="
ServiceStatusUptime 24hUptime 7dAvg ResponseLast Check
${escapeHtml(e.name||e.serviceId)}${t?"Up":"Down"}${typeof r=="number"?r.toFixed(1)+"%":r}${typeof p=="number"?p.toFixed(1)+"%":p}${v}${o}
",B.innerHTML=n,A.textContent="Updated "+new Date().toLocaleTimeString(),B.querySelectorAll("tr[data-health-id]").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.healthId,i=document.getElementById("health-detail-"+t);if(i){if(i.style.display!=="none"){i.style.display="none";return}i.style.display="";try{const p=await(await fetch(`/api/v1/health-checks/${t}/stats?hours=24`)).json();if(p.success&&p.stats){const v=p.stats,o=v.responseTime||{};i.querySelector("td").innerHTML=` +
+
Total Checks
${v.totalChecks||0}
+
Uptime
${(v.uptime||0).toFixed(2)}%
+
Avg Response
${Math.round(o.avg||0)}ms
+
P95 / P99
${Math.round(o.p95||0)}ms / ${Math.round(o.p99||0)}ms
+
Min Response
${Math.round(o.min||0)}ms
+
Max Response
${Math.round(o.max||0)}ms
+
Up Checks
${v.upChecks||0}
+
Down Checks
${v.downChecks||0}
+
`}else i.querySelector("td").innerHTML='
No detailed stats available for this period.
'}catch(r){i.querySelector("td").innerHTML=`
Failed: ${escapeHtml(r.message)}
`}}})})}catch(c){B.innerHTML=`
Failed to load health status: ${escapeHtml(c.message)}
`}}async function k(){try{const[c,d]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),a=await c.json(),n=await d.json();let e="";const t=a.success&&a.incidents?a.incidents:[];if(t.length>0){e+='

Open Incidents ('+t.length+")

";for(const r of t)e+=`
+
+ ${escapeHtml(r.serviceId)} + ${S(r.severity)} +
+
${escapeHtml(r.message)}
+
Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)
+
`;e+="
"}else e+='
All services operational \u2014 no open incidents
';const i=n.success&&n.history?n.history:[];if(i.length>0){e+='

Incident History

',e+='',e+='';for(const r of i){const p=r.status==="resolved",v=p&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";e+='',e+=``,e+=``,e+=``,e+=``,e+=``,e+=``,e+=""}e+="
ServiceTypeSeverityStatusDurationWhen
${escapeHtml(r.serviceId)}${escapeHtml(r.type)}${S(r.severity)}${r.status}${v}${timeAgo(r.createdAt)}
"}N.innerHTML=e||'
\u{1F6A8}No incidents recorded yet.
'}catch(c){N.innerHTML=`
Failed: ${escapeHtml(c.message)}
`}}async function b(){try{const d=await(await fetch("/api/v1/health-checks/status")).json(),a=d.success&&d.status?Object.values(d.status):[];if(a.length===0){D.innerHTML='
\u2699\uFE0FNo health checks configured yet. Click "Add Health Check" below.
';return}let n='';n+='';for(const e of a){const t=e.status==="up";n+='',n+=``,n+=``,n+=``,n+='"}n+="
ServiceStatusSLA TargetActions
${escapeHtml(e.name||e.serviceId)}${t?"Up":"Down"}${e.sla?.target?e.sla.target+"%":"-"}',n+=``,n+=``,n+="
",D.innerHTML=n}catch(c){D.innerHTML=`
Failed: ${escapeHtml(c.message)}
`}}function m(c,d,a,n,e,t,i){O=c||null,C.textContent=c?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=c||"",document.getElementById("health-form-id").disabled=!!c,document.getElementById("health-form-name").value=d||"",document.getElementById("health-form-url").value=a||"",document.getElementById("health-form-timeout").value=n||1e4,document.getElementById("health-form-codes").value=e||"200",document.getElementById("health-form-sla").value=t||99.9,document.getElementById("health-form-slow").value=i||5e3,P.style.display="",$.style.display="none"}function f(){P.style.display="none",$.style.display="",O=null}$?.addEventListener("click",()=>m("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",f),x?.addEventListener("click",async()=>{const c=O||document.getElementById("health-form-id").value.trim();if(!c)return showNotification("Service ID is required","warning");const d=document.getElementById("health-form-url").value.trim();if(!d)return showNotification("URL is required","warning");const a=document.getElementById("health-form-codes").value.split(",").map(e=>parseInt(e.trim())).filter(Boolean),n={name:document.getElementById("health-form-name").value.trim()||c,url:d,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:a.length?a:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)||5e3};try{x.textContent="Saving...",x.disabled=!0;const t=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(c)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();if(!t.success)throw new Error(t.error||"Save failed");f(),b(),E()}catch(e){showNotification("Error: "+e.message,"error")}finally{x.textContent="Save",x.disabled=!1}}),document.addEventListener("health-edit",async c=>{const d=c.detail;m(d,"","",1e4,"200",99.9,5e3)}),document.addEventListener("health-delete",async c=>{const d=c.detail;if(confirm(`Delete health check for "${d}"?`))try{const n=await(await secureFetch(`/api/v1/health-checks/${encodeURIComponent(d)}/configure`,{method:"DELETE"})).json();if(!n.success)throw new Error(n.error);b(),E()}catch(a){showNotification("Error: "+a.message,"error")}}),h?.addEventListener("click",()=>{y?.classList.add("show"),E()}),wireModal(y,L),T?.addEventListener("click",E),document.querySelector('[data-panel="health-incidents"]')?.addEventListener("click",k),document.querySelector('[data-panel="health-config"]')?.addEventListener("click",b)})(),(function(){injectModal("updates-modal",`
+
+

\u2B06\uFE0F Update Management

+ + +
+ + + + +
+ + +
+
+ +
+
+
\u{1F4E6} Click "Check for Updates" to scan containers.
+
+
+ + +
+
+
Loading update history...
+
+
+ + +
+
+
\u{1F916} Loading auto-update configuration...
+
+
+ + +
+
+
+
DashCaddy
+
Loading...
+
+ +
+ + +
+ + +
+
+
\u{1F4E6}No self-update history.
+
+
+ +
+ +
+ + +
+
`);const y=document.getElementById("updates-modal"),h=document.getElementById("updates-btn"),L=document.getElementById("updates-cancel"),T=document.getElementById("updates-check-btn"),B=document.getElementById("updates-available-container"),N=document.getElementById("updates-history-container"),D=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function $(){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)throw new Error(v.error);const o=v.updates||[];if(o.length===0){B.innerHTML='
\u2705All containers are up to date.
',A.textContent="";return}let s='';s+='';for(const u of o)s+='',s+=``,s+=``,s+=``,s+=``,s+='";s+="
ContainerImageCurrentLatestActions
${escapeHtml(u.containerName)}${escapeHtml(u.imageName)}${escapeHtml(u.currentDigest)}${escapeHtml(u.latestDigest)}',s+=``,s+=``,s+="
",B.innerHTML=s,A.textContent=o.length+" update(s) available",B.querySelectorAll(".update-now-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.dataset.name;if(confirm(`Update "${g}" to the latest version? The container will restart.`)){u.textContent="Updating...",u.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(w.success)u.textContent="Done!",u.style.background="var(--ok-fg)",setTimeout(()=>$(),2e3);else throw new Error(w.error||"Update failed")}catch(I){u.textContent="Failed",u.style.color="var(--bad-fg)",showNotification("Update error: "+I.message,"error"),setTimeout(()=>{u.textContent="Update",u.disabled=!1,u.style.color="",u.style.background=""},3e3)}}})}),B.querySelectorAll(".rollback-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.dataset.name;if(confirm(`Rollback "${g}" to its previous version?`)){u.textContent="Rolling back...",u.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(l)}`,{method:"POST"})).json();if(w.success)u.textContent="Rolled back!",setTimeout(()=>$(),2e3);else throw new Error(w.error||"Rollback failed")}catch(I){u.textContent="Failed",showNotification("Rollback error: "+I.message,"error"),setTimeout(()=>{u.textContent="Rollback",u.disabled=!1},3e3)}}})})}catch(p){B.innerHTML=`
Failed: ${escapeHtml(p.message)}
`}}async function P(){T.textContent="\u{1F50D} Checking...",T.disabled=!0;try{const v=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!v.success)throw new Error(v.error);T.textContent="\u2705 Done!",await $()}catch(p){T.textContent="\u274C Failed",showNotification("Check error: "+p.message,"error")}setTimeout(()=>{T.textContent="\u{1F50D} Check for Updates",T.disabled=!1},3e3)}async function C(){try{N.innerHTML='
Loading...
';const v=await(await fetch("/api/v1/updates/history?limit=50")).json(),o=v.success&&v.history?v.history:[];if(o.length===0){N.innerHTML='
\u{1F4CB}No update history yet.
';return}let s='';s+='';for(const u of o){const l=u.status==="success",g=u.duration?u.duration<1e3?u.duration+"ms":Math.round(u.duration/1e3)+"s":"-";s+='',s+=``,s+=``,s+=``,s+=``,s+=``,s+="",!l&&u.error&&(s+=``)}s+="
WhenContainerImageDurationStatus
${timeAgo(u.timestamp)}${escapeHtml(u.containerName)}${escapeHtml(u.imageName)}${g}${l?"\u2713 success":"\u2717 failed"}
${escapeHtml(u.error)}
",N.innerHTML=s}catch(p){N.innerHTML=`
Failed: ${escapeHtml(p.message)}
`}}async function H(){try{D.innerHTML='
Loading...
';const v=await(await fetch("/api/v1/stats/containers")).json(),o=v.success&&v.stats?v.stats:[];if(o.length===0){D.innerHTML='
\u{1F916}No running containers found.
';return}let s='';s+='';for(const u of o){const l=u.name||u.Names?.[0]?.replace(/^\//,"")||u.Id?.substring(0,12),g=u.containerId||u.Id;s+=``,s+=``,s+=``,s+=``,s+=``,s+=""}s+="
ContainerScheduleAuto-RollbackActions
${escapeHtml(l)} +
",D.innerHTML=s,D.querySelectorAll(".save-auto-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.closest("tr"),I=g.querySelector(".auto-schedule").value,w=g.querySelector(".auto-rollback").checked;u.textContent="Saving...",u.disabled=!0;try{const R=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!I,schedule:I||"weekly",autoRollback:w})})).json();if(R.success)u.textContent="\u2713 Saved";else throw new Error(R.error)}catch(M){u.textContent="\u2717 Error",showNotification("Save error: "+M.message,"error")}setTimeout(()=>{u.textContent="Save",u.disabled=!1},2e3)})})}catch(p){D.innerHTML=`
Failed: ${escapeHtml(p.message)}
`}}const x=document.getElementById("dashcaddy-current-version"),O=document.getElementById("dashcaddy-update-badge"),z=document.getElementById("dashcaddy-update-details"),S=document.getElementById("dashcaddy-new-version"),E=document.getElementById("dashcaddy-changelog"),k=document.getElementById("dashcaddy-apply-btn"),b=document.getElementById("dashcaddy-check-btn"),m=document.getElementById("dashcaddy-rollback-btn"),f=document.getElementById("dashcaddy-status-bar"),c=document.getElementById("dashcaddy-history-container");let d=null;function a(p,v){f&&(f.style.display="block",f.style.background=v==="error"?"var(--bad-bg)":v==="success"?"var(--ok-bg)":"var(--bg)",f.style.color=v==="error"?"var(--bad-fg)":v==="success"?"var(--ok-fg)":"var(--fg)",f.textContent=p)}async function n(){try{const v=await(await fetch("/api/v1/system/version")).json();v.success&&(x.textContent="v"+v.version+(v.commit?" ("+v.commit.substring(0,7)+")":""))}catch{x.textContent="Unable to fetch version"}}async function e(p){p||(b.textContent="Checking...",b.disabled=!0);try{const o=await(await fetch("/api/v1/system/update-check")).json();if(d=o,o.success&&o.available&&o.remote){O.style.display="",z.style.display="",S.textContent="v"+o.remote.version,E.textContent=o.remote.changelog||"No changelog available.";const s=document.getElementById("updates-btn");if(s&&!s.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",s.style.position="relative",s.appendChild(l)}const u=document.getElementById("updates-dashcaddy-tab");if(u&&!u.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",u.appendChild(l)}}else O.style.display="none",z.style.display="none",p||a("You are running the latest version.","success");p||(b.textContent="Check for Updates",b.disabled=!1)}catch(v){p||(a("Failed to check: "+v.message,"error"),b.textContent="Check for Updates",b.disabled=!1)}}async function t(){if(confirm("Apply DashCaddy update? The API container will restart.")){k.textContent="Updating...",k.disabled=!0,a("Downloading and applying update...","info");try{const v=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(v.success)a("Update initiated: v"+(v.fromVersion||"?")+" \u2192 v"+(v.toVersion||"?")+". The container will restart shortly.","success"),k.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(o=>o.remove());else throw new Error(v.error||"Update failed")}catch(p){a("Update failed: "+p.message,"error"),k.textContent="Update Now",k.disabled=!1}}}async function i(){try{const v=await(await fetch("/api/v1/system/update-history")).json(),o=v.success&&v.history?v.history:[];if(o.length===0){c.innerHTML='
\u{1F4E6}No self-update history.
';return}let s='';s+='';for(const u of o){const l=u.status==="success"?"\u2713 success":u.status==="pending"?"\u23F3 pending":u.status==="partial"?"\u26A0 partial":"\u2717 "+u.status,g=u.status==="success"?"var(--ok-fg)":u.status==="pending"?"var(--muted)":"var(--bad-fg)";s+='',s+='",s+='",s+='",s+='",s+="",u.error&&(s+='"),u.note&&(s+='")}s+="
WhenVersionFromStatus
'+timeAgo(u.timestamp)+"v'+escapeHtml(u.version)+(u.rollback?" (rollback)":"")+"v'+escapeHtml(u.fromVersion||"?")+"'+l+"
'+escapeHtml(u.error)+"
'+escapeHtml(u.note)+"
",c.innerHTML=s}catch(p){c.innerHTML='
Failed: '+escapeHtml(p.message)+"
"}}async function r(){try{const v=await(await fetch("/api/v1/system/rollback-versions")).json(),o=v.success&&v.versions?v.versions:[];if(o.length===0){showNotification("No rollback versions available.","info");return}const s=prompt(`Available rollback versions: +`+o.join(` +`)+` + +Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification("Invalid version: "+s,"error");return}if(!confirm("Rollback DashCaddy to v"+s+"? The container will restart."))return;a("Rolling back to v"+s+"...","info");const l=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:s})})).json();if(l.success)a("Rollback to v"+s+" initiated. Container will restart.","success");else throw new Error(l.error||"Rollback failed")}catch(p){a("Rollback failed: "+p.message,"error")}}b?.addEventListener("click",()=>e(!1)),k?.addEventListener("click",t),m?.addEventListener("click",r),T?.addEventListener("click",P),h?.addEventListener("click",()=>{y?.classList.add("show"),$()}),wireModal(y,L),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",C),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{n(),i(),d||e(!0)}),setTimeout(()=>e(!0),5e3)})(),(function(){injectModal("audit-modal",`
+
+

\u{1F4DC} Audit Log

+ + +
+ + + + + +
+ +
+
Loading audit log...
+
+ +
+ +
+ + +
+
`);const y=document.getElementById("audit-modal"),h=document.getElementById("audit-log-btn"),L=document.getElementById("audit-cancel"),T=document.getElementById("audit-refresh-btn"),B=document.getElementById("audit-clear-btn"),N=document.getElementById("audit-filter"),D=document.getElementById("audit-log-container"),A=document.getElementById("audit-load-more");let $=0;const P=50;async function C(H){try{H||($=0,D.innerHTML='
Loading...
');const x=N.value;let O=`/api/v1/audit-logs?limit=${P}&offset=${$}`;x&&(O+=`&action=${encodeURIComponent(x)}`);const S=await(await fetch(O)).json(),E=S.success&&S.entries?S.entries:[];if(E.length===0&&!H){D.innerHTML='
\u{1F4DC}No audit log entries yet. Actions will be logged automatically.
',A.style.display="none";return}let k="";H||(k='',k+='');for(const b of E){const m=b.outcome==="success";k+='',k+=``,k+=``,k+=``,k+=``,k+=``,k+="",b.details&&Object.keys(b.details).length>0&&(k+=``)}if(!H)k+="
WhenIPActionResourceResult
${timeAgo(b.timestamp)}${escapeHtml(b.ip||"-")}${escapeHtml(b.action||"-")}${escapeHtml(b.resource||"-")}${m?"\u2713":"\u2717"}
",D.innerHTML=k;else{const b=D.querySelector("table");b&&b.insertAdjacentHTML("beforeend",k)}$+=E.length,A.style.display=E.length>=P?"":"none",D.querySelectorAll(".audit-row").forEach(b=>{b.dataset.wired||(b.dataset.wired="true",b.addEventListener("click",()=>{const m=b.nextElementSibling;m&&m.classList.contains("audit-detail")&&(m.style.display=m.style.display==="none"?"":"none")}))})}catch(x){D.innerHTML=`
Failed: ${escapeHtml(x.message)}
`}}h?.addEventListener("click",()=>{y?.classList.add("show"),C(!1)}),wireModal(y,L),T?.addEventListener("click",()=>C(!1)),N?.addEventListener("change",()=>C(!1)),A?.addEventListener("click",()=>C(!0)),B?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const x=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();x.success?C(!1):showNotification("Error: "+(x.error||"Clear failed"),"error")}catch(H){showNotification("Error: "+H.message,"error")}})})(),(function(){injectModal("weather-modal",`

Weather Settings

+ + +
Enter a city name, postal code, or “City, Country”
+
+ +
+ + +
+
+
`);const y="weather-location",h="weather-zip",L="weather-geo",T="weather-unit";!safeGet(y)&&safeGet(h)&&safeSet(y,safeGet(h));function B(){return safeGet(T)||"imperial"}function N(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},A={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},$=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function P(E){return $[Math.round(E/22.5)%16]}async function C(E){const k=safeGet(L);if(k)try{const d=JSON.parse(k);if(d.query===E)return d}catch{}const b=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(E)}&count=1&language=en&format=json`);if(!b.ok)throw new Error("Geocoding failed");const m=await b.json();if(!m.results||!m.results.length)throw new Error("Location not found");const f=m.results[0],c={query:E,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(L,JSON.stringify(c)),c}function H(E){return E.countryCode==="US"&&E.state?`${E.city}, ${E.state}`:E.country?`${E.city}, ${E.country}`:E.city}async function x(E){try{const k=await C(E),b=B(),m=b==="metric"?"celsius":"fahrenheit",f=b==="metric"?"kmh":"mph",c=`https://api.open-meteo.com/v1/forecast?latitude=${k.lat}&longitude=${k.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${m}&wind_speed_unit=${f}`,d=await fetch(c);if(!d.ok)throw new Error("Weather fetch failed");const n=(await d.json()).current,e=n.weather_code;return{temp:Math.round(n.temperature_2m),condition:D[e]||"Unknown",icon:A[e]||"\u{1F324}\uFE0F",locationStr:H(k),windSpeed:Math.round(n.wind_speed_10m),windDir:P(n.wind_direction_10m),unit:b}}catch(k){return console.warn("Weather fetch failed:",k),null}}async function O(){const E=N();if(!E.icon||!E.temp||!E.condition||!E.location||!E.wind){console.warn("Weather widget elements not found");return}const k=safeGet(y);if(!k){E.location.textContent="Set Location",E.temp.textContent="--\xB0",E.condition.textContent="Click \u2699\uFE0F to configure",E.wind.textContent="--",E.icon.innerHTML='\u{1F324}\uFE0F';return}try{const b=await x(k);if(b){const m=b.unit==="metric"?"\xB0C":"\xB0F",f=b.unit==="metric"?"km/h":"mph";E.location.textContent=b.locationStr,E.temp.textContent=`${b.temp}${m}`,E.condition.textContent=b.condition,E.wind.textContent=`Wind: ${b.windSpeed} ${f} ${b.windDir}`,E.icon.innerHTML=`${escapeHtml(b.icon)}`}}catch(b){console.error("Weather update error:",b),E.location.textContent="Weather Error",E.temp.textContent="Error",E.condition.textContent="Failed to load",E.wind.textContent="--"}}const z=document.getElementById("weather-modal"),S=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{S.value=safeGet(y)||"";const E=B(),k=z.querySelector(`input[name="weather-unit-radio"][value="${E}"]`);k&&(k.checked=!0),z.classList.add("show"),S.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{z.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const E=S.value.trim();if(E){safeGet(y)!==E&&safeSet(L,""),safeSet(y,E);const b=z.querySelector('input[name="weather-unit-radio"]:checked'),m=b?b.value:"imperial",f=B();safeSet(T,m),f!==m&&safeSet(L,""),z.classList.remove("show"),O()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(z),document.addEventListener("keydown",E=>{E.key==="Escape"&&z.classList.contains("show")&&z.classList.remove("show")}),O(),setInterval(O,DC.POLL.WEATHER)})(),(function(){const y=document.getElementById("clock-widget"),h=document.getElementById("clock-render");if(!y||!h)return;const L=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],T=["January","February","March","April","May","June","July","August","September","October","November","December"],B=["XII","I","II","III","IV","V","VI","VII","VIII","IX","X","XI"];let N=safeGet("clock-style")||"default",D=-1,A=!1,$="",P="",C=null,H=null;function x(o){if(A||safeGet("clock-chimes")!=="true")return;A=!0;const s=parseInt(safeGet("clock-chime-volume")||"50",10)/100;let u=0;function l(){if(u>=o){A=!1;return}const g=new Audio("/assets/sounds/church-bell.mp3");g.volume=s,g.play().catch(()=>{}),u++,u{A=!1},2500)}l()}function O(o){return L[o.getDay()]+", "+T[o.getMonth()]+" "+o.getDate()+", "+o.getFullYear()}function z(){P="",C=null}function S(){return P!=="digital"&&(h.innerHTML='
',C={main:h.querySelector(".clock-main"),seconds:h.querySelector(".clock-seconds"),ampm:h.querySelector(".clock-ampm"),date:h.querySelector(".clock-date")},P="digital"),C}function E(o){const s=o.getHours(),u=o.getMinutes(),l=o.getSeconds(),g=s>=12?"PM":"AM",I=s%12||12,w=S();w.main.textContent=`${I}:${String(u).padStart(2,"0")}`,w.seconds.textContent=`:${String(l).padStart(2,"0")}`,w.ampm.textContent=g,w.date.textContent=O(o)}function k(o,s){const u=o.getHours(),l=o.getMinutes(),g=o.getSeconds(),I=u>=12?"PM":"AM",w=u%12||12,M=S();M.main.textContent=`${String(w).padStart(2,"0")}:${String(l).padStart(2,"0")}`,M.seconds.textContent=`:${String(g).padStart(2,"0")}`,M.ampm.textContent=I,M.date.textContent=O(o)}function b(o){const s=o.getHours(),u=o.getMinutes(),l=o.getSeconds(),g=s>=12?"PM":"AM",I=s%12||12,w=String(I).padStart(2," ")+String(u).padStart(2,"0")+String(l).padStart(2,"0");let M='
';if(M+=m(w[0],0),M+=m(w[1],1),M+=':',M+=m(w[2],2),M+=m(w[3],3),M+=':',M+=m(w[4],4),M+=m(w[5],5),M+=`${g}`,M+="
",M+=`
${O(o)}
`,h.innerHTML=M,P="flip",$){for(let R=0;R<6;R++)if(w[R]!==$[R]){const q=h.querySelector(`.flip-card[data-idx="${R}"]`);q&&q.classList.add("flipping")}}$=w}function m(o,s){const u=o===" "?"":o;return`
${u}
${u}
`}function f(o){const s=o.getHours(),u=o.getMinutes(),l=o.getSeconds(),g=s%12||12,I=s>=12?"PM":"AM",w=[Math.floor(g/10),g%10,Math.floor(u/10),u%10,Math.floor(l/10),l%10];let M='
';M+='
HHMMSS
';for(let R=3;R>=0;R--){M+='
';for(let q=0;q<6;q++){const _=w[q]>>R&1;M+=`
`}M+="
"}M+='
';for(let R=0;R<6;R++)M+=`${w[R]}`;M+="
",M+=`
${I}
`,M+="
",M+=`
${O(o)}
`,h.innerHTML=M,P="binary"}function c(o,s){const u=o.getHours(),l=o.getMinutes(),g=o.getSeconds(),I=120,w=I/2,M=I/2,R=g/60*360-90,q=(l+g/60)/60*360-90,_=(u%12+l/60)/12*360-90;let W="";for(let K=1;K<=12;K++){const Q=K/12*2*Math.PI-Math.PI/2,te=47,ne=w+te*Math.cos(Q),X=M+te*Math.sin(Q),oe=s?B[K%12]:K;W+=`${oe}`}let j="";for(let K=0;K<60;K++){const Q=K/60*2*Math.PI-Math.PI/2,te=56,ne=K%5===0?52:54,X=w+ne*Math.cos(Q),oe=M+ne*Math.sin(Q),ie=w+te*Math.cos(Q),ae=M+te*Math.sin(Q),F=K%5===0?1.5:.5;j+=``}const V=` + + ${j} + ${W} + + + + + `,se=o.getHours()>=12?"PM":"AM";h.innerHTML=`
${V}
${o.getHours()%12||12}:${String(l).padStart(2,"0")} ${se}${O(o)}
`,P="analog"}function d(){const o=new Date,s=o.getHours()%12||12,u=o.getMinutes(),l=o.getSeconds(),g="clock-widget"+(N!=="default"?" "+N:"");switch(y.className!==g&&(y.className=g),N){case"lcd":k(o);break;case"lcd-blue":k(o);break;case"lcd-amber":k(o);break;case"lcd-retro":k(o);break;case"lcd-taxi":k(o);break;case"flip":b(o);break;case"binary":f(o);break;case"analog":c(o,!1);break;case"roman":c(o,!0);break;default:E(o)}u===0&&l===0&&s!==D&&(D=s,x(s)),u!==0&&(D=-1)}function a(){clearTimeout(H);const o=document.hidden?6e4:1e3,s=o-Date.now()%o+25;H=setTimeout(()=>{d(),a()},s)}document.addEventListener("visibilitychange",()=>{$="",z(),d(),a()}),d(),a();const n=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let e='
';n.forEach(o=>{e+=``}),e+="
",injectModal("clock-settings-modal",`
+
+

Clock Settings

+
+ + ${e} +
+
+ +
+ Strikes the number of the hour (e.g., 3 bells at 3:00) +
+
+
+ +
+ \u{1F508} + + \u{1F50A} + +
+
+
+ + +
+
+
`);const t=document.getElementById("clock-settings-modal"),i=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),p=document.getElementById("clock-volume-section");function v(){const o=safeGet("clock-style")||"default",s=t.querySelector(`input[value="${o}"]`);s&&(s.checked=!0),i.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",p.style.opacity=i.checked?"1":"0.4"}i?.addEventListener("change",()=>{p.style.opacity=i.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{v(),t.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const o=parseInt(r.value,10)/100,s=new Audio("/assets/sounds/church-bell.mp3");s.volume=o,s.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const o=t.querySelector('input[name="clock-style-radio"]:checked'),s=o?o.value:"default";safeSet("clock-style",s),safeSet("clock-chimes",String(i.checked)),safeSet("clock-chime-volume",r.value),N=s,$="",z(),d(),a(),t.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{t.classList.remove("show")}),wireModal(t),t?.querySelectorAll('input[name="clock-style-radio"]').forEach(o=>{o.addEventListener("change",()=>{N=o.value,$="",z(),d()})})})(),(function(){async function y(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[A,$]of Object.entries(D.status)){const P=document.getElementById("uptime-"+A),C=document.getElementById("uptime-bar-"+A);if(!P)continue;const H=$.uptime?.["24h"];if(H!=null){const x=H.toFixed(1);P.textContent=`${x}% uptime`,P.className="uptime-chip",H>=99.9?P.classList.add("excellent"):H>=99?P.classList.add("good"):H>=95?P.classList.add("degraded"):P.classList.add("poor"),C&&(C.style.width=x+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let h;try{h=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{h=new Set}async function L(){try{const D=await(await fetch("/api/v1/updates/available")).json();if(!D.success||(document.querySelectorAll(".update-available-badge").forEach(A=>A.classList.remove("visible")),!D.updates?.length))return;for(const A of D.updates){const $=window.APPS||[];for(const P of $)if(P.containerId===A.containerId||P.id===A.containerName||P.name===A.containerName){if(h.has(P.id))break;const C=document.getElementById("update-badge-"+P.id);C&&(C.classList.add("visible"),C.title=`Image digest changed. Click to dismiss if already up to date. +${A.imageName||""}`,C.style.cursor="pointer",C.onclick=H=>{H.stopPropagation(),C.classList.remove("visible"),h.add(P.id),safeSessionSet("dismissed-updates",JSON.stringify([...h]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function T(){setTimeout(()=>{y(),L()},5e3),setInterval(()=>{y(),L()},6e4)}const B=window.refreshAll;B&&(window.refreshAll=async function(){try{await B(),setTimeout(y,1e3)}catch(N){console.warn("[Card Badges] Error in refreshAll hook:",N.message)}}),T()})(),(function(){var y=null,h=null,L={},T={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},B=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],N=document.getElementById("theme");if(!N)return;var D=document.getElementById("theme-label");function A(l){if(T[l])return T[l];var g=safeGetJSON(window.USER_THEMES_KEY,{});return g[l]&&g[l].name||l}function $(){D&&(D.textContent=A(window.getActiveTheme()))}N.addEventListener("click",function(){var l=window.THEMES.slice(),g=window.getActiveTheme(),I=l.indexOf(g),w=l[(I+1)%l.length];window.applyTheme(w),$()}),$();function P(){var l={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},g={};B.forEach(function(w){g[w[2]]||(g[w[2]]=[]),g[w[2]].push(w)});var I="";return Object.keys(l).forEach(function(w){w==="advanced"?(I+='
Show advanced colors ▼
',I+='