From e2c67a8fe8653a5acb79b02f5542e9a0e8bbf7d1 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:00:25 +0100 Subject: [PATCH 01/11] Phase 1: Add ESLint/Prettier config + baseline auto-fixes --- dashcaddy-api/.eslintignore | 4 + dashcaddy-api/.eslintrc.js | 56 + dashcaddy-api/.prettierrc | 8 + dashcaddy-api/__tests__/api-endpoints.test.js | 30 +- dashcaddy-api/__tests__/auth-manager.test.js | 30 +- .../__tests__/backup-manager.test.js | 2 +- dashcaddy-api/__tests__/config.test.js | 4 +- .../__tests__/credential-manager.test.js | 20 +- dashcaddy-api/__tests__/crypto-utils.test.js | 4 +- .../__tests__/docker-security.test.js | 2 +- dashcaddy-api/__tests__/edge-cases.test.js | 18 +- .../__tests__/health-checker.test.js | 14 +- .../__tests__/input-validator.test.js | 2 +- dashcaddy-api/__tests__/integration.test.js | 38 +- dashcaddy-api/__tests__/logger-utils.test.js | 16 +- dashcaddy-api/__tests__/notifications.test.js | 16 +- .../__tests__/resource-monitor.test.js | 8 +- dashcaddy-api/__tests__/security.test.js | 62 +- dashcaddy-api/__tests__/sites.test.js | 2 +- dashcaddy-api/__tests__/state-manager.test.js | 14 +- .../__tests__/update-manager.test.js | 2 +- dashcaddy-api/app-templates.js | 3196 ++++++++--------- dashcaddy-api/audit-logger.js | 2 +- dashcaddy-api/auth-manager.js | 16 +- dashcaddy-api/backup-manager.js | 40 +- dashcaddy-api/cache-config.js | 12 +- dashcaddy-api/comprehensive-test.js | 16 +- dashcaddy-api/config-schema.js | 6 +- dashcaddy-api/constants.js | 2 +- dashcaddy-api/credential-manager.js | 10 +- dashcaddy-api/crypto-utils.js | 2 +- dashcaddy-api/csrf-protection.js | 12 +- dashcaddy-api/docker-maintenance.js | 20 +- dashcaddy-api/docker-security.js | 10 +- dashcaddy-api/health-checker.js | 30 +- dashcaddy-api/input-validator.js | 40 +- dashcaddy-api/jest.config.js | 8 +- dashcaddy-api/keychain-manager.js | 2 +- dashcaddy-api/license-keygen.js | 10 +- dashcaddy-api/license-manager.js | 38 +- dashcaddy-api/log-digest.js | 52 +- dashcaddy-api/logger-utils.js | 6 +- dashcaddy-api/metrics.js | 14 +- dashcaddy-api/middleware.js | 44 +- dashcaddy-api/package-lock.json | 877 ++++- dashcaddy-api/package.json | 7 +- dashcaddy-api/platform-paths.js | 20 +- dashcaddy-api/port-lock-manager.js | 18 +- dashcaddy-api/recipe-templates.js | 394 +- dashcaddy-api/resource-monitor.js | 40 +- dashcaddy-api/routes/apps/deploy.js | 22 +- dashcaddy-api/routes/apps/helpers.js | 124 +- dashcaddy-api/routes/apps/removal.js | 6 +- dashcaddy-api/routes/apps/restore.js | 26 +- dashcaddy-api/routes/apps/templates.js | 6 +- dashcaddy-api/routes/arr/config.js | 74 +- dashcaddy-api/routes/arr/credentials.js | 8 +- dashcaddy-api/routes/arr/detect.js | 22 +- dashcaddy-api/routes/arr/helpers.js | 34 +- dashcaddy-api/routes/arr/plex.js | 10 +- dashcaddy-api/routes/arr/smart-connect.js | 40 +- dashcaddy-api/routes/auth/keys.js | 12 +- dashcaddy-api/routes/auth/session-handlers.js | 2 +- dashcaddy-api/routes/auth/totp.js | 10 +- dashcaddy-api/routes/browse.js | 26 +- dashcaddy-api/routes/ca.js | 72 +- dashcaddy-api/routes/config/assets.js | 18 +- dashcaddy-api/routes/config/backup.js | 28 +- dashcaddy-api/routes/containers.js | 158 +- dashcaddy-api/routes/dns.js | 34 +- dashcaddy-api/routes/errorlogs.js | 28 +- dashcaddy-api/routes/health.js | 34 +- dashcaddy-api/routes/license.js | 6 +- dashcaddy-api/routes/logs.js | 16 +- dashcaddy-api/routes/monitoring.js | 16 +- dashcaddy-api/routes/notifications.js | 198 +- dashcaddy-api/routes/recipes/deploy.js | 32 +- dashcaddy-api/routes/recipes/index.js | 4 +- dashcaddy-api/routes/recipes/manage.js | 14 +- dashcaddy-api/routes/services.js | 22 +- dashcaddy-api/routes/sites.js | 144 +- dashcaddy-api/routes/tailscale.js | 34 +- dashcaddy-api/routes/themes.js | 8 +- dashcaddy-api/scripts/webhook-handler.js | 6 +- dashcaddy-api/self-updater.js | 12 +- dashcaddy-api/server.js | 392 +- dashcaddy-api/startup-validator.js | 2 +- dashcaddy-api/state-manager.js | 4 +- dashcaddy-api/test-security-fixes.js | 14 +- dashcaddy-api/update-manager.js | 60 +- 90 files changed, 4008 insertions(+), 3066 deletions(-) create mode 100644 dashcaddy-api/.eslintignore create mode 100644 dashcaddy-api/.eslintrc.js create mode 100644 dashcaddy-api/.prettierrc diff --git a/dashcaddy-api/.eslintignore b/dashcaddy-api/.eslintignore new file mode 100644 index 0000000..896481b --- /dev/null +++ b/dashcaddy-api/.eslintignore @@ -0,0 +1,4 @@ +node_modules/ +coverage/ +dist/ +*.min.js diff --git a/dashcaddy-api/.eslintrc.js b/dashcaddy-api/.eslintrc.js new file mode 100644 index 0000000..9235272 --- /dev/null +++ b/dashcaddy-api/.eslintrc.js @@ -0,0 +1,56 @@ +module.exports = { + env: { + node: true, + es2021: true, + jest: true, + }, + extends: 'eslint:recommended', + parserOptions: { + ecmaVersion: 2021, + }, + rules: { + // Possible errors + 'no-await-in-loop': 'warn', + 'no-console': 'off', // We use console in server code + 'no-template-curly-in-string': 'error', + + // Best practices + 'curly': ['error', 'multi-line'], + 'eqeqeq': ['error', 'always', { null: 'ignore' }], + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-return-await': 'error', + 'no-throw-literal': 'error', + 'prefer-promise-reject-errors': 'error', + 'require-await': 'warn', + + // Variables + 'no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + 'no-use-before-define': ['error', { + functions: false, + classes: true, + }], + + // Stylistic + 'comma-dangle': ['error', 'always-multiline'], + 'quotes': ['error', 'single', { avoidEscape: true }], + 'semi': ['error', 'always'], + 'indent': ['error', 2, { SwitchCase: 1 }], + 'max-len': ['warn', { + code: 120, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + }], + + // ES6 + 'arrow-spacing': 'error', + 'no-var': 'error', + 'prefer-const': 'error', + 'prefer-arrow-callback': 'warn', + 'prefer-template': 'warn', + }, +}; diff --git a/dashcaddy-api/.prettierrc b/dashcaddy-api/.prettierrc new file mode 100644 index 0000000..7ef273f --- /dev/null +++ b/dashcaddy-api/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "printWidth": 120, + "arrowParens": "always" +} diff --git a/dashcaddy-api/__tests__/api-endpoints.test.js b/dashcaddy-api/__tests__/api-endpoints.test.js index 4a5598f..05b5366 100644 --- a/dashcaddy-api/__tests__/api-endpoints.test.js +++ b/dashcaddy-api/__tests__/api-endpoints.test.js @@ -77,7 +77,7 @@ describe('API Endpoints', () => { name: 'Test Service', logo: '/assets/test.png', ip: 'localhost', - tailscaleOnly: false + tailscaleOnly: false, }); // Now get services @@ -87,7 +87,7 @@ describe('API Endpoints', () => { expect(res.body.length).toBe(1); expect(res.body[0]).toMatchObject({ id: 'test-service', - name: 'Test Service' + name: 'Test Service', }); }); @@ -113,7 +113,7 @@ describe('API Endpoints', () => { name: 'Plex', logo: '/assets/plex.png', ip: 'localhost', - tailscaleOnly: false + tailscaleOnly: false, }; const res = await request(app) @@ -134,7 +134,7 @@ describe('API Endpoints', () => { test('should reject duplicate service IDs', async () => { const service = { id: 'duplicate', - name: 'Duplicate Service' + name: 'Duplicate Service', }; // Add first time @@ -153,7 +153,7 @@ describe('API Endpoints', () => { .post('/api/services') .send({ // Missing 'id' and 'name' - logo: '/assets/test.png' + logo: '/assets/test.png', }); expect(res.statusCode).toBe(400); @@ -164,7 +164,7 @@ describe('API Endpoints', () => { const maliciousService = { id: 'test', name: '', - logo: '/assets/test.png' + logo: '/assets/test.png', }; const res = await request(app) @@ -192,8 +192,8 @@ describe('API Endpoints', () => { promises.push( request(app).post('/api/services').send({ id: `service-${i}`, - name: `Service ${i}` - }) + name: `Service ${i}`, + }), ); } @@ -215,11 +215,11 @@ describe('API Endpoints', () => { // Add test services await request(app).post('/api/services').send({ id: 'service1', - name: 'Service 1' + name: 'Service 1', }); await request(app).post('/api/services').send({ id: 'service2', - name: 'Service 2' + name: 'Service 2', }); }); @@ -246,7 +246,7 @@ describe('API Endpoints', () => { // Try to delete the same service twice simultaneously const promises = [ request(app).delete('/api/services/service1'), - request(app).delete('/api/services/service1') + request(app).delete('/api/services/service1'), ]; const results = await Promise.all(promises); @@ -263,7 +263,7 @@ describe('API Endpoints', () => { const services = [ { id: 'plex', name: 'Plex' }, { id: 'jellyfin', name: 'Jellyfin' }, - { id: 'emby', name: 'Emby' } + { id: 'emby', name: 'Emby' }, ]; const res = await request(app) @@ -282,13 +282,13 @@ describe('API Endpoints', () => { // Add initial service await request(app).post('/api/services').send({ id: 'old', - name: 'Old Service' + name: 'Old Service', }); // Import new services (should replace) const newServices = [ { id: 'new1', name: 'New Service 1' }, - { id: 'new2', name: 'New Service 2' } + { id: 'new2', name: 'New Service 2' }, ]; await request(app).put('/api/services').send(newServices); @@ -360,7 +360,7 @@ describe('API Endpoints', () => { test('should save config', async () => { const config = { theme: 'dark', - domain: 'test.local' + domain: 'test.local', }; const res = await request(app) diff --git a/dashcaddy-api/__tests__/auth-manager.test.js b/dashcaddy-api/__tests__/auth-manager.test.js index f76c0a4..a29fcf9 100644 --- a/dashcaddy-api/__tests__/auth-manager.test.js +++ b/dashcaddy-api/__tests__/auth-manager.test.js @@ -12,7 +12,7 @@ const credentialManager = require('../credential-manager'); // Mock credential manager jest.mock('../credential-manager'); jest.mock('../logger-utils', () => ({ - safeLog: jest.fn() + safeLog: jest.fn(), })); describe('AuthManager', () => { @@ -166,8 +166,8 @@ describe('AuthManager', () => { expect(credentialManager.save).toHaveBeenCalledWith( expect.stringMatching(/^auth\.apikey\./), expect.objectContaining({ - keySecret: expect.any(String) - }) + keySecret: expect.any(String), + }), ); }); @@ -179,8 +179,8 @@ describe('AuthManager', () => { expect.objectContaining({ name: 'test-key', scopes: ['read'], - createdAt: expect.any(String) - }) + createdAt: expect.any(String), + }), ); }); @@ -210,12 +210,12 @@ describe('AuthManager', () => { // Mock credential manager to return the stored key credentialManager.get.mockResolvedValueOnce({ - keySecret: key.split('_')[2] + keySecret: key.split('_')[2], }); credentialManager.get.mockResolvedValueOnce({ name: 'test-key', scopes: ['read', 'write'], - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), }); const validated = await authManager.validateAPIKey(key); @@ -239,7 +239,7 @@ describe('AuthManager', () => { }); test('should reject non-existent API key', async () => { - const fakeKey = 'dk_' + crypto.randomBytes(16).toString('hex') + '_' + crypto.randomBytes(32).toString('hex'); + const fakeKey = `dk_${ crypto.randomBytes(16).toString('hex') }_${ crypto.randomBytes(32).toString('hex')}`; credentialManager.get.mockResolvedValue(null); // Key doesn't exist const validated = await authManager.validateAPIKey(fakeKey); @@ -252,7 +252,7 @@ describe('AuthManager', () => { credentialManager.get.mockResolvedValueOnce({ keySecret: key.split('_')[2], - revoked: true // Key is revoked + revoked: true, // Key is revoked }); const validated = await authManager.validateAPIKey(key); @@ -278,7 +278,7 @@ describe('AuthManager', () => { const { id } = await authManager.generateAPIKey('test-key'); credentialManager.get.mockResolvedValue({ - keySecret: 'test-secret' + keySecret: 'test-secret', }); const revoked = await authManager.revokeAPIKey(id); @@ -288,8 +288,8 @@ describe('AuthManager', () => { `auth.apikey.${id}`, expect.objectContaining({ revoked: true, - revokedAt: expect.any(String) - }) + revokedAt: expect.any(String), + }), ); }); @@ -305,19 +305,19 @@ describe('AuthManager', () => { test('should list all API keys with metadata', async () => { credentialManager.list.mockResolvedValue([ 'auth.metadata.key1', - 'auth.metadata.key2' + 'auth.metadata.key2', ]); credentialManager.get.mockResolvedValueOnce({ name: 'Key 1', scopes: ['read'], - createdAt: '2026-01-01T00:00:00Z' + createdAt: '2026-01-01T00:00:00Z', }); credentialManager.get.mockResolvedValueOnce({ name: 'Key 2', scopes: ['read', 'write'], - createdAt: '2026-01-02T00:00:00Z' + createdAt: '2026-01-02T00:00:00Z', }); const keys = await authManager.listAPIKeys(); diff --git a/dashcaddy-api/__tests__/backup-manager.test.js b/dashcaddy-api/__tests__/backup-manager.test.js index b222a78..62792ee 100644 --- a/dashcaddy-api/__tests__/backup-manager.test.js +++ b/dashcaddy-api/__tests__/backup-manager.test.js @@ -198,7 +198,7 @@ describe('cleanupOldBackups', () => { name: 'daily', status: 'success', timestamp: new Date(Date.now() - i * 86400000).toISOString(), - locations: [{ type: 'local', path: `/tmp/fake-${i}.backup` }] + locations: [{ type: 'local', path: `/tmp/fake-${i}.backup` }], }); } diff --git a/dashcaddy-api/__tests__/config.test.js b/dashcaddy-api/__tests__/config.test.js index e239291..3690583 100644 --- a/dashcaddy-api/__tests__/config.test.js +++ b/dashcaddy-api/__tests__/config.test.js @@ -47,7 +47,7 @@ describe('Config Routes', () => { const validConfig = { tld: 'sami', theme: 'dark', - timezone: 'America/New_York' + timezone: 'America/New_York', }; const res = await request(app) @@ -76,7 +76,7 @@ describe('Config Routes', () => { test('should return 400 for config with invalid field values', async () => { const invalidConfig = { tld: 123, // tld must be a string - dns: 'not-an-object' // dns must be an object + dns: 'not-an-object', // dns must be an object }; const res = await request(app) diff --git a/dashcaddy-api/__tests__/credential-manager.test.js b/dashcaddy-api/__tests__/credential-manager.test.js index d905109..f2c561e 100644 --- a/dashcaddy-api/__tests__/credential-manager.test.js +++ b/dashcaddy-api/__tests__/credential-manager.test.js @@ -68,7 +68,7 @@ describe('store', () => { 'key-with-dashes', 'key_with_underscores', 'key:with:colons', - 'key/with/slashes' + 'key/with/slashes', ]; for (const key of specialKeys) { @@ -83,8 +83,8 @@ describe('store', () => { 'password!@#$%^&*()', 'token\nwith\nnewlines', 'json{"key":"value"}', - 'unicode==G', - 'quotes"and\'apostrophes' + 'unicode=���=���G��', + 'quotes"and\'apostrophes', ]; for (let i = 0; i < specialValues.length; i++) { @@ -210,7 +210,7 @@ describe('getMetadata', () => { description: 'API Key', service: 'GitHub', expiresAt: '2026-12-31', - createdBy: 'admin' + createdBy: 'admin', }; await credentialManager.store('meta.complex', 'value', metadata); @@ -328,7 +328,7 @@ describe('Concurrent Access', () => { const promises = [ credentialManager.store('concurrent.key', 'value1'), credentialManager.store('concurrent.key', 'value2'), - credentialManager.store('concurrent.key', 'value3') + credentialManager.store('concurrent.key', 'value3'), ]; await Promise.all(promises); @@ -359,7 +359,7 @@ describe('Concurrent Access', () => { const promises = [ credentialManager.retrieve('readwrite.key'), credentialManager.store('readwrite.key', 'updated'), - credentialManager.retrieve('readwrite.key') + credentialManager.retrieve('readwrite.key'), ]; const results = await Promise.all(promises); @@ -496,7 +496,7 @@ describe('Credential Manager - Extended Coverage', () => { const promises = [ credentialManager.delete('delete.concurrent'), credentialManager.delete('delete.concurrent'), - credentialManager.delete('delete.concurrent') + credentialManager.delete('delete.concurrent'), ]; // Should not throw @@ -532,7 +532,7 @@ describe('Credential Manager - Extended Coverage', () => { }); test('should handle unicode characters', async () => { - const unicode = 'S+s+S+t = +++++ ++++++'; + const unicode = 'S+�s�+S+�t�� =��� +�+�+�+�+� +�+�+�+�+�+�'; const stored = await credentialManager.store('unicode.key', unicode); expect(stored).toBe(true); @@ -621,7 +621,7 @@ describe('Credential Manager - Extended Coverage', () => { description: 'Production database password', createdAt: new Date().toISOString(), owner: 'admin', - tags: ['production', 'database'] + tags: ['production', 'database'], }; await credentialManager.store('meta.full', 'value', metadata); @@ -648,7 +648,7 @@ describe('Credential Manager - Extended Coverage', () => { test('should handle metadata with special characters', async () => { const metadata = { description: 'Test with "quotes" and \'apostrophes\'', - notes: 'Line 1\nLine 2\tTabbed' + notes: 'Line 1\nLine 2\tTabbed', }; await credentialManager.store('meta.special', 'value', metadata); diff --git a/dashcaddy-api/__tests__/crypto-utils.test.js b/dashcaddy-api/__tests__/crypto-utils.test.js index 10b5f42..332aafb 100644 --- a/dashcaddy-api/__tests__/crypto-utils.test.js +++ b/dashcaddy-api/__tests__/crypto-utils.test.js @@ -43,14 +43,14 @@ describe('encrypt / decrypt', () => { test('throws on tampered ciphertext', () => { const encrypted = cryptoUtils.encrypt('test'); const parts = encrypted.split(':'); - parts[2] = 'AAAA' + parts[2].slice(4); // tamper with ciphertext + parts[2] = `AAAA${ parts[2].slice(4)}`; // tamper with ciphertext expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow(); }); test('throws on tampered authTag', () => { const encrypted = cryptoUtils.encrypt('test'); const parts = encrypted.split(':'); - parts[1] = 'AAAA' + parts[1].slice(4); // tamper with auth tag + parts[1] = `AAAA${ parts[1].slice(4)}`; // tamper with auth tag expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow(); }); diff --git a/dashcaddy-api/__tests__/docker-security.test.js b/dashcaddy-api/__tests__/docker-security.test.js index f8b8686..7ee45ff 100644 --- a/dashcaddy-api/__tests__/docker-security.test.js +++ b/dashcaddy-api/__tests__/docker-security.test.js @@ -151,7 +151,7 @@ describe('DockerSecurity Module', () => { }); test('should handle very long image names', () => { - const longName = 'registry.example.com/team/project/' + 'a'.repeat(100) + ':v1.2.3'; + const longName = `registry.example.com/team/project/${ 'a'.repeat(100) }:v1.2.3`; dockerSecurity.setTrustedDigest(longName, 'sha256:long'); expect(dockerSecurity.config.trustedDigests[longName]).toBe('sha256:long'); diff --git a/dashcaddy-api/__tests__/edge-cases.test.js b/dashcaddy-api/__tests__/edge-cases.test.js index 84a1658..fbe69ca 100644 --- a/dashcaddy-api/__tests__/edge-cases.test.js +++ b/dashcaddy-api/__tests__/edge-cases.test.js @@ -202,7 +202,7 @@ describe('Edge Case Tests', () => { .send({ id: 'path-traversal', name: 'Path Traversal', - logo: '../../../../../../etc/passwd' + logo: '../../../../../../etc/passwd', }); // Should handle safely @@ -255,7 +255,7 @@ describe('Edge Case Tests', () => { test('should handle bulk import of 200 services', async () => { const bulkServices = Array.from({ length: 200 }, (_, i) => ({ id: `bulk-${i}`, - name: `Bulk Service ${i}` + name: `Bulk Service ${i}`, })); const res = await request(app) @@ -277,7 +277,7 @@ describe('Edge Case Tests', () => { .send({ id: 'large-data', name: 'Large Data', - description: largeData + description: largeData, }); // Might reject due to size @@ -290,7 +290,7 @@ describe('Edge Case Tests', () => { const promises = Array.from({ length: 20 }, (_, i) => request(app) .post('/api/services') - .send({ id: `concurrent-${i}`, name: `Concurrent ${i}` }) + .send({ id: `concurrent-${i}`, name: `Concurrent ${i}` }), ); const results = await Promise.all(promises); @@ -317,7 +317,7 @@ describe('Edge Case Tests', () => { // Simultaneously add again and delete const [addRes, deleteRes] = await Promise.all([ request(app).post('/api/services').send({ id: 'race', name: 'Race 2' }), - request(app).delete('/api/services/race') + request(app).delete('/api/services/race'), ]); // One should succeed, states should be consistent @@ -331,7 +331,7 @@ describe('Edge Case Tests', () => { const [res1, res2] = await Promise.all([ request(app).put('/api/services').send(set1), - request(app).put('/api/services').send(set2) + request(app).put('/api/services').send(set2), ]); // Both operations should complete @@ -463,7 +463,7 @@ describe('Edge Case Tests', () => { test('should handle double-encoded JSON', async () => { const doubleEncoded = JSON.stringify( - JSON.stringify({ id: 'double', name: 'Double Encoded' }) + JSON.stringify({ id: 'double', name: 'Double Encoded' }), ); const res = await request(app) @@ -525,7 +525,7 @@ describe('Edge Case Tests', () => { test('should handle configuration with nested arrays', async () => { const config = { - nested: [[['deep', 'array'], ['values']], [['more']]] + nested: [[['deep', 'array'], ['values']], [['more']]], }; const res = await request(app) @@ -558,7 +558,7 @@ describe('Edge Case Tests', () => { // Delete twice at once const [res1, res2] = await Promise.all([ request(app).delete('/api/services/delete-me'), - request(app).delete('/api/services/delete-me') + request(app).delete('/api/services/delete-me'), ]); // One should succeed (200), one should fail (404) diff --git a/dashcaddy-api/__tests__/health-checker.test.js b/dashcaddy-api/__tests__/health-checker.test.js index 0b3e63d..260b4f4 100644 --- a/dashcaddy-api/__tests__/health-checker.test.js +++ b/dashcaddy-api/__tests__/health-checker.test.js @@ -37,25 +37,25 @@ describe('evaluateHealth', () => { test('returns false when expectedBodyPattern regex does not match', () => { expect(healthChecker.evaluateHealth(200, 'error occurred', { - expectedBodyPattern: 'ok|healthy' + expectedBodyPattern: 'ok|healthy', })).toBe(false); }); test('returns true when expectedBodyPattern regex matches', () => { expect(healthChecker.evaluateHealth(200, 'status: healthy', { - expectedBodyPattern: 'healthy' + expectedBodyPattern: 'healthy', })).toBe(true); }); test('returns false when expectedBodyContains text is missing', () => { expect(healthChecker.evaluateHealth(200, 'some response', { - expectedBodyContains: 'healthy' + expectedBodyContains: 'healthy', })).toBe(false); }); test('returns true when expectedBodyContains text is present', () => { expect(healthChecker.evaluateHealth(200, 'service is healthy', { - expectedBodyContains: 'healthy' + expectedBodyContains: 'healthy', })).toBe(true); }); @@ -64,21 +64,21 @@ describe('evaluateHealth', () => { expect(healthChecker.evaluateHealth(200, 'healthy ok', { expectedStatusCodes: [200], expectedBodyPattern: 'healthy', - expectedBodyContains: 'ok' + expectedBodyContains: 'ok', })).toBe(true); // Status fails expect(healthChecker.evaluateHealth(500, 'healthy ok', { expectedStatusCodes: [200], expectedBodyPattern: 'healthy', - expectedBodyContains: 'ok' + expectedBodyContains: 'ok', })).toBe(false); // Body pattern fails expect(healthChecker.evaluateHealth(200, 'error', { expectedStatusCodes: [200], expectedBodyPattern: 'healthy', - expectedBodyContains: 'error' + expectedBodyContains: 'error', })).toBe(false); }); }); diff --git a/dashcaddy-api/__tests__/input-validator.test.js b/dashcaddy-api/__tests__/input-validator.test.js index 7af8be9..132882f 100644 --- a/dashcaddy-api/__tests__/input-validator.test.js +++ b/dashcaddy-api/__tests__/input-validator.test.js @@ -9,7 +9,7 @@ const { validateServiceConfig, sanitizeString, isValidPort, - isPrivateIP + isPrivateIP, } = require('../input-validator'); // Helper: extract .errors from ValidationError diff --git a/dashcaddy-api/__tests__/integration.test.js b/dashcaddy-api/__tests__/integration.test.js index 4bff0b1..07a618d 100644 --- a/dashcaddy-api/__tests__/integration.test.js +++ b/dashcaddy-api/__tests__/integration.test.js @@ -61,7 +61,7 @@ describe('Integration Tests', () => { id: 'test-app', name: 'Test Application', logo: '/assets/test.png', - url: 'https://test.test.local' + url: 'https://test.test.local', }; const addRes = await request(app) @@ -81,7 +81,7 @@ describe('Integration Tests', () => { const updatedServices = [{ ...newService, status: 'online', - responseTime: 150 + responseTime: 150, }]; const updateRes = await request(app) @@ -116,7 +116,7 @@ describe('Integration Tests', () => { name: template.name, logo: template.logo, port: 8096, - subdomain: 'jellyfin' + subdomain: 'jellyfin', }; // Step 3: Add configured service @@ -129,7 +129,7 @@ describe('Integration Tests', () => { // Step 4: Verify service is listed const servicesRes = await request(app).get('/api/services'); expect(servicesRes.body).toContainEqual( - expect.objectContaining({ id: 'jellyfin' }) + expect.objectContaining({ id: 'jellyfin' }), ); }); }); @@ -140,11 +140,11 @@ describe('Integration Tests', () => { const services = Array.from({ length: 5 }, (_, i) => ({ id: `concurrent-${i}`, name: `Concurrent Service ${i}`, - logo: `/assets/service-${i}.png` + logo: `/assets/service-${i}.png`, })); const deployPromises = services.map(service => - request(app).post('/api/services').send(service) + request(app).post('/api/services').send(service), ); const results = await Promise.all(deployPromises); @@ -167,7 +167,7 @@ describe('Integration Tests', () => { const bulkServices = [ { id: 'plex', name: 'Plex' }, { id: 'jellyfin', name: 'Jellyfin' }, - { id: 'emby', name: 'Emby' } + { id: 'emby', name: 'Emby' }, ]; const importRes = await request(app) @@ -180,7 +180,7 @@ describe('Integration Tests', () => { const updatedServices = [ { id: 'plex', name: 'Plex', status: 'online' }, { id: 'jellyfin', name: 'Jellyfin' }, - { id: 'emby', name: 'Emby' } + { id: 'emby', name: 'Emby' }, ]; await request(app).put('/api/services').send(updatedServices); @@ -219,7 +219,7 @@ describe('Integration Tests', () => { const config = { domain: 'example.local', theme: 'dark', - enableHealthCheck: false + enableHealthCheck: false, }; const configRes = await request(app) @@ -232,7 +232,7 @@ describe('Integration Tests', () => { const service = { id: 'test', name: 'Test Service', - subdomain: 'test' + subdomain: 'test', }; await request(app).post('/api/services').send(service); @@ -282,7 +282,7 @@ describe('Integration Tests', () => { const service = { id: firstTemplateId, name: singleTemplateRes.body.template.name, - logo: singleTemplateRes.body.template.logo + logo: singleTemplateRes.body.template.logo, }; const deployRes = await request(app) @@ -310,7 +310,7 @@ describe('Integration Tests', () => { name: 'Plex Production', logo: template.logo, port: 32400, - subdomain: 'plex' + subdomain: 'plex', }; const deployRes = await request(app) @@ -322,7 +322,7 @@ describe('Integration Tests', () => { // Verify service exists const servicesRes = await request(app).get('/api/services'); expect(servicesRes.body).toContainEqual( - expect.objectContaining({ id: 'plex-prod' }) + expect.objectContaining({ id: 'plex-prod' }), ); }); }); @@ -367,7 +367,7 @@ describe('Integration Tests', () => { // Start with empty state const initialServices = [ { id: 'base1', name: 'Base 1' }, - { id: 'base2', name: 'Base 2' } + { id: 'base2', name: 'Base 2' }, ]; await request(app).put('/api/services').send(initialServices); @@ -377,7 +377,7 @@ describe('Integration Tests', () => { request(app).post('/api/services').send({ id: 'new1', name: 'New 1' }), request(app).post('/api/services').send({ id: 'new2', name: 'New 2' }), request(app).delete('/api/services/base1'), - request(app).post('/api/services').send({ id: 'new3', name: 'New 3' }) + request(app).post('/api/services').send({ id: 'new3', name: 'New 3' }), ]; await Promise.all(operations); @@ -426,7 +426,7 @@ describe('Integration Tests', () => { const selectedApps = mediaApps.map(id => ({ id, name: templates[id].name, - logo: templates[id].logo + logo: templates[id].logo, })); // Step 3: Deploy all media apps @@ -451,7 +451,7 @@ describe('Integration Tests', () => { const config = { domain: 'homelab.local', theme: 'dark', - enableHealthCheck: true + enableHealthCheck: true, }; await request(app).post('/api/config').send(config); @@ -460,7 +460,7 @@ describe('Integration Tests', () => { const existingServices = [ { id: 'router', name: 'Router', logo: '/assets/router.png' }, { id: 'nas', name: 'NAS', logo: '/assets/nas.png' }, - { id: 'pihole', name: 'Pi-hole', logo: '/assets/pihole.png' } + { id: 'pihole', name: 'Pi-hole', logo: '/assets/pihole.png' }, ]; await request(app).put('/api/services').send(existingServices); @@ -484,7 +484,7 @@ describe('Integration Tests', () => { const oldServices = [ { id: 'old1', name: 'Old Service 1' }, { id: 'old2', name: 'Old Service 2' }, - { id: 'keep', name: 'Keep This' } + { id: 'keep', name: 'Keep This' }, ]; await request(app).put('/api/services').send(oldServices); diff --git a/dashcaddy-api/__tests__/logger-utils.test.js b/dashcaddy-api/__tests__/logger-utils.test.js index ddee4f0..7b95b40 100644 --- a/dashcaddy-api/__tests__/logger-utils.test.js +++ b/dashcaddy-api/__tests__/logger-utils.test.js @@ -12,7 +12,7 @@ describe('logger-utils', () => { username: 'admin', password: 'secret123', apiKey: 'abc-def-ghi', - token: 'xyz123' + token: 'xyz123', }; const result = sanitizeForLog(input); @@ -29,9 +29,9 @@ describe('logger-utils', () => { name: 'Alice', credentials: { password: 'secret', - token: 'abc123' - } - } + token: 'abc123', + }, + }, }; const result = sanitizeForLog(input); @@ -44,7 +44,7 @@ describe('logger-utils', () => { test('should handle arrays', () => { const input = [ { name: 'user1', password: 'pass1' }, - { name: 'user2', secret: 'pass2' } + { name: 'user2', secret: 'pass2' }, ]; const result = sanitizeForLog(input); @@ -63,7 +63,7 @@ describe('logger-utils', () => { test('should support additional sensitive keys', () => { const input = { email: 'user@example.com', - ssn: '123-45-6789' + ssn: '123-45-6789', }; const result = sanitizeForLog(input, ['ssn']); @@ -76,7 +76,7 @@ describe('logger-utils', () => { const input = { PASSWORD: 'secret', ApiKey: 'key123', - Bearer_Token: 'token456' + Bearer_Token: 'token456', }; const result = sanitizeForLog(input); @@ -125,7 +125,7 @@ describe('logger-utils', () => { test('should create safe log object with message and sanitized data', () => { const result = safeLog('User login', { username: 'alice', - password: 'secret123' + password: 'secret123', }); expect(result).toHaveProperty('message', 'User login'); diff --git a/dashcaddy-api/__tests__/notifications.test.js b/dashcaddy-api/__tests__/notifications.test.js index eb69ada..2948c3e 100644 --- a/dashcaddy-api/__tests__/notifications.test.js +++ b/dashcaddy-api/__tests__/notifications.test.js @@ -72,8 +72,8 @@ describe('Notification Routes', () => { .send({ events: { containerDown: true, - containerUp: false - } + containerUp: false, + }, }); expect(res.statusCode).toBe(200); @@ -87,9 +87,9 @@ describe('Notification Routes', () => { providers: { discord: { enabled: true, - webhookUrl: 'not-a-valid-url' - } - } + webhookUrl: 'not-a-valid-url', + }, + }, }); expect(res.statusCode).toBe(400); @@ -102,9 +102,9 @@ describe('Notification Routes', () => { providers: { ntfy: { enabled: true, - topic: 'invalid topic with spaces!!!' - } - } + topic: 'invalid topic with spaces!!!', + }, + }, }); expect(res.statusCode).toBe(400); diff --git a/dashcaddy-api/__tests__/resource-monitor.test.js b/dashcaddy-api/__tests__/resource-monitor.test.js index b11e7ba..69a7acb 100644 --- a/dashcaddy-api/__tests__/resource-monitor.test.js +++ b/dashcaddy-api/__tests__/resource-monitor.test.js @@ -27,7 +27,7 @@ function makeStat(cpu = 10, memory = 50, timestamp = new Date().toISOString()) { memory: { usage: memory * 1024 * 1024, limit: 1024 * 1024 * 1024, percent: memory, usageMB: memory, limitMB: 1024 }, network: { rxBytes: 0, txBytes: 0, rxMB: 0, txMB: 0 }, disk: { readBytes: 0, writeBytes: 0, readMB: 0, writeMB: 0 }, - pids: 5 + pids: 5, }; } @@ -95,7 +95,7 @@ describe('getAggregatedStats', () => { const now = new Date().toISOString(); resourceMonitor.stats.set('c1', { name: '/app', - history: [makeStat(10, 50, now), makeStat(30, 50, now), makeStat(20, 50, now)] + history: [makeStat(10, 50, now), makeStat(30, 50, now), makeStat(20, 50, now)], }); const agg = resourceMonitor.getAggregatedStats('c1', 24); expect(agg.cpu.avg).toBe(20); @@ -107,7 +107,7 @@ describe('getAggregatedStats', () => { const now = new Date().toISOString(); resourceMonitor.stats.set('c1', { name: '/app', - history: [makeStat(10, 40, now), makeStat(10, 60, now), makeStat(10, 80, now)] + history: [makeStat(10, 40, now), makeStat(10, 60, now), makeStat(10, 80, now)], }); const agg = resourceMonitor.getAggregatedStats('c1', 24); expect(agg.memory.avg).toBe(60); @@ -239,7 +239,7 @@ describe('exportStats / importStats', () => { test('import restores stats from backup', () => { const backup = { stats: { 'c1': { name: '/app', history: [makeStat()] } }, - alerts: { 'c1': { enabled: true, cpuThreshold: 80 } } + alerts: { 'c1': { enabled: true, cpuThreshold: 80 } }, }; resourceMonitor.importStats(backup); expect(resourceMonitor.stats.has('c1')).toBe(true); diff --git a/dashcaddy-api/__tests__/security.test.js b/dashcaddy-api/__tests__/security.test.js index 756cdec..1126d3e 100644 --- a/dashcaddy-api/__tests__/security.test.js +++ b/dashcaddy-api/__tests__/security.test.js @@ -150,7 +150,7 @@ describe('Sites Route Security', () => { .post('/api/site/external') .send({ subdomain: 'test', - externalUrl: 'https://evil.com/path{inject}' + externalUrl: 'https://evil.com/path{inject}', }); // Should be rejected — either 400 (our validation) or 500 (URL constructor throws on {}) @@ -164,7 +164,7 @@ describe('Sites Route Security', () => { .post('/api/site/external') .send({ subdomain: 'test', - externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234' + externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234', }); expect(res.statusCode).toBe(400); @@ -183,7 +183,7 @@ describe('Sites Route Security', () => { .post('/api/site/external') .send({ subdomain: '../etc/passwd', - externalUrl: 'https://example.com' + externalUrl: 'https://example.com', }); expect(res.statusCode).toBe(400); @@ -205,7 +205,7 @@ describe('Error Logs — No Stack Trace Leak', () => { '[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 @@ -334,10 +334,10 @@ describe('Backup Security', () => { files: { encryptionKey: { type: 'text', - content: 'malicious-key-data' - } - } - } + content: 'malicious-key-data', + }, + }, + }, }); // The encryptionKey should be skipped (not in fileMapping) @@ -392,8 +392,8 @@ describe('Custom Volume Path Validation', () => { port: '32400', customVolumes: [{ containerPath: '/config', - hostPath: '/etc/shadow' - }] + hostPath: '/etc/shadow', + }], }); // The deploy will likely fail for other reasons (no Docker, etc.) @@ -414,7 +414,7 @@ describe('Logo Delete Path Traversal', () => { // Write config with a malicious logo path const configWithMaliciousLogo = { customLogo: '/assets/../../etc/passwd', - customLogoDark: '/assets/../../../root/.ssh/id_rsa' + customLogoDark: '/assets/../../../root/.ssh/id_rsa', }; await fsp.writeFile(testConfigFile, JSON.stringify(configWithMaliciousLogo), 'utf8'); @@ -439,7 +439,7 @@ describe('DNS Server SSRF Prevention', () => { .query({ domain: 'test.sami', type: 'A', - server: '169.254.169.254' // AWS metadata endpoint + server: '169.254.169.254', // AWS metadata endpoint }); // Must never succeed — 400 (server rejected), 401 (no token), or 500 (dns not configured in test) @@ -452,7 +452,7 @@ describe('DNS Server SSRF Prevention', () => { .send({ domain: 'test.sami', ipAddress: '192.168.1.1', - server: '10.0.0.1' // Not a configured DNS server + server: '10.0.0.1', // Not a configured DNS server }); expect(res.statusCode).not.toBe(200); @@ -463,7 +463,7 @@ describe('DNS Server SSRF Prevention', () => { .get('/api/dns/resolve') .query({ domain: 'test.sami', - server: '127.0.0.1' + server: '127.0.0.1', }); expect(res.statusCode).not.toBe(200); @@ -503,7 +503,7 @@ describe('HTTP Fetch Response Size Limit', () => { 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' + path.join(__dirname, '..', 'server.js'), 'utf8', ); expect(serverSource).toContain('MAX_RESPONSE_SIZE'); expect(serverSource).toContain('10 * 1024 * 1024'); @@ -516,7 +516,7 @@ describe('HTTP Fetch Response Size Limit', () => { describe('Middleware Security', () => { test('middleware should set Secure flag on cookies', () => { const middlewareSource = fs.readFileSync( - path.join(__dirname, '..', 'middleware.js'), 'utf8' + path.join(__dirname, '..', 'middleware.js'), 'utf8', ); // Verify the Set-Cookie string includes Secure expect(middlewareSource).toContain('; Secure;'); @@ -529,7 +529,7 @@ describe('Middleware Security', () => { describe('Config Save Atomicity', () => { test('saveConfig should use state manager for locking', () => { const serverSource = fs.readFileSync( - path.join(__dirname, '..', 'server.js'), 'utf8' + path.join(__dirname, '..', 'server.js'), 'utf8', ); // Verify saveConfig uses configStateManager.update (not raw fs.writeFile) expect(serverSource).toContain('configStateManager.update'); @@ -542,7 +542,7 @@ describe('Config Save Atomicity', () => { 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' + path.join(__dirname, '..', 'routes', 'sites.js'), 'utf8', ); // Verify the unsafe character regex exists expect(sitesSource).toContain('unsafeCaddyChars'); @@ -556,7 +556,7 @@ describe('External URL Security', () => { describe('Credential Manager File Locking', () => { test('credential-manager should use proper-lockfile', () => { const cmSource = fs.readFileSync( - path.join(__dirname, '..', 'credential-manager.js'), 'utf8' + path.join(__dirname, '..', 'credential-manager.js'), 'utf8', ); expect(cmSource).toContain('proper-lockfile'); expect(cmSource).toContain('_lockedUpdate'); @@ -569,7 +569,7 @@ describe('Credential Manager File Locking', () => { describe('TOTP Config File Security', () => { test('loadTotpConfig should delete secret from file data', () => { const serverSource = fs.readFileSync( - path.join(__dirname, '..', 'server.js'), 'utf8' + path.join(__dirname, '..', 'server.js'), 'utf8', ); // Verify the secret deletion exists in loadTotpConfig expect(serverSource).toContain('delete loaded.secret'); @@ -577,7 +577,7 @@ describe('TOTP Config File Security', () => { test('totp verify-setup should not write secret to config file', () => { const totpSource = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8' + path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8', ); // Verify totpConfig.secret assignment is NOT present expect(totpSource).not.toContain('totpConfig.secret = pendingSecret'); @@ -591,7 +591,7 @@ describe('TOTP Config File Security', () => { 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' + path.join(__dirname, '..', 'routes', 'apps', 'helpers.js'), 'utf8', ); expect(helpersSource).toContain('allowedRoots'); expect(helpersSource).toContain('platformPaths.dockerData'); @@ -605,7 +605,7 @@ describe('Helpers — Volume Security', () => { describe('Error Logs — Response Format', () => { test('errorlogs.js should not include details field', () => { const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8' + path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8', ); // The parsed log object should only have timestamp, context, error // NOT details (which contains stack traces) @@ -622,7 +622,7 @@ describe('Error Logs — Response Format', () => { 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' + path.join(__dirname, '..', 'routes', 'config', 'assets.js'), 'utf8', ); expect(source).toContain('path.basename(logoPath)'); // Should NOT use string replace for path extraction @@ -636,7 +636,7 @@ describe('Assets — Logo Path Safety', () => { 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' + path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8', ); // Should have a comment about deliberate exclusion expect(source).toContain('encryptionKey deliberately excluded'); @@ -646,7 +646,7 @@ describe('Backup — Encryption Key Exclusion', () => { test('backup.js restore fileMapping should not include encryptionKey', () => { const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' + 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) @@ -659,7 +659,7 @@ describe('Backup — Encryption Key Exclusion', () => { test('backup.js should require TOTP for sensitive restores', () => { const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' + path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8', ); expect(source).toContain('sensitiveKeys'); expect(source).toContain('totpCode'); @@ -673,7 +673,7 @@ describe('Backup — Encryption Key Exclusion', () => { describe('DNS — Server Validation Function', () => { test('dns.js should define validateDnsServer', () => { const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8' + path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8', ); expect(source).toContain('function validateDnsServer'); expect(source).toContain('configuredIps'); @@ -687,7 +687,7 @@ describe('DNS — Server Validation Function', () => { describe('Containers — Verified Container Access', () => { test('containers.js update route should use getVerifiedContainer', () => { const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8' + path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8', ); // update and check-update should both use getVerifiedContainer const updateSection = source.substring(source.indexOf("'/:id/update'")); @@ -704,7 +704,7 @@ describe('Containers — Verified Container Access', () => { describe('Logs — Symlink Resolution', () => { test('logs.js should use realpath for symlink resolution', () => { const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8' + path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8', ); expect(source).toContain('fsp.realpath'); expect(source).toContain('path.sep'); @@ -712,7 +712,7 @@ describe('Logs — Symlink Resolution', () => { test('logs.js container routes should verify container exists', () => { const source = fs.readFileSync( - path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8' + path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8', ); // Both container/:id and stream/:id should have inspect + NotFoundError expect(source).toContain('container.inspect()'); diff --git a/dashcaddy-api/__tests__/sites.test.js b/dashcaddy-api/__tests__/sites.test.js index d6742e2..e6913f5 100644 --- a/dashcaddy-api/__tests__/sites.test.js +++ b/dashcaddy-api/__tests__/sites.test.js @@ -85,7 +85,7 @@ describe('Sites Routes', () => { .send({ subdomain: 'INVALID SUBDOMAIN!', targetUrl: 'https://example.com', - name: 'Test' + name: 'Test', }); expect(res.statusCode).toBe(400); diff --git a/dashcaddy-api/__tests__/state-manager.test.js b/dashcaddy-api/__tests__/state-manager.test.js index a007b86..8c411d1 100644 --- a/dashcaddy-api/__tests__/state-manager.test.js +++ b/dashcaddy-api/__tests__/state-manager.test.js @@ -29,7 +29,7 @@ describe('StateManager', () => { stateManager = new StateManager(testFile, { lockRetries: 20, lockRetryInterval: 50, - lockTimeout: 15000 + lockTimeout: 15000, }); }); @@ -53,7 +53,7 @@ describe('StateManager', () => { test('write and read roundtrip', async () => { const testData = [ { id: '1', name: 'Test Service 1' }, - { id: '2', name: 'Test Service 2' } + { id: '2', name: 'Test Service 2' }, ]; await stateManager.write(testData); @@ -88,7 +88,7 @@ describe('StateManager', () => { await stateManager.write([ { id: '1', name: 'Service 1' }, { id: '2', name: 'Service 2' }, - { id: '3', name: 'Service 3' } + { id: '3', name: 'Service 3' }, ]); await stateManager.removeItem('2'); @@ -100,7 +100,7 @@ describe('StateManager', () => { test('updateItem updates by ID', async () => { await stateManager.write([ - { id: '1', name: 'Service 1', status: 'offline' } + { id: '1', name: 'Service 1', status: 'offline' }, ]); await stateManager.updateItem('1', { status: 'online' }); @@ -130,7 +130,7 @@ describe('StateManager', () => { stateManager.update(items => { items.push({ id: `service-${i}`, name: `Service ${i}` }); return items; - }) + }), ); } @@ -187,7 +187,7 @@ describe('StateManager', () => { await expect( stateManager.update(() => { throw new Error('Test error'); - }) + }), ).rejects.toThrow('Test error'); }); }); @@ -229,7 +229,7 @@ describe('StateManager', () => { id: `service-${i}`, name: `Service ${i}`, url: `https://service-${i}.example.com`, - status: 'online' + status: 'online', }); } diff --git a/dashcaddy-api/__tests__/update-manager.test.js b/dashcaddy-api/__tests__/update-manager.test.js index 96f5c20..8157064 100644 --- a/dashcaddy-api/__tests__/update-manager.test.js +++ b/dashcaddy-api/__tests__/update-manager.test.js @@ -123,7 +123,7 @@ describe('configureAutoUpdate', () => { updateManager.configureAutoUpdate('c1', { enabled: true, schedule: 'daily', - securityOnly: true + securityOnly: true, }); expect(updateManager.config.autoUpdate['c1'].schedule).toBe('daily'); expect(updateManager.config.autoUpdate['c1'].securityOnly).toBe(true); diff --git a/dashcaddy-api/app-templates.js b/dashcaddy-api/app-templates.js index e5b13b8..3f06b68 100644 --- a/dashcaddy-api/app-templates.js +++ b/dashcaddy-api/app-templates.js @@ -3,2495 +3,2495 @@ const APP_TEMPLATES = { // === MEDIA & ENTERTAINMENT === - "plex": { - name: "Plex", - description: "Stream your personal media collection anywhere", - icon: "🎬", - logo: "/assets/plex.png", - category: "Media", + 'plex': { + name: 'Plex', + description: 'Stream your personal media collection anywhere', + icon: '🎬', + logo: '/assets/plex.png', + category: 'Media', popularity: 95, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "plexinc/pms-docker:latest", - ports: ["{{PORT}}:32400"], + image: 'plexinc/pms-docker:latest', + ports: ['{{PORT}}:32400'], volumes: [ - "/opt/plex/config:/config", - "/opt/plex/transcode:/transcode", - "{{MEDIA_PATH}}:/data" + '/opt/plex/config:/config', + '/opt/plex/transcode:/transcode', + '{{MEDIA_PATH}}:/data', ], environment: { - "PLEX_CLAIM": "", - "ADVERTISE_IP": "http://{{HOST_IP}}:{{PORT}}/", - "PLEX_UID": "1000", - "PLEX_GID": "1000" - } + 'PLEX_CLAIM': '', + 'ADVERTISE_IP': 'http://{{HOST_IP}}:{{PORT}}/', + 'PLEX_UID': '1000', + 'PLEX_GID': '1000', + }, }, - subdomain: "plex", + subdomain: 'plex', defaultPort: 32400, - healthCheck: "/web/index.html", + healthCheck: '/web/index.html', subpathSupport: 'none', mediaMount: { required: true, - containerPath: "/data", - label: "Media Library", - description: "Folder containing your movies, TV shows, music, etc.", - defaultPath: "/media" + containerPath: '/data', + label: 'Media Library', + description: 'Folder containing your movies, TV shows, music, etc.', + defaultPath: '/media', }, claimToken: { - envVar: "PLEX_CLAIM", - label: "Plex Claim Token", - description: "Get from https://plex.tv/claim - expires in 4 minutes!", - placeholder: "claim-xxxxxxxxxxxxxxxxxxxx", - helpUrl: "https://plex.tv/claim" + envVar: 'PLEX_CLAIM', + label: 'Plex Claim Token', + description: 'Get from https://plex.tv/claim - expires in 4 minutes!', + placeholder: 'claim-xxxxxxxxxxxxxxxxxxxx', + helpUrl: 'https://plex.tv/claim', }, setupInstructions: [ - "Get your claim token from https://plex.tv/claim", - "Add your media libraries in the web interface", - "Configure remote access settings" + 'Get your claim token from https://plex.tv/claim', + 'Add your media libraries in the web interface', + 'Configure remote access settings', ], - requiredVolumes: ["config", "media"], - optionalVolumes: ["transcode"] + requiredVolumes: ['config', 'media'], + optionalVolumes: ['transcode'], }, - "jellyfin": { - name: "Jellyfin", - description: "Free software media system - alternative to Plex", - icon: "🍿", - logo: "/assets/jellyfin.png", - category: "Media", + 'jellyfin': { + name: 'Jellyfin', + description: 'Free software media system - alternative to Plex', + icon: '🍿', + logo: '/assets/jellyfin.png', + category: 'Media', popularity: 88, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "jellyfin/jellyfin:latest", - ports: ["{{PORT}}:8096"], + image: 'jellyfin/jellyfin:latest', + ports: ['{{PORT}}:8096'], volumes: [ - "/opt/jellyfin/config:/config", - "/opt/jellyfin/cache:/cache", - "{{MEDIA_PATH}}:/media" + '/opt/jellyfin/config:/config', + '/opt/jellyfin/cache:/cache', + '{{MEDIA_PATH}}:/media', ], environment: { - "JELLYFIN_PublishedServerUrl": "https://{{SUBDOMAIN}}.sami" - } + 'JELLYFIN_PublishedServerUrl': 'https://{{SUBDOMAIN}}.sami', + }, }, - subdomain: "jellyfin", + subdomain: 'jellyfin', defaultPort: 8096, - healthCheck: "/health", + healthCheck: '/health', subpathSupport: 'native', urlBaseEnv: 'JELLYFIN_BaseUrl', mediaMount: { required: true, - containerPath: "/media", - label: "Media Library", - description: "Folder containing your movies, TV shows, music, etc.", - defaultPath: "/media" + containerPath: '/media', + label: 'Media Library', + description: 'Folder containing your movies, TV shows, music, etc.', + defaultPath: '/media', }, setupInstructions: [ - "Complete the initial setup wizard", - "Add your media libraries", - "Configure user accounts and permissions" - ] + 'Complete the initial setup wizard', + 'Add your media libraries', + 'Configure user accounts and permissions', + ], }, - "emby": { - name: "Emby", - description: "Personal media server with apps for all devices", - icon: "🎥", - logo: "/assets/emby.png", - category: "Media", + 'emby': { + name: 'Emby', + description: 'Personal media server with apps for all devices', + icon: '🎥', + logo: '/assets/emby.png', + category: 'Media', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "emby/embyserver:latest", - ports: ["{{PORT}}:8096"], + image: 'emby/embyserver:latest', + ports: ['{{PORT}}:8096'], volumes: [ - "/opt/emby/config:/config", - "/opt/emby/cache:/cache", - "{{MEDIA_PATH}}:/media" + '/opt/emby/config:/config', + '/opt/emby/cache:/cache', + '{{MEDIA_PATH}}:/media', ], environment: { - "UID": "1000", - "GID": "1000" - } + 'UID': '1000', + 'GID': '1000', + }, }, - subdomain: "emby", + subdomain: 'emby', defaultPort: 8096, - healthCheck: "/emby/web/", + healthCheck: '/emby/web/', subpathSupport: 'none', mediaMount: { required: true, - containerPath: "/media", - label: "Media Library", - description: "Folder containing your movies, TV shows, music, etc.", - defaultPath: "/media" + containerPath: '/media', + label: 'Media Library', + description: 'Folder containing your movies, TV shows, music, etc.', + defaultPath: '/media', }, setupInstructions: [ - "Complete the initial setup wizard at the web interface", - "Add your media libraries (Movies, TV Shows, Music)", - "Configure user accounts and permissions", - "Install Emby apps on your devices for remote access" - ] + 'Complete the initial setup wizard at the web interface', + 'Add your media libraries (Movies, TV Shows, Music)', + 'Configure user accounts and permissions', + 'Install Emby apps on your devices for remote access', + ], }, - "sonarr": { - name: "Sonarr", - description: "Smart PVR for newsgroup and bittorrent users", - icon: "📺", - category: "Media Management", + 'sonarr': { + name: 'Sonarr', + description: 'Smart PVR for newsgroup and bittorrent users', + icon: '📺', + category: 'Media Management', popularity: 82, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "linuxserver/sonarr:latest", - ports: ["{{PORT}}:8989"], + image: 'linuxserver/sonarr:latest', + ports: ['{{PORT}}:8989'], volumes: [ - "/opt/sonarr/config:/config", - "/downloads:/downloads", - "/tv:/tv" + '/opt/sonarr/config:/config', + '/downloads:/downloads', + '/tv:/tv', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "sonarr", + subdomain: 'sonarr', defaultPort: 8989, - healthCheck: "/api/v3/system/status", + healthCheck: '/api/v3/system/status', subpathSupport: 'native', urlBaseEnv: 'URL_BASE', setupInstructions: [ - "Configure download clients (qBittorrent, etc.)", - "Add indexers for content discovery", - "Set up root folders for TV shows" - ] + 'Configure download clients (qBittorrent, etc.)', + 'Add indexers for content discovery', + 'Set up root folders for TV shows', + ], }, - "radarr": { - name: "Radarr", - description: "Movie collection manager for Usenet and BitTorrent", - icon: "🎭", - category: "Media Management", + 'radarr': { + name: 'Radarr', + description: 'Movie collection manager for Usenet and BitTorrent', + icon: '🎭', + category: 'Media Management', popularity: 80, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "linuxserver/radarr:latest", - ports: ["{{PORT}}:7878"], + image: 'linuxserver/radarr:latest', + ports: ['{{PORT}}:7878'], volumes: [ - "/opt/radarr/config:/config", - "/downloads:/downloads", - "/movies:/movies" + '/opt/radarr/config:/config', + '/downloads:/downloads', + '/movies:/movies', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "radarr", + subdomain: 'radarr', defaultPort: 7878, - healthCheck: "/api/v3/system/status", + healthCheck: '/api/v3/system/status', subpathSupport: 'native', - urlBaseEnv: 'URL_BASE' + urlBaseEnv: 'URL_BASE', }, - "prowlarr": { - name: "Prowlarr", - description: "Indexer manager/proxy for *arr applications", - icon: "🔍", - category: "Media Management", + 'prowlarr': { + name: 'Prowlarr', + description: 'Indexer manager/proxy for *arr applications', + icon: '🔍', + category: 'Media Management', popularity: 75, - difficulty: "Advanced", + difficulty: 'Advanced', docker: { - image: "linuxserver/prowlarr:latest", - ports: ["{{PORT}}:9696"], - volumes: ["/opt/prowlarr/config:/config"], + image: 'linuxserver/prowlarr:latest', + ports: ['{{PORT}}:9696'], + volumes: ['/opt/prowlarr/config:/config'], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "prowlarr", + subdomain: 'prowlarr', defaultPort: 9696, - healthCheck: "/api/v1/system/status", + healthCheck: '/api/v1/system/status', subpathSupport: 'native', - urlBaseEnv: 'URL_BASE' + urlBaseEnv: 'URL_BASE', }, - "qbittorrent": { - name: "qBittorrent", - description: "Lightweight BitTorrent client with web UI", - icon: "⬇️", - category: "Downloads", + 'qbittorrent': { + name: 'qBittorrent', + description: 'Lightweight BitTorrent client with web UI', + icon: '⬇️', + category: 'Downloads', popularity: 90, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "linuxserver/qbittorrent:latest", - ports: ["{{PORT}}:8080", "6881:6881", "6881:6881/udp"], + image: 'linuxserver/qbittorrent:latest', + ports: ['{{PORT}}:8080', '6881:6881', '6881:6881/udp'], volumes: [ - "/opt/qbittorrent/config:/config", - "/downloads:/downloads" + '/opt/qbittorrent/config:/config', + '/downloads:/downloads', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}", - "WEBUI_PORT": "8080" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + 'WEBUI_PORT': '8080', + }, }, - subdomain: "torrent", + subdomain: 'torrent', defaultPort: 8080, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'native', urlBaseEnv: 'WEBUI_BASE_PATH', setupInstructions: [ - "Default login: admin/adminadmin", - "Change default password immediately", - "Configure download paths" - ] + 'Default login: admin/adminadmin', + 'Change default password immediately', + 'Configure download paths', + ], }, // === PRODUCTIVITY & TOOLS === - "nextcloud": { - name: "Nextcloud", - description: "Self-hosted productivity platform and file sync", - icon: "☁️", - category: "Productivity", + 'nextcloud': { + name: 'Nextcloud', + description: 'Self-hosted productivity platform and file sync', + icon: '☁️', + category: 'Productivity', popularity: 92, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "nextcloud:latest", - ports: ["{{PORT}}:80"], + image: 'nextcloud:latest', + ports: ['{{PORT}}:80'], volumes: [ - "/opt/nextcloud/html:/var/www/html", - "/opt/nextcloud/data:/var/www/html/data" + '/opt/nextcloud/html:/var/www/html', + '/opt/nextcloud/data:/var/www/html/data', ], environment: { - "NEXTCLOUD_ADMIN_USER": "admin", - "NEXTCLOUD_ADMIN_PASSWORD": "{{NEXTCLOUD_ADMIN_PASSWORD}}", - "NEXTCLOUD_TRUSTED_DOMAINS": "{{SUBDOMAIN}}.sami" - } + 'NEXTCLOUD_ADMIN_USER': 'admin', + 'NEXTCLOUD_ADMIN_PASSWORD': '{{NEXTCLOUD_ADMIN_PASSWORD}}', + 'NEXTCLOUD_TRUSTED_DOMAINS': '{{SUBDOMAIN}}.sami', + }, }, - subdomain: "cloud", + subdomain: 'cloud', defaultPort: 8080, - healthCheck: "/status.php", + healthCheck: '/status.php', subpathSupport: 'none', setupInstructions: [ - "Change the default admin password", - "Configure trusted domains", - "Install recommended apps" + 'Change the default admin password', + 'Configure trusted domains', + 'Install recommended apps', ], secrets: [ { - envVar: "NEXTCLOUD_ADMIN_PASSWORD", - label: "Admin Password", - description: "Secure password for Nextcloud admin account", - type: "password", + envVar: 'NEXTCLOUD_ADMIN_PASSWORD', + label: 'Admin Password', + description: 'Secure password for Nextcloud admin account', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "vscode-server": { - name: "VS Code Server", - description: "Visual Studio Code in your browser", - icon: "💻", - category: "Development", + 'vscode-server': { + name: 'VS Code Server', + description: 'Visual Studio Code in your browser', + icon: '💻', + category: 'Development', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "codercom/code-server:latest", - ports: ["{{PORT}}:8080"], + image: 'codercom/code-server:latest', + ports: ['{{PORT}}:8080'], volumes: [ - "/opt/vscode/config:/home/coder/.config", - "/opt/vscode/projects:/home/coder/projects" + '/opt/vscode/config:/home/coder/.config', + '/opt/vscode/projects:/home/coder/projects', ], environment: { - "PASSWORD": "{{VSCODE_PASSWORD}}" - } + 'PASSWORD': '{{VSCODE_PASSWORD}}', + }, }, - subdomain: "code", + subdomain: 'code', defaultPort: 8443, - healthCheck: "/healthz", + healthCheck: '/healthz', subpathSupport: 'strip', secrets: [ { - envVar: "VSCODE_PASSWORD", - label: "Access Password", - description: "Password to access VS Code Server web interface", - type: "password", + envVar: 'VSCODE_PASSWORD', + label: 'Access Password', + description: 'Password to access VS Code Server web interface', + type: 'password', required: true, - generate: "alphanumeric", - length: 24 - } - ] + generate: 'alphanumeric', + length: 24, + }, + ], }, // === MONITORING & ADMIN === - "portainer": { - name: "Portainer", - description: "Docker container management UI", - icon: "🐳", - category: "Management", + 'portainer': { + name: 'Portainer', + description: 'Docker container management UI', + icon: '🐳', + category: 'Management', popularity: 88, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "portainer/portainer-ce:latest", - ports: ["{{PORT}}:9000"], + image: 'portainer/portainer-ce:latest', + ports: ['{{PORT}}:9000'], volumes: [ - "/var/run/docker.sock:/var/run/docker.sock", - "/opt/portainer/data:/data" - ] + '/var/run/docker.sock:/var/run/docker.sock', + '/opt/portainer/data:/data', + ], }, - subdomain: "portainer", + subdomain: 'portainer', defaultPort: 9000, - healthCheck: "/api/status", - subpathSupport: 'strip' + healthCheck: '/api/status', + subpathSupport: 'strip', }, - "grafana": { - name: "Grafana", - description: "Analytics and interactive visualization platform", - icon: "📊", - category: "Monitoring", + 'grafana': { + name: 'Grafana', + description: 'Analytics and interactive visualization platform', + icon: '📊', + category: 'Monitoring', popularity: 78, - difficulty: "Advanced", + difficulty: 'Advanced', docker: { - image: "grafana/grafana:latest", - ports: ["{{PORT}}:3000"], - volumes: ["/opt/grafana/data:/var/lib/grafana"], + image: 'grafana/grafana:latest', + ports: ['{{PORT}}:3000'], + volumes: ['/opt/grafana/data:/var/lib/grafana'], environment: { - "GF_SECURITY_ADMIN_PASSWORD": "{{GRAFANA_ADMIN_PASSWORD}}" - } + 'GF_SECURITY_ADMIN_PASSWORD': '{{GRAFANA_ADMIN_PASSWORD}}', + }, }, - subdomain: "grafana", + subdomain: 'grafana', defaultPort: 3000, - healthCheck: "/api/health", + healthCheck: '/api/health', subpathSupport: 'native', urlBaseEnv: 'GF_SERVER_ROOT_URL', secrets: [ { - envVar: "GRAFANA_ADMIN_PASSWORD", - label: "Admin Password", - description: "Password for Grafana admin user", - type: "password", + envVar: 'GRAFANA_ADMIN_PASSWORD', + label: 'Admin Password', + description: 'Password for Grafana admin user', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "uptime-kuma": { - name: "Uptime Kuma", - description: "Self-hosted monitoring tool like Uptime Robot", - icon: "📈", - category: "Monitoring", + 'uptime-kuma': { + name: 'Uptime Kuma', + description: 'Self-hosted monitoring tool like Uptime Robot', + icon: '📈', + category: 'Monitoring', popularity: 82, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "louislam/uptime-kuma:latest", - ports: ["{{PORT}}:3001"], - volumes: ["/opt/uptime-kuma:/app/data"] + image: 'louislam/uptime-kuma:latest', + ports: ['{{PORT}}:3001'], + volumes: ['/opt/uptime-kuma:/app/data'], }, - subdomain: "uptime", + subdomain: 'uptime', defaultPort: 3002, - healthCheck: "/", - subpathSupport: 'strip' + healthCheck: '/', + subpathSupport: 'strip', }, // === NETWORKING & SECURITY === - "pihole": { - name: "Pi-hole", - description: "Network-wide ad blocker and DNS sinkhole", - icon: "🛡️", - category: "Networking", + 'pihole': { + name: 'Pi-hole', + description: 'Network-wide ad blocker and DNS sinkhole', + icon: '🛡️', + category: 'Networking', popularity: 90, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "pihole/pihole:latest", - ports: ["{{PORT}}:80", "53:53", "53:53/udp"], + image: 'pihole/pihole:latest', + ports: ['{{PORT}}:80', '53:53', '53:53/udp'], volumes: [ - "/opt/pihole/etc:/etc/pihole", - "/opt/pihole/dnsmasq:/etc/dnsmasq.d" + '/opt/pihole/etc:/etc/pihole', + '/opt/pihole/dnsmasq:/etc/dnsmasq.d', ], environment: { - "WEBPASSWORD": "{{PIHOLE_WEB_PASSWORD}}", - "TZ": "{{TIMEZONE}}" - } + 'WEBPASSWORD': '{{PIHOLE_WEB_PASSWORD}}', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "pihole", + subdomain: 'pihole', defaultPort: 80, - healthCheck: "/admin/", + healthCheck: '/admin/', subpathSupport: 'strip', secrets: [ { - envVar: "PIHOLE_WEB_PASSWORD", - label: "Web Interface Password", - description: "Password for Pi-hole admin web interface", - type: "password", + envVar: 'PIHOLE_WEB_PASSWORD', + label: 'Web Interface Password', + description: 'Password for Pi-hole admin web interface', + type: 'password', required: true, - generate: "alphanumeric", - length: 24 - } - ] + generate: 'alphanumeric', + length: 24, + }, + ], }, - "wireguard": { - name: "WireGuard VPN", - description: "Fast, modern, secure VPN tunnel", - icon: "🔒", - category: "Networking", + 'wireguard': { + name: 'WireGuard VPN', + description: 'Fast, modern, secure VPN tunnel', + icon: '🔒', + category: 'Networking', popularity: 75, - difficulty: "Advanced", + difficulty: 'Advanced', docker: { - image: "linuxserver/wireguard:latest", - ports: ["{{PORT}}:51820/udp"], - volumes: ["/opt/wireguard/config:/config"], + image: 'linuxserver/wireguard:latest', + ports: ['{{PORT}}:51820/udp'], + volumes: ['/opt/wireguard/config:/config'], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}", - "SERVERURL": "{{HOST_IP}}", - "SERVERPORT": "{{PORT}}", - "PEERS": "1" + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + 'SERVERURL': '{{HOST_IP}}', + 'SERVERPORT': '{{PORT}}', + 'PEERS': '1', }, - capabilities: ["NET_ADMIN", "SYS_MODULE"] + capabilities: ['NET_ADMIN', 'SYS_MODULE'], }, - subdomain: "vpn", + subdomain: 'vpn', defaultPort: 51820, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Configure your external IP/domain", - "Set up port forwarding on router", - "Download client configs from /config/peer1/" - ] + 'Configure your external IP/domain', + 'Set up port forwarding on router', + 'Download client configs from /config/peer1/', + ], }, // === DNS SERVERS === - "technitium": { - name: "Technitium DNS Server", - description: "Modern DNS server with web UI for managing private zones", - icon: "🌐", - category: "DNS", + 'technitium': { + name: 'Technitium DNS Server', + description: 'Modern DNS server with web UI for managing private zones', + icon: '🌐', + category: 'DNS', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', features: [ - "Web-based management interface", - "Private zone management for .sami domain", - "DHCP server integration", - "DNS-over-HTTPS and DNS-over-TLS support", - "Built-in DNSSEC support" + 'Web-based management interface', + 'Private zone management for .sami domain', + 'DHCP server integration', + 'DNS-over-HTTPS and DNS-over-TLS support', + 'Built-in DNSSEC support', ], docker: { - image: "technitium/dns-server:latest", - ports: ["{{PORT}}:5380", "53:53", "53:53/udp"], - volumes: ["/opt/technitium/config:/etc/dns"], + image: 'technitium/dns-server:latest', + ports: ['{{PORT}}:5380', '53:53', '53:53/udp'], + volumes: ['/opt/technitium/config:/etc/dns'], environment: { - "DNS_SERVER_DOMAIN": "dns1.sami", - "DNS_SERVER_ADMIN_PASSWORD": "{{DNS_ADMIN_PASSWORD}}" - } + 'DNS_SERVER_DOMAIN': 'dns1.sami', + 'DNS_SERVER_ADMIN_PASSWORD': '{{DNS_ADMIN_PASSWORD}}', + }, }, - subdomain: "dns1", + subdomain: 'dns1', defaultPort: 5380, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Access web interface at https://dns1.sami", - "Login with admin credentials", + 'Access web interface at https://dns1.sami', + 'Login with admin credentials', "Create a primary zone for 'sami' domain", - "Add A records for your services (e.g., plex.sami -> 192.168.1.100)", - "Configure your devices to use this DNS server" + 'Add A records for your services (e.g., plex.sami -> 192.168.1.100)', + 'Configure your devices to use this DNS server', ], - requiredVolumes: ["config"], + requiredVolumes: ['config'], optionalVolumes: [], secrets: [ { - envVar: "DNS_ADMIN_PASSWORD", - label: "Admin Password", - description: "Password for Technitium DNS admin account", - type: "password", + envVar: 'DNS_ADMIN_PASSWORD', + label: 'Admin Password', + description: 'Password for Technitium DNS admin account', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "bind9": { - name: "BIND9 DNS Server", - description: "Industry-standard DNS server - powerful and flexible", - icon: "🔧", - category: "DNS", + 'bind9': { + name: 'BIND9 DNS Server', + description: 'Industry-standard DNS server - powerful and flexible', + icon: '🔧', + category: 'DNS', popularity: 80, - difficulty: "Advanced", + difficulty: 'Advanced', features: [ - "Industry standard DNS server", - "Full RFC compliance", - "Advanced zone management", - "DNSSEC support", - "High performance and reliability" + 'Industry standard DNS server', + 'Full RFC compliance', + 'Advanced zone management', + 'DNSSEC support', + 'High performance and reliability', ], docker: { - image: "ubuntu/bind9:latest", - ports: ["53:53", "53:53/udp", "{{PORT}}:953"], + image: 'ubuntu/bind9:latest', + ports: ['53:53', '53:53/udp', '{{PORT}}:953'], volumes: [ - "/opt/bind9/config:/etc/bind", - "/opt/bind9/cache:/var/cache/bind", - "/opt/bind9/records:/var/lib/bind" + '/opt/bind9/config:/etc/bind', + '/opt/bind9/cache:/var/cache/bind', + '/opt/bind9/records:/var/lib/bind', ], environment: { - "BIND9_USER": "root", - "TZ": "{{TIMEZONE}}" - } + 'BIND9_USER': 'root', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "dns2", + subdomain: 'dns2', defaultPort: 953, healthCheck: null, subpathSupport: 'strip', setupInstructions: [ - "Configure zone files in /opt/bind9/config/", - "Create named.conf.local for your .sami zone", - "Add zone file: /opt/bind9/records/db.sami", - "Restart container to apply changes", - "Test with: dig @localhost sami" + 'Configure zone files in /opt/bind9/config/', + 'Create named.conf.local for your .sami zone', + 'Add zone file: /opt/bind9/records/db.sami', + 'Restart container to apply changes', + 'Test with: dig @localhost sami', ], - requiredVolumes: ["config", "records"], - optionalVolumes: ["cache"] + requiredVolumes: ['config', 'records'], + optionalVolumes: ['cache'], }, - "powerdns": { - name: "PowerDNS", - description: "High-performance DNS server with SQL backend", - icon: "⚡", - category: "DNS", + 'powerdns': { + name: 'PowerDNS', + description: 'High-performance DNS server with SQL backend', + icon: '⚡', + category: 'DNS', popularity: 75, - difficulty: "Intermediate", + difficulty: 'Intermediate', features: [ - "SQL database backend (MySQL/PostgreSQL)", - "RESTful API for automation", - "PowerDNS Admin web interface available", - "Geographic load balancing", - "DNSSEC support" + 'SQL database backend (MySQL/PostgreSQL)', + 'RESTful API for automation', + 'PowerDNS Admin web interface available', + 'Geographic load balancing', + 'DNSSEC support', ], docker: { - image: "pschiffe/pdns-mysql:latest", - ports: ["53:53", "53:53/udp", "{{PORT}}:8081"], - volumes: ["/opt/powerdns/data:/var/lib/mysql"], + image: 'pschiffe/pdns-mysql:latest', + ports: ['53:53', '53:53/udp', '{{PORT}}:8081'], + volumes: ['/opt/powerdns/data:/var/lib/mysql'], environment: { - "PDNS_api": "yes", - "PDNS_api_key": "{{POWERDNS_API_KEY}}", - "PDNS_webserver": "yes", - "PDNS_webserver_address": "0.0.0.0", - "PDNS_webserver_allow_from": "0.0.0.0/0", - "MYSQL_ROOT_PASSWORD": "{{MYSQL_ROOT_PASSWORD}}" - } + 'PDNS_api': 'yes', + 'PDNS_api_key': '{{POWERDNS_API_KEY}}', + 'PDNS_webserver': 'yes', + 'PDNS_webserver_address': '0.0.0.0', + 'PDNS_webserver_allow_from': '0.0.0.0/0', + 'MYSQL_ROOT_PASSWORD': '{{MYSQL_ROOT_PASSWORD}}', + }, }, - subdomain: "dns3", + subdomain: 'dns3', defaultPort: 8081, - healthCheck: "/api/v1/servers", + healthCheck: '/api/v1/servers', subpathSupport: 'strip', setupInstructions: [ - "Access API at https://dns3.sami:8081", - "Use API key for authentication", - "Create zone via API or PowerDNS Admin", - "Add records for your .sami domain", - "Configure devices to use DNS server" + 'Access API at https://dns3.sami:8081', + 'Use API key for authentication', + 'Create zone via API or PowerDNS Admin', + 'Add records for your .sami domain', + 'Configure devices to use DNS server', ], - requiredVolumes: ["data"], + requiredVolumes: ['data'], optionalVolumes: [], secrets: [ { - envVar: "POWERDNS_API_KEY", - label: "API Key", - description: "API key for PowerDNS webserver authentication", - type: "password", + envVar: 'POWERDNS_API_KEY', + label: 'API Key', + description: 'API key for PowerDNS webserver authentication', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 + generate: 'alphanumeric', + length: 32, }, { - envVar: "MYSQL_ROOT_PASSWORD", - label: "MySQL Root Password", - description: "Root password for embedded MySQL database", - type: "password", + envVar: 'MYSQL_ROOT_PASSWORD', + label: 'MySQL Root Password', + description: 'Root password for embedded MySQL database', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "coredns": { - name: "CoreDNS", - description: "Cloud-native DNS server - lightweight and flexible", - icon: "☁️", - category: "DNS", + 'coredns': { + name: 'CoreDNS', + description: 'Cloud-native DNS server - lightweight and flexible', + icon: '☁️', + category: 'DNS', popularity: 70, - difficulty: "Intermediate", + difficulty: 'Intermediate', features: [ - "Plugin-based architecture", - "Kubernetes-native (used in K8s)", - "Lightweight and fast", - "Prometheus metrics", - "Easy configuration via Corefile" + 'Plugin-based architecture', + 'Kubernetes-native (used in K8s)', + 'Lightweight and fast', + 'Prometheus metrics', + 'Easy configuration via Corefile', ], docker: { - image: "coredns/coredns:latest", - ports: ["53:53", "53:53/udp"], - volumes: ["/opt/coredns/config:/etc/coredns"], + image: 'coredns/coredns:latest', + ports: ['53:53', '53:53/udp'], + volumes: ['/opt/coredns/config:/etc/coredns'], environment: {}, - command: ["-conf", "/etc/coredns/Corefile"] + command: ['-conf', '/etc/coredns/Corefile'], }, - subdomain: "dns4", + subdomain: 'dns4', defaultPort: 53, healthCheck: null, subpathSupport: 'strip', setupInstructions: [ - "Create Corefile in /opt/coredns/config/", - "Define .sami zone with file plugin", - "Create zone file with your records", - "Restart container to load config", - "Test with: dig @localhost test.sami" + 'Create Corefile in /opt/coredns/config/', + 'Define .sami zone with file plugin', + 'Create zone file with your records', + 'Restart container to load config', + 'Test with: dig @localhost test.sami', ], - requiredVolumes: ["config"], - optionalVolumes: [] + requiredVolumes: ['config'], + optionalVolumes: [], }, // === FILE MANAGEMENT === - "filebrowser": { - name: "FileBrowser", - description: "Web-based file manager with sharing capabilities", - icon: "📁", - category: "Files", + 'filebrowser': { + name: 'FileBrowser', + description: 'Web-based file manager with sharing capabilities', + icon: '📁', + category: 'Files', popularity: 88, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "filebrowser/filebrowser:latest", - ports: ["{{PORT}}:80"], + image: 'filebrowser/filebrowser:latest', + ports: ['{{PORT}}:80'], volumes: [ - "/opt/filebrowser/data:/srv", - "/opt/filebrowser/database:/database" + '/opt/filebrowser/data:/srv', + '/opt/filebrowser/database:/database', ], - environment: {} + environment: {}, }, - subdomain: "files", + subdomain: 'files', defaultPort: 8085, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Default login: admin/admin", - "Change default password immediately", - "Configure user permissions and shares" - ] + 'Default login: admin/admin', + 'Change default password immediately', + 'Configure user permissions and shares', + ], }, - "syncthing": { - name: "Syncthing", - description: "Continuous file synchronization between devices", - icon: "🔄", - category: "Files", + 'syncthing': { + name: 'Syncthing', + description: 'Continuous file synchronization between devices', + icon: '🔄', + category: 'Files', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "linuxserver/syncthing:latest", - ports: ["{{PORT}}:8384", "22000:22000", "21027:21027/udp"], + image: 'linuxserver/syncthing:latest', + ports: ['{{PORT}}:8384', '22000:22000', '21027:21027/udp'], volumes: [ - "/opt/syncthing/config:/config", - "/opt/syncthing/data:/data" + '/opt/syncthing/config:/config', + '/opt/syncthing/data:/data', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "sync", + subdomain: 'sync', defaultPort: 8384, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Add devices using their Device IDs", - "Configure shared folders", - "Set up folder synchronization" - ] + 'Add devices using their Device IDs', + 'Configure shared folders', + 'Set up folder synchronization', + ], }, // === COMMUNICATION & EMAIL === - "mailserver": { - name: "Docker Mailserver", - description: "Full-featured email server with SMTP, IMAP, spam filtering", - icon: "📧", - category: "Communication", + 'mailserver': { + name: 'Docker Mailserver', + description: 'Full-featured email server with SMTP, IMAP, spam filtering', + icon: '📧', + category: 'Communication', popularity: 70, - difficulty: "Advanced", + difficulty: 'Advanced', docker: { - image: "mailserver/docker-mailserver:latest", - ports: ["{{PORT}}:25", "143:143", "587:587", "993:993"], + image: 'mailserver/docker-mailserver:latest', + ports: ['{{PORT}}:25', '143:143', '587:587', '993:993'], volumes: [ - "/opt/mailserver/data:/var/mail", - "/opt/mailserver/state:/var/mail-state", - "/opt/mailserver/logs:/var/log/mail", - "/opt/mailserver/config:/tmp/docker-mailserver" + '/opt/mailserver/data:/var/mail', + '/opt/mailserver/state:/var/mail-state', + '/opt/mailserver/logs:/var/log/mail', + '/opt/mailserver/config:/tmp/docker-mailserver', ], environment: { - "ENABLE_SPAMASSASSIN": "1", - "ENABLE_CLAMAV": "1", - "ENABLE_FAIL2BAN": "1", - "ONE_DIR": "1", - "TZ": "{{TIMEZONE}}" - } + 'ENABLE_SPAMASSASSIN': '1', + 'ENABLE_CLAMAV': '1', + 'ENABLE_FAIL2BAN': '1', + 'ONE_DIR': '1', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "mail", + subdomain: 'mail', defaultPort: 25, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Configure DNS records (MX, SPF, DKIM, DMARC)", - "Create email accounts using setup.sh", - "Set up SSL certificates for secure connections" - ] + 'Configure DNS records (MX, SPF, DKIM, DMARC)', + 'Create email accounts using setup.sh', + 'Set up SSL certificates for secure connections', + ], }, - "roundcube": { - name: "Roundcube", - description: "Modern webmail client with rich features", - icon: "💌", - category: "Communication", + 'roundcube': { + name: 'Roundcube', + description: 'Modern webmail client with rich features', + icon: '💌', + category: 'Communication', popularity: 72, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "roundcube/roundcubemail:latest", - ports: ["{{PORT}}:80"], + image: 'roundcube/roundcubemail:latest', + ports: ['{{PORT}}:80'], volumes: [ - "/opt/roundcube/config:/var/roundcube/config", - "/opt/roundcube/db:/var/roundcube/db" + '/opt/roundcube/config:/var/roundcube/config', + '/opt/roundcube/db:/var/roundcube/db', ], environment: { - "ROUNDCUBEMAIL_DEFAULT_HOST": "mail.{{SUBDOMAIN}}.sami", - "ROUNDCUBEMAIL_SMTP_SERVER": "mail.{{SUBDOMAIN}}.sami" - } + 'ROUNDCUBEMAIL_DEFAULT_HOST': 'mail.{{SUBDOMAIN}}.sami', + 'ROUNDCUBEMAIL_SMTP_SERVER': 'mail.{{SUBDOMAIN}}.sami', + }, }, - subdomain: "webmail", + subdomain: 'webmail', defaultPort: 8086, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Configure IMAP/SMTP server settings", - "Set up database connection", - "Customize appearance and plugins" - ] + 'Configure IMAP/SMTP server settings', + 'Set up database connection', + 'Customize appearance and plugins', + ], }, - "matrix": { - name: "Matrix Synapse", - description: "Decentralized, secure messaging and collaboration", - icon: "💬", - category: "Communication", + 'matrix': { + name: 'Matrix Synapse', + description: 'Decentralized, secure messaging and collaboration', + icon: '💬', + category: 'Communication', popularity: 75, - difficulty: "Advanced", + difficulty: 'Advanced', docker: { - image: "matrixdotorg/synapse:latest", - ports: ["{{PORT}}:8008"], - volumes: ["/opt/matrix/data:/data"], + image: 'matrixdotorg/synapse:latest', + ports: ['{{PORT}}:8008'], + volumes: ['/opt/matrix/data:/data'], environment: { - "SYNAPSE_SERVER_NAME": "{{SUBDOMAIN}}.sami", - "SYNAPSE_REPORT_STATS": "no" - } + 'SYNAPSE_SERVER_NAME': '{{SUBDOMAIN}}.sami', + 'SYNAPSE_REPORT_STATS': 'no', + }, }, - subdomain: "matrix", + subdomain: 'matrix', defaultPort: 8008, - healthCheck: "/_matrix/client/versions", + healthCheck: '/_matrix/client/versions', subpathSupport: 'none', setupInstructions: [ - "Generate initial config with --generate", - "Configure homeserver.yaml", - "Set up federation if needed" - ] + 'Generate initial config with --generate', + 'Configure homeserver.yaml', + 'Set up federation if needed', + ], }, - "rocketchat": { - name: "Rocket.Chat", - description: "Team collaboration platform like Slack", - icon: "🚀", - category: "Communication", + 'rocketchat': { + name: 'Rocket.Chat', + description: 'Team collaboration platform like Slack', + icon: '🚀', + category: 'Communication', popularity: 78, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "rocket.chat:latest", - ports: ["{{PORT}}:3000"], - volumes: ["/opt/rocketchat/uploads:/app/uploads"], + image: 'rocket.chat:latest', + ports: ['{{PORT}}:3000'], + volumes: ['/opt/rocketchat/uploads:/app/uploads'], environment: { - "ROOT_URL": "https://{{SUBDOMAIN}}.sami", - "MONGO_URL": "mongodb://mongo:27017/rocketchat" - } + 'ROOT_URL': 'https://{{SUBDOMAIN}}.sami', + 'MONGO_URL': 'mongodb://mongo:27017/rocketchat', + }, }, - subdomain: "chat", + subdomain: 'chat', defaultPort: 3004, - healthCheck: "/api/info", + healthCheck: '/api/info', subpathSupport: 'strip', setupInstructions: [ - "Requires MongoDB - deploy mongo container first", - "Complete admin setup wizard", - "Configure OAuth and integrations" - ] + 'Requires MongoDB - deploy mongo container first', + 'Complete admin setup wizard', + 'Configure OAuth and integrations', + ], }, // === HOME AUTOMATION === - "homeassistant": { - name: "Home Assistant", - description: "Open source home automation platform", - icon: "🏠", - category: "Home Automation", + 'homeassistant': { + name: 'Home Assistant', + description: 'Open source home automation platform', + icon: '🏠', + category: 'Home Automation', popularity: 92, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "homeassistant/home-assistant:stable", - ports: ["{{PORT}}:8123"], + image: 'homeassistant/home-assistant:stable', + ports: ['{{PORT}}:8123'], volumes: [ - "/opt/homeassistant/config:/config", - "/etc/localtime:/etc/localtime:ro" + '/opt/homeassistant/config:/config', + '/etc/localtime:/etc/localtime:ro', ], environment: { - "TZ": "{{TIMEZONE}}" - } + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "home", + subdomain: 'home', defaultPort: 8123, - healthCheck: "/api/", + healthCheck: '/api/', subpathSupport: 'strip', setupInstructions: [ - "Complete onboarding wizard", - "Add integrations for your smart devices", - "Create automations and dashboards" - ] + 'Complete onboarding wizard', + 'Add integrations for your smart devices', + 'Create automations and dashboards', + ], }, - "nodered": { - name: "Node-RED", - description: "Flow-based programming for IoT and automation", - icon: "🔴", - category: "Home Automation", + 'nodered': { + name: 'Node-RED', + description: 'Flow-based programming for IoT and automation', + icon: '🔴', + category: 'Home Automation', popularity: 80, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "nodered/node-red:latest", - ports: ["{{PORT}}:1880"], - volumes: ["/opt/nodered/data:/data"], + image: 'nodered/node-red:latest', + ports: ['{{PORT}}:1880'], + volumes: ['/opt/nodered/data:/data'], environment: { - "TZ": "{{TIMEZONE}}" - } + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "nodered", + subdomain: 'nodered', defaultPort: 1880, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Install additional nodes from palette", - "Create flows for automation", - "Connect to Home Assistant or MQTT" - ] + 'Install additional nodes from palette', + 'Create flows for automation', + 'Connect to Home Assistant or MQTT', + ], }, // === DATABASES === - "postgres": { - name: "PostgreSQL", - description: "Advanced open-source relational database", - icon: "🐘", - category: "Database", + 'postgres': { + name: 'PostgreSQL', + description: 'Advanced open-source relational database', + icon: '🐘', + category: 'Database', popularity: 85, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "postgres:16-alpine", - ports: ["{{PORT}}:5432"], - volumes: ["/opt/postgres/data:/var/lib/postgresql/data"], + image: 'postgres:16-alpine', + ports: ['{{PORT}}:5432'], + volumes: ['/opt/postgres/data:/var/lib/postgresql/data'], environment: { - "POSTGRES_USER": "admin", - "POSTGRES_PASSWORD": "{{POSTGRES_PASSWORD}}", - "POSTGRES_DB": "default" - } + 'POSTGRES_USER': 'admin', + 'POSTGRES_PASSWORD': '{{POSTGRES_PASSWORD}}', + 'POSTGRES_DB': 'default', + }, }, - subdomain: "postgres", + subdomain: 'postgres', defaultPort: 5432, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Change default password immediately", - "Create databases and users as needed", - "Configure pg_hba.conf for remote access" + 'Change default password immediately', + 'Create databases and users as needed', + 'Configure pg_hba.conf for remote access', ], secrets: [ { - envVar: "POSTGRES_PASSWORD", - label: "Admin Password", - description: "Password for PostgreSQL admin user", - type: "password", + envVar: 'POSTGRES_PASSWORD', + label: 'Admin Password', + description: 'Password for PostgreSQL admin user', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "redis": { - name: "Redis", - description: "In-memory data structure store and cache", - icon: "🔴", - category: "Database", + 'redis': { + name: 'Redis', + description: 'In-memory data structure store and cache', + icon: '🔴', + category: 'Database', popularity: 82, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "redis:alpine", - ports: ["{{PORT}}:6379"], - volumes: ["/opt/redis/data:/data"], - environment: {} + image: 'redis:alpine', + ports: ['{{PORT}}:6379'], + volumes: ['/opt/redis/data:/data'], + environment: {}, }, - subdomain: "redis", + subdomain: 'redis', defaultPort: 6379, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Configure redis.conf for persistence", - "Set up authentication if needed", - "Configure maxmemory policy" - ] + 'Configure redis.conf for persistence', + 'Set up authentication if needed', + 'Configure maxmemory policy', + ], }, - "mongodb": { - name: "MongoDB", - description: "Document-oriented NoSQL database", - icon: "🍃", - category: "Database", + 'mongodb': { + name: 'MongoDB', + description: 'Document-oriented NoSQL database', + icon: '🍃', + category: 'Database', popularity: 80, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "mongo:latest", - ports: ["{{PORT}}:27017"], - volumes: ["/opt/mongodb/data:/data/db"], + image: 'mongo:latest', + ports: ['{{PORT}}:27017'], + volumes: ['/opt/mongodb/data:/data/db'], environment: { - "MONGO_INITDB_ROOT_USERNAME": "admin", - "MONGO_INITDB_ROOT_PASSWORD": "{{MONGO_ROOT_PASSWORD}}" - } + 'MONGO_INITDB_ROOT_USERNAME': 'admin', + 'MONGO_INITDB_ROOT_PASSWORD': '{{MONGO_ROOT_PASSWORD}}', + }, }, - subdomain: "mongo", + subdomain: 'mongo', defaultPort: 27017, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Change default admin password", - "Create application databases and users", - "Configure replica set if needed" + 'Change default admin password', + 'Create application databases and users', + 'Configure replica set if needed', ], secrets: [ { - envVar: "MONGO_ROOT_PASSWORD", - label: "Root Password", - description: "Root password for MongoDB admin user", - type: "password", + envVar: 'MONGO_ROOT_PASSWORD', + label: 'Root Password', + description: 'Root password for MongoDB admin user', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "adminer": { - name: "Adminer", - description: "Lightweight database management in single PHP file", - icon: "🗄️", - category: "Database", + 'adminer': { + name: 'Adminer', + description: 'Lightweight database management in single PHP file', + icon: '🗄️', + category: 'Database', popularity: 75, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "adminer:latest", - ports: ["{{PORT}}:8080"], + image: 'adminer:latest', + ports: ['{{PORT}}:8080'], volumes: [], environment: { - "ADMINER_DEFAULT_SERVER": "postgres" - } + 'ADMINER_DEFAULT_SERVER': 'postgres', + }, }, - subdomain: "adminer", + subdomain: 'adminer', defaultPort: 8087, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Connect to your database servers", - "Supports MySQL, PostgreSQL, SQLite, etc." - ] + 'Connect to your database servers', + 'Supports MySQL, PostgreSQL, SQLite, etc.', + ], }, // === SECURITY & AUTH === - "vaultwarden": { - name: "Vaultwarden", - description: "Lightweight Bitwarden-compatible password manager", - icon: "🔑", - category: "Security", + 'vaultwarden': { + name: 'Vaultwarden', + description: 'Lightweight Bitwarden-compatible password manager', + icon: '🔑', + category: 'Security', popularity: 90, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "vaultwarden/server:latest", - ports: ["{{PORT}}:80"], - volumes: ["/opt/vaultwarden/data:/data"], + image: 'vaultwarden/server:latest', + ports: ['{{PORT}}:80'], + volumes: ['/opt/vaultwarden/data:/data'], environment: { - "DOMAIN": "https://{{SUBDOMAIN}}.sami", - "ADMIN_TOKEN": "{{VAULTWARDEN_ADMIN_TOKEN}}" - } + 'DOMAIN': 'https://{{SUBDOMAIN}}.sami', + 'ADMIN_TOKEN': '{{VAULTWARDEN_ADMIN_TOKEN}}', + }, }, - subdomain: "vault", + subdomain: 'vault', defaultPort: 8088, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Change admin token immediately", - "Create your account", - "Install browser extensions and mobile apps" + 'Change admin token immediately', + 'Create your account', + 'Install browser extensions and mobile apps', ], secrets: [ { - envVar: "VAULTWARDEN_ADMIN_TOKEN", - label: "Admin Token", - description: "Admin panel access token for Vaultwarden", - type: "password", + envVar: 'VAULTWARDEN_ADMIN_TOKEN', + label: 'Admin Token', + description: 'Admin panel access token for Vaultwarden', + type: 'password', required: true, - generate: "alphanumeric", - length: 48 - } - ] + generate: 'alphanumeric', + length: 48, + }, + ], }, - "dashca": { - name: "DashCA", - description: "One-click root CA certificate installer for your network", - icon: "🔐", - logo: "/assets/certificate-icon.png", - category: "Security", + 'dashca': { + name: 'DashCA', + description: 'One-click root CA certificate installer for your network', + icon: '🔐', + logo: '/assets/certificate-icon.png', + category: 'Security', popularity: 95, - difficulty: "Easy", + difficulty: 'Easy', isStaticSite: true, // Special flag for non-Docker deployments - subdomain: "ca", + subdomain: 'ca', defaultPort: null, // Static site, no port needed healthCheck: null, subpathSupport: 'strip', features: [ - "Automatic OS detection", - "One-click installation", - "Supports Windows, macOS, Linux, iOS, Android", - "Apple mobileconfig for easy iOS/macOS setup", - "QR code for mobile access", - "Certificate expiration monitoring" + 'Automatic OS detection', + 'One-click installation', + 'Supports Windows, macOS, Linux, iOS, Android', + 'Apple mobileconfig for easy iOS/macOS setup', + 'QR code for mobile access', + 'Certificate expiration monitoring', ], setupInstructions: [ - "New devices: visit http://ca.sami (HTTP, no certificate needed)", + 'New devices: visit http://ca.sami (HTTP, no certificate needed)', "Click the 'Install Certificate' button for your platform", - "Follow platform-specific instructions", - "Verify all *.sami domains now show secure connections" + 'Follow platform-specific instructions', + 'Verify all *.sami domains now show secure connections', ], - tags: ["security", "certificates", "ssl", "tls", "infrastructure"] + tags: ['security', 'certificates', 'ssl', 'tls', 'infrastructure'], }, - "weather": { - name: "Weather", - description: "Live weather widget with temperature, conditions, and wind", - icon: "🌤️", - category: "Utilities", + 'weather': { + name: 'Weather', + description: 'Live weather widget with temperature, conditions, and wind', + icon: '🌤️', + category: 'Utilities', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', isDashboardWidget: true, - widgetSelector: ".weather-widget-container", + widgetSelector: '.weather-widget-container', subdomain: null, defaultPort: null, healthCheck: null, subpathSupport: 'strip', features: [ - "Current temperature and conditions", - "Wind speed and direction", - "Weather icon with emoji fallback", - "Configurable ZIP code", - "Auto-refreshes periodically" + 'Current temperature and conditions', + 'Wind speed and direction', + 'Weather icon with emoji fallback', + 'Configurable ZIP code', + 'Auto-refreshes periodically', ], setupInstructions: [ - "Click the gear icon on the widget to set your ZIP code", - "Weather appears in the top bar next to the logo" + 'Click the gear icon on the widget to set your ZIP code', + 'Weather appears in the top bar next to the logo', ], - tags: ["weather", "widget", "dashboard", "utility"] + tags: ['weather', 'widget', 'dashboard', 'utility'], }, - "digital-clock": { - name: "Digital Clock", - description: "Live digital clock with time, date, and day of week", - icon: "🕐", - category: "Utilities", + 'digital-clock': { + name: 'Digital Clock', + description: 'Live digital clock with time, date, and day of week', + icon: '🕐', + category: 'Utilities', popularity: 80, - difficulty: "Easy", + difficulty: 'Easy', isDashboardWidget: true, - widgetSelector: ".clock-widget-container", + widgetSelector: '.clock-widget-container', subdomain: null, defaultPort: null, healthCheck: null, subpathSupport: 'strip', features: [ - "12-hour format with AM/PM", - "Live seconds display", - "Full date with day of week", - "Responsive sizing across all devices", - "Matches dashboard theme automatically" + '12-hour format with AM/PM', + 'Live seconds display', + 'Full date with day of week', + 'Responsive sizing across all devices', + 'Matches dashboard theme automatically', ], setupInstructions: [ - "Clock appears in the top bar to the right of the weather widget", - "No configuration needed — runs automatically" + 'Clock appears in the top bar to the right of the weather widget', + 'No configuration needed — runs automatically', ], - tags: ["clock", "time", "widget", "dashboard", "utility"] + tags: ['clock', 'time', 'widget', 'dashboard', 'utility'], }, // === MEDIA MANAGEMENT (Additional) === - "lidarr": { - name: "Lidarr", - description: "Music collection manager for Usenet and BitTorrent", - icon: "🎵", - category: "Media Management", + 'lidarr': { + name: 'Lidarr', + description: 'Music collection manager for Usenet and BitTorrent', + icon: '🎵', + category: 'Media Management', popularity: 70, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "linuxserver/lidarr:latest", - ports: ["{{PORT}}:8686"], + image: 'linuxserver/lidarr:latest', + ports: ['{{PORT}}:8686'], volumes: [ - "/opt/lidarr/config:/config", - "/downloads:/downloads", - "/music:/music" + '/opt/lidarr/config:/config', + '/downloads:/downloads', + '/music:/music', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "lidarr", + subdomain: 'lidarr', defaultPort: 8686, - healthCheck: "/api/v1/system/status", + healthCheck: '/api/v1/system/status', subpathSupport: 'native', urlBaseEnv: 'URL_BASE', setupInstructions: [ - "Configure download clients", - "Add indexers", - "Set up root folders for music" - ] + 'Configure download clients', + 'Add indexers', + 'Set up root folders for music', + ], }, - "readarr": { - name: "Readarr", - description: "Book and audiobook collection manager", - icon: "📚", - category: "Media Management", + 'readarr': { + name: 'Readarr', + description: 'Book and audiobook collection manager', + icon: '📚', + category: 'Media Management', popularity: 65, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "linuxserver/readarr:develop", - ports: ["{{PORT}}:8787"], + image: 'linuxserver/readarr:develop', + ports: ['{{PORT}}:8787'], volumes: [ - "/opt/readarr/config:/config", - "/downloads:/downloads", - "/books:/books" + '/opt/readarr/config:/config', + '/downloads:/downloads', + '/books:/books', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "readarr", + subdomain: 'readarr', defaultPort: 8787, - healthCheck: "/api/v1/system/status", + healthCheck: '/api/v1/system/status', subpathSupport: 'native', urlBaseEnv: 'URL_BASE', setupInstructions: [ - "Configure download clients", - "Add indexers for books", - "Set up root folders" - ] + 'Configure download clients', + 'Add indexers for books', + 'Set up root folders', + ], }, - "bazarr": { - name: "Bazarr", - description: "Automatic subtitle downloader for Sonarr and Radarr", - icon: "💬", - category: "Media Management", + 'bazarr': { + name: 'Bazarr', + description: 'Automatic subtitle downloader for Sonarr and Radarr', + icon: '💬', + category: 'Media Management', popularity: 72, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "linuxserver/bazarr:latest", - ports: ["{{PORT}}:6767"], + image: 'linuxserver/bazarr:latest', + ports: ['{{PORT}}:6767'], volumes: [ - "/opt/bazarr/config:/config", - "/movies:/movies", - "/tv:/tv" + '/opt/bazarr/config:/config', + '/movies:/movies', + '/tv:/tv', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "bazarr", + subdomain: 'bazarr', defaultPort: 6767, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'native', urlBaseEnv: 'BASE_URL', setupInstructions: [ - "Connect to Sonarr and Radarr", - "Configure subtitle providers", - "Set language preferences" - ] + 'Connect to Sonarr and Radarr', + 'Configure subtitle providers', + 'Set language preferences', + ], }, - "seerr": { - name: "Seerr", - description: "Media request and discovery manager for Plex, Jellyfin, and Emby", - icon: "🎫", - category: "Media Management", + 'seerr': { + name: 'Seerr', + description: 'Media request and discovery manager for Plex, Jellyfin, and Emby', + icon: '🎫', + category: 'Media Management', popularity: 82, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "ghcr.io/seerr-team/seerr:latest", - ports: ["{{PORT}}:5055"], - volumes: ["/opt/seerr/config:/app/config"], + image: 'ghcr.io/seerr-team/seerr:latest', + ports: ['{{PORT}}:5055'], + volumes: ['/opt/seerr/config:/app/config'], environment: { - "TZ": "{{TIMEZONE}}" + 'TZ': '{{TIMEZONE}}', }, - init: true + init: true, }, - subdomain: "requests", + subdomain: 'requests', defaultPort: 5055, - healthCheck: "/api/v1/status", + healthCheck: '/api/v1/status', subpathSupport: 'native', urlBaseEnv: 'BASE_PATH', setupInstructions: [ - "Connect to Plex, Jellyfin, or Emby server", - "Link Sonarr and Radarr", - "Configure user permissions" - ] + 'Connect to Plex, Jellyfin, or Emby server', + 'Link Sonarr and Radarr', + 'Configure user permissions', + ], }, - "tautulli": { - name: "Tautulli", - description: "Plex media server monitoring and statistics", - icon: "📊", - category: "Media Management", + 'tautulli': { + name: 'Tautulli', + description: 'Plex media server monitoring and statistics', + icon: '📊', + category: 'Media Management', popularity: 78, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "linuxserver/tautulli:latest", - ports: ["{{PORT}}:8181"], - volumes: ["/opt/tautulli/config:/config"], + image: 'linuxserver/tautulli:latest', + ports: ['{{PORT}}:8181'], + volumes: ['/opt/tautulli/config:/config'], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "tautulli", + subdomain: 'tautulli', defaultPort: 8181, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'native', urlBaseEnv: 'TAUTULLI_HTTP_ROOT', setupInstructions: [ - "Connect to Plex server", - "Configure notifications", - "Set up newsletters" - ] + 'Connect to Plex server', + 'Configure notifications', + 'Set up newsletters', + ], }, // === DEVELOPMENT TOOLS === - "gitea": { - name: "Gitea", - description: "Lightweight self-hosted Git service", - icon: "🦊", - category: "Development", + 'gitea': { + name: 'Gitea', + description: 'Lightweight self-hosted Git service', + icon: '🦊', + category: 'Development', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "gitea/gitea:latest", - ports: ["{{PORT}}:3000", "2222:22"], + image: 'gitea/gitea:latest', + ports: ['{{PORT}}:3000', '2222:22'], volumes: [ - "/opt/gitea/data:/data", - "/etc/timezone:/etc/timezone:ro", - "/etc/localtime:/etc/localtime:ro" + '/opt/gitea/data:/data', + '/etc/timezone:/etc/timezone:ro', + '/etc/localtime:/etc/localtime:ro', ], environment: { - "USER_UID": "1000", - "USER_GID": "1000" - } + 'USER_UID': '1000', + 'USER_GID': '1000', + }, }, - subdomain: "gitea", + subdomain: 'gitea', defaultPort: 3005, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'native', urlBaseEnv: 'GITEA__server__ROOT_URL', setupInstructions: [ - "Complete initial setup wizard", - "Create admin account", - "Configure SSH access" - ] + 'Complete initial setup wizard', + 'Create admin account', + 'Configure SSH access', + ], }, - "jenkins": { - name: "Jenkins", - description: "Automation server for CI/CD pipelines", - icon: "🔧", - category: "Development", + 'jenkins': { + name: 'Jenkins', + description: 'Automation server for CI/CD pipelines', + icon: '🔧', + category: 'Development', popularity: 75, - difficulty: "Advanced", + difficulty: 'Advanced', docker: { - image: "jenkins/jenkins:lts", - ports: ["{{PORT}}:8080", "50000:50000"], - volumes: ["/opt/jenkins/data:/var/jenkins_home"], - environment: {} + image: 'jenkins/jenkins:lts', + ports: ['{{PORT}}:8080', '50000:50000'], + volumes: ['/opt/jenkins/data:/var/jenkins_home'], + environment: {}, }, - subdomain: "jenkins", + subdomain: 'jenkins', defaultPort: 8089, - healthCheck: "/login", + healthCheck: '/login', subpathSupport: 'strip', setupInstructions: [ - "Get initial admin password from logs", - "Install suggested plugins", - "Create admin user" - ] + 'Get initial admin password from logs', + 'Install suggested plugins', + 'Create admin user', + ], }, - "drone": { - name: "Drone CI", - description: "Container-native continuous delivery platform", - icon: "🐝", - category: "Development", + 'drone': { + name: 'Drone CI', + description: 'Container-native continuous delivery platform', + icon: '🐝', + category: 'Development', popularity: 70, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "drone/drone:latest", - ports: ["{{PORT}}:80"], - volumes: ["/opt/drone/data:/data"], + image: 'drone/drone:latest', + ports: ['{{PORT}}:80'], + volumes: ['/opt/drone/data:/data'], environment: { - "DRONE_GITEA_SERVER": "https://git.sami", - "DRONE_RPC_SECRET": "{{DRONE_RPC_SECRET}}", - "DRONE_SERVER_HOST": "{{SUBDOMAIN}}.sami", - "DRONE_SERVER_PROTO": "https" - } + 'DRONE_GITEA_SERVER': 'https://git.sami', + 'DRONE_RPC_SECRET': '{{DRONE_RPC_SECRET}}', + 'DRONE_SERVER_HOST': '{{SUBDOMAIN}}.sami', + 'DRONE_SERVER_PROTO': 'https', + }, }, - subdomain: "drone", + subdomain: 'drone', defaultPort: 8090, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Configure Git provider integration", - "Set up shared secret", - "Deploy Drone runners" + 'Configure Git provider integration', + 'Set up shared secret', + 'Deploy Drone runners', ], secrets: [ { - envVar: "DRONE_RPC_SECRET", - label: "RPC Secret", - description: "Shared secret for Drone server and runner communication", - type: "password", + envVar: 'DRONE_RPC_SECRET', + label: 'RPC Secret', + description: 'Shared secret for Drone server and runner communication', + type: 'password', required: true, - generate: "alphanumeric", - length: 64 - } - ] + generate: 'alphanumeric', + length: 64, + }, + ], }, // === NOTES & WIKI === - "bookstack": { - name: "BookStack", - description: "Simple wiki and documentation platform", - icon: "📖", - category: "Productivity", + 'bookstack': { + name: 'BookStack', + description: 'Simple wiki and documentation platform', + icon: '📖', + category: 'Productivity', popularity: 80, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "linuxserver/bookstack:latest", - ports: ["{{PORT}}:80"], - volumes: ["/opt/bookstack/config:/config"], + image: 'linuxserver/bookstack:latest', + ports: ['{{PORT}}:80'], + volumes: ['/opt/bookstack/config:/config'], environment: { - "PUID": "1000", - "PGID": "1000", - "APP_URL": "https://{{SUBDOMAIN}}.sami", - "DB_HOST": "mariadb", - "DB_DATABASE": "bookstack", - "DB_USERNAME": "bookstack", - "DB_PASSWORD": "{{BOOKSTACK_DB_PASSWORD}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'APP_URL': 'https://{{SUBDOMAIN}}.sami', + 'DB_HOST': 'mariadb', + 'DB_DATABASE': 'bookstack', + 'DB_USERNAME': 'bookstack', + 'DB_PASSWORD': '{{BOOKSTACK_DB_PASSWORD}}', + }, }, - subdomain: "wiki", + subdomain: 'wiki', defaultPort: 8091, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Requires MariaDB/MySQL database", - "Default login: admin@admin.com / password", - "Change default credentials" + 'Requires MariaDB/MySQL database', + 'Default login: admin@admin.com / password', + 'Change default credentials', ], secrets: [ { - envVar: "BOOKSTACK_DB_PASSWORD", - label: "Database Password", - description: "Password for BookStack database user", - type: "password", + envVar: 'BOOKSTACK_DB_PASSWORD', + label: 'Database Password', + description: 'Password for BookStack database user', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "outline": { - name: "Outline", - description: "Modern team knowledge base and wiki", - icon: "📝", - category: "Productivity", + 'outline': { + name: 'Outline', + description: 'Modern team knowledge base and wiki', + icon: '📝', + category: 'Productivity', popularity: 75, - difficulty: "Advanced", + difficulty: 'Advanced', docker: { - image: "outlinewiki/outline:latest", - ports: ["{{PORT}}:3000"], - volumes: ["/opt/outline/data:/var/lib/outline/data"], + image: 'outlinewiki/outline:latest', + ports: ['{{PORT}}:3000'], + volumes: ['/opt/outline/data:/var/lib/outline/data'], environment: { - "URL": "https://{{SUBDOMAIN}}.sami", - "SECRET_KEY": "{{OUTLINE_SECRET_KEY}}", - "DATABASE_URL": "postgres://outline:{{OUTLINE_DB_PASSWORD}}@postgres:5432/outline" - } + 'URL': 'https://{{SUBDOMAIN}}.sami', + 'SECRET_KEY': '{{OUTLINE_SECRET_KEY}}', + 'DATABASE_URL': 'postgres://outline:{{OUTLINE_DB_PASSWORD}}@postgres:5432/outline', + }, }, - subdomain: "outline", + subdomain: 'outline', defaultPort: 3006, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Requires PostgreSQL and Redis", - "Configure OAuth provider", - "Set up S3-compatible storage" + 'Requires PostgreSQL and Redis', + 'Configure OAuth provider', + 'Set up S3-compatible storage', ], secrets: [ { - envVar: "OUTLINE_SECRET_KEY", - label: "Secret Key", - description: "Secret key for encrypting session data", - type: "password", + envVar: 'OUTLINE_SECRET_KEY', + label: 'Secret Key', + description: 'Secret key for encrypting session data', + type: 'password', required: true, - generate: "alphanumeric", - length: 64 + generate: 'alphanumeric', + length: 64, }, { - envVar: "OUTLINE_DB_PASSWORD", - label: "Database Password", - description: "Password for Outline PostgreSQL database user", - type: "password", + envVar: 'OUTLINE_DB_PASSWORD', + label: 'Database Password', + description: 'Password for Outline PostgreSQL database user', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "standardnotes": { - name: "Standard Notes", - description: "End-to-end encrypted notes app", - icon: "🔒", - category: "Productivity", + 'standardnotes': { + name: 'Standard Notes', + description: 'End-to-end encrypted notes app', + icon: '🔒', + category: 'Productivity', popularity: 72, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "standardnotes/server:latest", - ports: ["{{PORT}}:3000"], - volumes: ["/opt/standardnotes/data:/var/lib/server"], + image: 'standardnotes/server:latest', + ports: ['{{PORT}}:3000'], + volumes: ['/opt/standardnotes/data:/var/lib/server'], environment: { - "RAILS_ENV": "production" - } + 'RAILS_ENV': 'production', + }, }, - subdomain: "notes", + subdomain: 'notes', defaultPort: 3007, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Configure environment variables", - "Set up database connection", - "Install Standard Notes apps" - ] + 'Configure environment variables', + 'Set up database connection', + 'Install Standard Notes apps', + ], }, // === PHOTOS & GALLERIES === - "immich": { - name: "Immich", - description: "Self-hosted Google Photos alternative", - icon: "📸", - category: "Photos", + 'immich': { + name: 'Immich', + description: 'Self-hosted Google Photos alternative', + icon: '📸', + category: 'Photos', popularity: 90, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "ghcr.io/immich-app/immich-server:latest", - ports: ["{{PORT}}:2283"], + image: 'ghcr.io/immich-app/immich-server:latest', + ports: ['{{PORT}}:2283'], volumes: [ - "/opt/immich/upload:/usr/src/app/upload", - "/opt/immich/library:/usr/src/app/library" + '/opt/immich/upload:/usr/src/app/upload', + '/opt/immich/library:/usr/src/app/library', ], environment: { - "DB_HOSTNAME": "postgres", - "DB_USERNAME": "immich", - "DB_PASSWORD": "{{IMMICH_DB_PASSWORD}}", - "DB_DATABASE_NAME": "immich", - "REDIS_HOSTNAME": "redis" - } + 'DB_HOSTNAME': 'postgres', + 'DB_USERNAME': 'immich', + 'DB_PASSWORD': '{{IMMICH_DB_PASSWORD}}', + 'DB_DATABASE_NAME': 'immich', + 'REDIS_HOSTNAME': 'redis', + }, }, - subdomain: "photos", + subdomain: 'photos', defaultPort: 2283, - healthCheck: "/api/server-info/ping", + healthCheck: '/api/server-info/ping', subpathSupport: 'strip', setupInstructions: [ - "Requires PostgreSQL and Redis", - "Install mobile apps for backup", - "Configure machine learning for face detection" + 'Requires PostgreSQL and Redis', + 'Install mobile apps for backup', + 'Configure machine learning for face detection', ], secrets: [ { - envVar: "IMMICH_DB_PASSWORD", - label: "Database Password", - description: "Password for Immich PostgreSQL database user", - type: "password", + envVar: 'IMMICH_DB_PASSWORD', + label: 'Database Password', + description: 'Password for Immich PostgreSQL database user', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, - "photoprism": { - name: "PhotoPrism", - description: "AI-powered photo management", - icon: "🖼️", - category: "Photos", + 'photoprism': { + name: 'PhotoPrism', + description: 'AI-powered photo management', + icon: '🖼️', + category: 'Photos', popularity: 85, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "photoprism/photoprism:latest", - ports: ["{{PORT}}:2342"], + image: 'photoprism/photoprism:latest', + ports: ['{{PORT}}:2342'], volumes: [ - "/opt/photoprism/storage:/photoprism/storage", - "/opt/photoprism/originals:/photoprism/originals" + '/opt/photoprism/storage:/photoprism/storage', + '/opt/photoprism/originals:/photoprism/originals', ], environment: { - "PHOTOPRISM_ADMIN_PASSWORD": "{{PHOTOPRISM_ADMIN_PASSWORD}}", - "PHOTOPRISM_SITE_URL": "https://{{SUBDOMAIN}}.sami/", - "PHOTOPRISM_DATABASE_DRIVER": "sqlite" - } + 'PHOTOPRISM_ADMIN_PASSWORD': '{{PHOTOPRISM_ADMIN_PASSWORD}}', + 'PHOTOPRISM_SITE_URL': 'https://{{SUBDOMAIN}}.sami/', + 'PHOTOPRISM_DATABASE_DRIVER': 'sqlite', + }, }, - subdomain: "gallery", + subdomain: 'gallery', defaultPort: 2342, - healthCheck: "/api/v1/status", + healthCheck: '/api/v1/status', subpathSupport: 'strip', setupInstructions: [ - "Change admin password", - "Import your photos", - "Run indexing for AI features" + 'Change admin password', + 'Import your photos', + 'Run indexing for AI features', ], secrets: [ { - envVar: "PHOTOPRISM_ADMIN_PASSWORD", - label: "Admin Password", - description: "Password for PhotoPrism admin account", - type: "password", + envVar: 'PHOTOPRISM_ADMIN_PASSWORD', + label: 'Admin Password', + description: 'Password for PhotoPrism admin account', + type: 'password', required: true, - generate: "alphanumeric", - length: 32 - } - ] + generate: 'alphanumeric', + length: 32, + }, + ], }, // === DOWNLOAD MANAGERS === - "sabnzbd": { - name: "SABnzbd", - description: "Binary newsreader for Usenet downloads", - icon: "📰", - category: "Downloads", + 'sabnzbd': { + name: 'SABnzbd', + description: 'Binary newsreader for Usenet downloads', + icon: '📰', + category: 'Downloads', popularity: 75, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "linuxserver/sabnzbd:latest", - ports: ["{{PORT}}:8080"], + image: 'linuxserver/sabnzbd:latest', + ports: ['{{PORT}}:8080'], volumes: [ - "/opt/sabnzbd/config:/config", - "/downloads:/downloads" + '/opt/sabnzbd/config:/config', + '/downloads:/downloads', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "sabnzbd", + subdomain: 'sabnzbd', defaultPort: 8092, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'native', urlBaseEnv: 'SABNZBD_URL_BASE', setupInstructions: [ - "Configure Usenet server credentials", - "Set up download categories", - "Configure post-processing scripts" - ] + 'Configure Usenet server credentials', + 'Set up download categories', + 'Configure post-processing scripts', + ], }, - "nzbget": { - name: "NZBGet", - description: "Efficient Usenet downloader", - icon: "📥", - category: "Downloads", + 'nzbget': { + name: 'NZBGet', + description: 'Efficient Usenet downloader', + icon: '📥', + category: 'Downloads', popularity: 70, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "linuxserver/nzbget:latest", - ports: ["{{PORT}}:6789"], + image: 'linuxserver/nzbget:latest', + ports: ['{{PORT}}:6789'], volumes: [ - "/opt/nzbget/config:/config", - "/downloads:/downloads" + '/opt/nzbget/config:/config', + '/downloads:/downloads', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "nzbget", + subdomain: 'nzbget', defaultPort: 6789, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Default login: nzbget/tegbzn6789", - "Configure news servers", - "Set up categories and paths" - ] + 'Default login: nzbget/tegbzn6789', + 'Configure news servers', + 'Set up categories and paths', + ], }, - "transmission": { - name: "Transmission", - description: "Lightweight BitTorrent client", - icon: "🌊", - category: "Downloads", + 'transmission': { + name: 'Transmission', + description: 'Lightweight BitTorrent client', + icon: '🌊', + category: 'Downloads', popularity: 80, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "linuxserver/transmission:latest", - ports: ["{{PORT}}:9091", "51413:51413", "51413:51413/udp"], + image: 'linuxserver/transmission:latest', + ports: ['{{PORT}}:9091', '51413:51413', '51413:51413/udp'], volumes: [ - "/opt/transmission/config:/config", - "/downloads:/downloads" + '/opt/transmission/config:/config', + '/downloads:/downloads', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "transmission", + subdomain: 'transmission', defaultPort: 9092, - healthCheck: "/transmission/web/", + healthCheck: '/transmission/web/', subpathSupport: 'native', urlBaseEnv: 'TRANSMISSION_WEB_HOME', setupInstructions: [ - "Configure download paths", - "Set bandwidth limits", - "Configure blocklists if needed" - ] + 'Configure download paths', + 'Set bandwidth limits', + 'Configure blocklists if needed', + ], }, - "jdownloader": { - name: "JDownloader 2", - description: "Download manager for file hosting sites", - icon: "⬇️", - category: "Downloads", + 'jdownloader': { + name: 'JDownloader 2', + description: 'Download manager for file hosting sites', + icon: '⬇️', + category: 'Downloads', popularity: 72, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "jlesage/jdownloader-2:latest", - ports: ["{{PORT}}:5800"], + image: 'jlesage/jdownloader-2:latest', + ports: ['{{PORT}}:5800'], volumes: [ - "/opt/jdownloader/config:/config", - "/downloads:/output" + '/opt/jdownloader/config:/config', + '/downloads:/output', ], - environment: {} + environment: {}, }, - subdomain: "jdownloader", + subdomain: 'jdownloader', defaultPort: 5800, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Access web interface to configure", - "Link to MyJDownloader account", - "Configure download paths" - ] + 'Access web interface to configure', + 'Link to MyJDownloader account', + 'Configure download paths', + ], }, // === STREAMING & MEDIA === - "navidrome": { - name: "Navidrome", - description: "Modern music server and streamer", - icon: "🎶", - category: "Media", + 'navidrome': { + name: 'Navidrome', + description: 'Modern music server and streamer', + icon: '🎶', + category: 'Media', popularity: 80, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "deluan/navidrome:latest", - ports: ["{{PORT}}:4533"], + image: 'deluan/navidrome:latest', + ports: ['{{PORT}}:4533'], volumes: [ - "/opt/navidrome/data:/data", - "/music:/music:ro" + '/opt/navidrome/data:/data', + '/music:/music:ro', ], environment: { - "ND_SCANSCHEDULE": "1h", - "ND_LOGLEVEL": "info" - } + 'ND_SCANSCHEDULE': '1h', + 'ND_LOGLEVEL': 'info', + }, }, - subdomain: "music", + subdomain: 'music', defaultPort: 4533, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Point to your music library", - "Create user accounts", - "Install Subsonic-compatible apps" - ] + 'Point to your music library', + 'Create user accounts', + 'Install Subsonic-compatible apps', + ], }, - "airsonic": { - name: "Airsonic Advanced", - description: "Free web-based media streamer", - icon: "🎧", - category: "Media", + 'airsonic': { + name: 'Airsonic Advanced', + description: 'Free web-based media streamer', + icon: '🎧', + category: 'Media', popularity: 68, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "linuxserver/airsonic-advanced:latest", - ports: ["{{PORT}}:4040"], + image: 'linuxserver/airsonic-advanced:latest', + ports: ['{{PORT}}:4040'], volumes: [ - "/opt/airsonic/config:/config", - "/music:/music", - "/podcasts:/podcasts" + '/opt/airsonic/config:/config', + '/music:/music', + '/podcasts:/podcasts', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "airsonic", + subdomain: 'airsonic', defaultPort: 4040, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Default login: admin/admin", - "Configure media folders", - "Set up transcoding" - ] + 'Default login: admin/admin', + 'Configure media folders', + 'Set up transcoding', + ], }, // === MISC UTILITIES === - "homepage": { - name: "Homepage", - description: "Highly customizable application dashboard", - icon: "🏡", - category: "Utilities", + 'homepage': { + name: 'Homepage', + description: 'Highly customizable application dashboard', + icon: '🏡', + category: 'Utilities', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "ghcr.io/gethomepage/homepage:latest", - ports: ["{{PORT}}:3000"], + image: 'ghcr.io/gethomepage/homepage:latest', + ports: ['{{PORT}}:3000'], volumes: [ - "/opt/homepage/config:/app/config", - "/var/run/docker.sock:/var/run/docker.sock:ro" + '/opt/homepage/config:/app/config', + '/var/run/docker.sock:/var/run/docker.sock:ro', ], - environment: {} + environment: {}, }, - subdomain: "dashboard", + subdomain: 'dashboard', defaultPort: 3008, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Edit config files to add services", - "Configure widgets", - "Customize appearance" - ] + 'Edit config files to add services', + 'Configure widgets', + 'Customize appearance', + ], }, - "homarr": { - name: "Homarr", - description: "Sleek dashboard for all your services", - icon: "🎯", - category: "Utilities", + 'homarr': { + name: 'Homarr', + description: 'Sleek dashboard for all your services', + icon: '🎯', + category: 'Utilities', popularity: 82, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "ghcr.io/ajnart/homarr:latest", - ports: ["{{PORT}}:7575"], + image: 'ghcr.io/ajnart/homarr:latest', + ports: ['{{PORT}}:7575'], volumes: [ - "/opt/homarr/configs:/app/data/configs", - "/opt/homarr/icons:/app/public/icons", - "/var/run/docker.sock:/var/run/docker.sock:ro" + '/opt/homarr/configs:/app/data/configs', + '/opt/homarr/icons:/app/public/icons', + '/var/run/docker.sock:/var/run/docker.sock:ro', ], - environment: {} + environment: {}, }, - subdomain: "homarr", + subdomain: 'homarr', defaultPort: 7575, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Add your services via UI", - "Configure integrations", - "Customize layout and appearance" - ] + 'Add your services via UI', + 'Configure integrations', + 'Customize layout and appearance', + ], }, - "changedetection": { - name: "Change Detection", - description: "Monitor websites for changes", - icon: "👁️", - category: "Utilities", + 'changedetection': { + name: 'Change Detection', + description: 'Monitor websites for changes', + icon: '👁️', + category: 'Utilities', popularity: 70, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "ghcr.io/dgtlmoon/changedetection.io:latest", - ports: ["{{PORT}}:5000"], - volumes: ["/opt/changedetection/data:/datastore"], - environment: {} + image: 'ghcr.io/dgtlmoon/changedetection.io:latest', + ports: ['{{PORT}}:5000'], + volumes: ['/opt/changedetection/data:/datastore'], + environment: {}, }, - subdomain: "watch", + subdomain: 'watch', defaultPort: 5001, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Add URLs to monitor", - "Configure check frequency", - "Set up notifications" - ] + 'Add URLs to monitor', + 'Configure check frequency', + 'Set up notifications', + ], }, - "speedtest": { - name: "Speedtest Tracker", - description: "Internet speed monitoring over time", - icon: "⚡", - category: "Monitoring", + 'speedtest': { + name: 'Speedtest Tracker', + description: 'Internet speed monitoring over time', + icon: '⚡', + category: 'Monitoring', popularity: 75, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "ghcr.io/alexjustesen/speedtest-tracker:latest", - ports: ["{{PORT}}:80"], - volumes: ["/opt/speedtest/config:/config"], + image: 'ghcr.io/alexjustesen/speedtest-tracker:latest', + ports: ['{{PORT}}:80'], + volumes: ['/opt/speedtest/config:/config'], environment: { - "PUID": "1000", - "PGID": "1000", - "DB_CONNECTION": "sqlite" - } + 'PUID': '1000', + 'PGID': '1000', + 'DB_CONNECTION': 'sqlite', + }, }, - subdomain: "speedtest", + subdomain: 'speedtest', defaultPort: 8093, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Configure test schedule", - "View historical data", - "Set up notifications for slow speeds" - ] + 'Configure test schedule', + 'View historical data', + 'Set up notifications for slow speeds', + ], }, - "whoami": { - name: "Whoami", - description: "Simple HTTP request debugging service", - icon: "🔍", - category: "Utilities", + 'whoami': { + name: 'Whoami', + description: 'Simple HTTP request debugging service', + icon: '🔍', + category: 'Utilities', popularity: 60, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "traefik/whoami:latest", - ports: ["{{PORT}}:80"], + image: 'traefik/whoami:latest', + ports: ['{{PORT}}:80'], volumes: [], - environment: {} + environment: {}, }, - subdomain: "whoami", + subdomain: 'whoami', defaultPort: 8094, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Useful for testing reverse proxy setup", - "Shows request headers and info" - ] + 'Useful for testing reverse proxy setup', + 'Shows request headers and info', + ], }, // === NEW APPS === - "stirling-pdf": { - name: "Stirling PDF", - description: "Self-hosted PDF manipulation tool - merge, split, convert, and more", - icon: "\uD83D\uDCC4", - logo: "/assets/stirling-pdf.png", - category: "Utilities", + 'stirling-pdf': { + name: 'Stirling PDF', + description: 'Self-hosted PDF manipulation tool - merge, split, convert, and more', + icon: '\uD83D\uDCC4', + logo: '/assets/stirling-pdf.png', + category: 'Utilities', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "frooodle/s-pdf:latest", - ports: ["{{PORT}}:8080"], + image: 'frooodle/s-pdf:latest', + ports: ['{{PORT}}:8080'], volumes: [ - "/opt/stirling-pdf/data:/usr/share/tessdata", - "/opt/stirling-pdf/config:/configs" + '/opt/stirling-pdf/data:/usr/share/tessdata', + '/opt/stirling-pdf/config:/configs', ], environment: { - "DOCKER_ENABLE_SECURITY": "false" - } + 'DOCKER_ENABLE_SECURITY': 'false', + }, }, - subdomain: "pdf", + subdomain: 'pdf', defaultPort: 8084, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Access the web interface to start manipulating PDFs", - "Supports merge, split, rotate, convert, compress, and more", - "Optional OCR support via Tesseract" - ] + 'Access the web interface to start manipulating PDFs', + 'Supports merge, split, rotate, convert, compress, and more', + 'Optional OCR support via Tesseract', + ], }, - "actual-budget": { - name: "Actual Budget", - description: "Privacy-focused budgeting app with envelope budgeting", - icon: "\uD83D\uDCB0", - logo: "/assets/actual-budget.png", - category: "Productivity", + 'actual-budget': { + name: 'Actual Budget', + description: 'Privacy-focused budgeting app with envelope budgeting', + icon: '\uD83D\uDCB0', + logo: '/assets/actual-budget.png', + category: 'Productivity', popularity: 78, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "actualbudget/actual-server:latest", - ports: ["{{PORT}}:5006"], + image: 'actualbudget/actual-server:latest', + ports: ['{{PORT}}:5006'], volumes: [ - "/opt/actual-budget/data:/data" + '/opt/actual-budget/data:/data', ], - environment: {} + environment: {}, }, - subdomain: "budget", + subdomain: 'budget', defaultPort: 5006, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Create your first budget in the web interface", - "Import transactions from your bank (OFX, QFX, CSV)", - "Set up envelope categories for spending control" - ] + 'Create your first budget in the web interface', + 'Import transactions from your bank (OFX, QFX, CSV)', + 'Set up envelope categories for spending control', + ], }, - "mealie": { - name: "Mealie", - description: "Recipe manager and meal planner with grocery lists", - icon: "\uD83C\uDF73", - logo: "/assets/mealie.png", - category: "Productivity", + 'mealie': { + name: 'Mealie', + description: 'Recipe manager and meal planner with grocery lists', + icon: '\uD83C\uDF73', + logo: '/assets/mealie.png', + category: 'Productivity', popularity: 76, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "ghcr.io/mealie-recipes/mealie:latest", - ports: ["{{PORT}}:9000"], + image: 'ghcr.io/mealie-recipes/mealie:latest', + ports: ['{{PORT}}:9000'], volumes: [ - "/opt/mealie/data:/app/data" + '/opt/mealie/data:/app/data', ], environment: { - "ALLOW_SIGNUP": "true", - "MAX_WORKERS": "1", - "WEB_CONCURRENCY": "1", - "BASE_URL": "https://{{SUBDOMAIN}}.sami" - } + 'ALLOW_SIGNUP': 'true', + 'MAX_WORKERS': '1', + 'WEB_CONCURRENCY': '1', + 'BASE_URL': 'https://{{SUBDOMAIN}}.sami', + }, }, - subdomain: "mealie", + subdomain: 'mealie', defaultPort: 9925, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Default login: changeme@example.com / MyPassword", - "Import recipes from URLs or add them manually", - "Create meal plans and generate shopping lists" - ] + 'Default login: changeme@example.com / MyPassword', + 'Import recipes from URLs or add them manually', + 'Create meal plans and generate shopping lists', + ], }, - "paperless-ngx": { - name: "Paperless-ngx", - description: "Document management system - scan, organize, and search documents", - icon: "\uD83D\uDCDA", - logo: "/assets/paperless-ngx.png", - category: "Productivity", + 'paperless-ngx': { + name: 'Paperless-ngx', + description: 'Document management system - scan, organize, and search documents', + icon: '\uD83D\uDCDA', + logo: '/assets/paperless-ngx.png', + category: 'Productivity', popularity: 82, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "ghcr.io/paperless-ngx/paperless-ngx:latest", - ports: ["{{PORT}}:8000"], + image: 'ghcr.io/paperless-ngx/paperless-ngx:latest', + ports: ['{{PORT}}:8000'], volumes: [ - "/opt/paperless/data:/usr/src/paperless/data", - "/opt/paperless/media:/usr/src/paperless/media", - "/opt/paperless/consume:/usr/src/paperless/consume" + '/opt/paperless/data:/usr/src/paperless/data', + '/opt/paperless/media:/usr/src/paperless/media', + '/opt/paperless/consume:/usr/src/paperless/consume', ], environment: { - "PAPERLESS_URL": "https://{{SUBDOMAIN}}.sami", - "USERMAP_UID": "1000", - "USERMAP_GID": "1000", - "PAPERLESS_TIME_ZONE": "{{TIMEZONE}}", - "PAPERLESS_OCR_LANGUAGE": "eng", - "PAPERLESS_SECRET_KEY": "{{GENERATED_SECRET}}" - } + 'PAPERLESS_URL': 'https://{{SUBDOMAIN}}.sami', + 'USERMAP_UID': '1000', + 'USERMAP_GID': '1000', + 'PAPERLESS_TIME_ZONE': '{{TIMEZONE}}', + 'PAPERLESS_OCR_LANGUAGE': 'eng', + 'PAPERLESS_SECRET_KEY': '{{GENERATED_SECRET}}', + }, }, - subdomain: "paperless", + subdomain: 'paperless', defaultPort: 8095, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Create admin account via: docker exec -it python3 manage.py createsuperuser", - "Drop documents into the consume folder for automatic import", - "Configure tags and correspondents for organization" - ] + 'Create admin account via: docker exec -it python3 manage.py createsuperuser', + 'Drop documents into the consume folder for automatic import', + 'Configure tags and correspondents for organization', + ], }, - "audiobookshelf": { - name: "Audiobookshelf", - description: "Self-hosted audiobook and podcast server", - icon: "\uD83C\uDFA7", - logo: "/assets/audiobookshelf.png", - category: "Media", + 'audiobookshelf': { + name: 'Audiobookshelf', + description: 'Self-hosted audiobook and podcast server', + icon: '\uD83C\uDFA7', + logo: '/assets/audiobookshelf.png', + category: 'Media', popularity: 80, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "ghcr.io/advplyr/audiobookshelf:latest", - ports: ["{{PORT}}:80"], + image: 'ghcr.io/advplyr/audiobookshelf:latest', + ports: ['{{PORT}}:80'], volumes: [ - "/opt/audiobookshelf/config:/config", - "/opt/audiobookshelf/metadata:/metadata", - "{{MEDIA_PATH}}:/audiobooks" + '/opt/audiobookshelf/config:/config', + '/opt/audiobookshelf/metadata:/metadata', + '{{MEDIA_PATH}}:/audiobooks', ], - environment: {} + environment: {}, }, - subdomain: "audiobooks", + subdomain: 'audiobooks', defaultPort: 13378, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', mediaMount: { required: true, - containerPath: "/audiobooks", - label: "Audiobook Library", - description: "Folder containing your audiobooks and podcasts", - defaultPath: "/media/audiobooks" + containerPath: '/audiobooks', + label: 'Audiobook Library', + description: 'Folder containing your audiobooks and podcasts', + defaultPath: '/media/audiobooks', }, setupInstructions: [ - "Create your account on first access", - "Add your audiobook library folders", - "Download the mobile app for offline listening" - ] + 'Create your account on first access', + 'Add your audiobook library folders', + 'Download the mobile app for offline listening', + ], }, - "calibre-web": { - name: "Calibre-Web", - description: "Web-based ebook manager and reader", - icon: "\uD83D\uDCD6", - logo: "/assets/calibre-web.png", - category: "Media", + 'calibre-web': { + name: 'Calibre-Web', + description: 'Web-based ebook manager and reader', + icon: '\uD83D\uDCD6', + logo: '/assets/calibre-web.png', + category: 'Media', popularity: 74, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "lscr.io/linuxserver/calibre-web:latest", - ports: ["{{PORT}}:8083"], + image: 'lscr.io/linuxserver/calibre-web:latest', + ports: ['{{PORT}}:8083'], volumes: [ - "/opt/calibre-web/config:/config", - "{{MEDIA_PATH}}:/books" + '/opt/calibre-web/config:/config', + '{{MEDIA_PATH}}:/books', ], environment: { - "PUID": "1000", - "PGID": "1000", - "TZ": "{{TIMEZONE}}" - } + 'PUID': '1000', + 'PGID': '1000', + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "books", + subdomain: 'books', defaultPort: 8083, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', mediaMount: { required: true, - containerPath: "/books", - label: "Ebook Library", - description: "Folder containing your Calibre library (with metadata.db)", - defaultPath: "/media/books" + containerPath: '/books', + label: 'Ebook Library', + description: 'Folder containing your Calibre library (with metadata.db)', + defaultPath: '/media/books', }, setupInstructions: [ - "Default login: admin / admin123", - "Point to your Calibre database location on first setup", - "Supports EPUB, PDF, MOBI, and more formats" - ] + 'Default login: admin / admin123', + 'Point to your Calibre database location on first setup', + 'Supports EPUB, PDF, MOBI, and more formats', + ], }, - "komga": { - name: "Komga", - description: "Comic and manga media server with web reader", - icon: "\uD83D\uDCDA", - logo: "/assets/komga.png", - category: "Media", + 'komga': { + name: 'Komga', + description: 'Comic and manga media server with web reader', + icon: '\uD83D\uDCDA', + logo: '/assets/komga.png', + category: 'Media', popularity: 70, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "gotson/komga:latest", - ports: ["{{PORT}}:25600"], + image: 'gotson/komga:latest', + ports: ['{{PORT}}:25600'], volumes: [ - "/opt/komga/config:/config", - "{{MEDIA_PATH}}:/data" + '/opt/komga/config:/config', + '{{MEDIA_PATH}}:/data', ], environment: { - "TZ": "{{TIMEZONE}}" - } + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "komga", + subdomain: 'komga', defaultPort: 25600, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', mediaMount: { required: true, - containerPath: "/data", - label: "Comics Library", - description: "Folder containing your comics and manga", - defaultPath: "/media/comics" + containerPath: '/data', + label: 'Comics Library', + description: 'Folder containing your comics and manga', + defaultPath: '/media/comics', }, setupInstructions: [ - "Create admin account on first access", - "Add your comic libraries (CBZ, CBR, PDF supported)", - "Use OPDS for third-party reader apps" - ] + 'Create admin account on first access', + 'Add your comic libraries (CBZ, CBR, PDF supported)', + 'Use OPDS for third-party reader apps', + ], }, - "kavita": { - name: "Kavita", - description: "Digital reading platform for manga, comics, and books", - icon: "\uD83D\uDCD6", - logo: "/assets/kavita.png", - category: "Media", + 'kavita': { + name: 'Kavita', + description: 'Digital reading platform for manga, comics, and books', + icon: '\uD83D\uDCD6', + logo: '/assets/kavita.png', + category: 'Media', popularity: 72, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "jvmilazz0/kavita:latest", - ports: ["{{PORT}}:5000"], + image: 'jvmilazz0/kavita:latest', + ports: ['{{PORT}}:5000'], volumes: [ - "/opt/kavita/config:/kavita/config", - "{{MEDIA_PATH}}:/data" + '/opt/kavita/config:/kavita/config', + '{{MEDIA_PATH}}:/data', ], - environment: {} + environment: {}, }, - subdomain: "kavita", + subdomain: 'kavita', defaultPort: 5004, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', mediaMount: { required: true, - containerPath: "/data", - label: "Reading Library", - description: "Folder containing your manga, comics, and ebooks", - defaultPath: "/media/reading" + containerPath: '/data', + label: 'Reading Library', + description: 'Folder containing your manga, comics, and ebooks', + defaultPath: '/media/reading', }, setupInstructions: [ - "Create admin account on first access", - "Add library folders for manga, comics, or books", - "Supports EPUB, PDF, CBZ, CBR formats" - ] + 'Create admin account on first access', + 'Add library folders for manga, comics, or books', + 'Supports EPUB, PDF, CBZ, CBR formats', + ], }, - "trilium": { - name: "Trilium Notes", - description: "Hierarchical knowledge base and note-taking app", - icon: "\uD83D\uDDD2\uFE0F", - logo: "/assets/trilium.png", - category: "Productivity", + 'trilium': { + name: 'Trilium Notes', + description: 'Hierarchical knowledge base and note-taking app', + icon: '\uD83D\uDDD2\uFE0F', + logo: '/assets/trilium.png', + category: 'Productivity', popularity: 75, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "zadam/trilium:latest", - ports: ["{{PORT}}:8080"], + image: 'zadam/trilium:latest', + ports: ['{{PORT}}:8080'], volumes: [ - "/opt/trilium/data:/home/node/trilium-data" + '/opt/trilium/data:/home/node/trilium-data', ], - environment: {} + environment: {}, }, - subdomain: "notes", + subdomain: 'notes', defaultPort: 8085, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Set your password on first access", - "Organize notes in a tree hierarchy", - "Supports rich text, code blocks, math equations, and diagrams" - ] + 'Set your password on first access', + 'Organize notes in a tree hierarchy', + 'Supports rich text, code blocks, math equations, and diagrams', + ], }, - "excalidraw": { - name: "Excalidraw", - description: "Collaborative virtual whiteboard for sketching and diagrams", - icon: "\uD83C\uDFA8", - logo: "/assets/excalidraw.png", - category: "Productivity", + 'excalidraw': { + name: 'Excalidraw', + description: 'Collaborative virtual whiteboard for sketching and diagrams', + icon: '\uD83C\uDFA8', + logo: '/assets/excalidraw.png', + category: 'Productivity', popularity: 73, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "excalidraw/excalidraw:latest", - ports: ["{{PORT}}:80"], + image: 'excalidraw/excalidraw:latest', + ports: ['{{PORT}}:80'], volumes: [], - environment: {} + environment: {}, }, - subdomain: "draw", + subdomain: 'draw', defaultPort: 8086, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Start drawing immediately - no account needed", - "Share drawings via link for real-time collaboration", - "Export as PNG, SVG, or Excalidraw file" - ] + 'Start drawing immediately - no account needed', + 'Share drawings via link for real-time collaboration', + 'Export as PNG, SVG, or Excalidraw file', + ], }, - "it-tools": { - name: "IT Tools", - description: "Collection of handy developer and IT tools in one place", - icon: "\uD83E\uDDF0", - logo: "/assets/it-tools.png", - category: "Utilities", + 'it-tools': { + name: 'IT Tools', + description: 'Collection of handy developer and IT tools in one place', + icon: '\uD83E\uDDF0', + logo: '/assets/it-tools.png', + category: 'Utilities', popularity: 79, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "corentinth/it-tools:latest", - ports: ["{{PORT}}:80"], + image: 'corentinth/it-tools:latest', + ports: ['{{PORT}}:80'], volumes: [], - environment: {} + environment: {}, }, - subdomain: "tools", + subdomain: 'tools', defaultPort: 8087, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "Access the web interface for instant tools access", - "Includes: hash generators, UUID, JWT decoder, base64, regex tester, and 70+ more", - "No configuration needed" - ] + 'Access the web interface for instant tools access', + 'Includes: hash generators, UUID, JWT decoder, base64, regex tester, and 70+ more', + 'No configuration needed', + ], }, - "dozzle": { - name: "Dozzle", - description: "Real-time Docker container log viewer", - icon: "\uD83D\uDCDC", - logo: "/assets/dozzle.png", - category: "Monitoring", + 'dozzle': { + name: 'Dozzle', + description: 'Real-time Docker container log viewer', + icon: '\uD83D\uDCDC', + logo: '/assets/dozzle.png', + category: 'Monitoring', popularity: 77, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "amir20/dozzle:latest", - ports: ["{{PORT}}:8080"], + image: 'amir20/dozzle:latest', + ports: ['{{PORT}}:8080'], volumes: [ - "/var/run/docker.sock:/var/run/docker.sock:ro" + '/var/run/docker.sock:/var/run/docker.sock:ro', ], - environment: {} + environment: {}, }, - subdomain: "logs", + subdomain: 'logs', defaultPort: 8088, - healthCheck: "/", + healthCheck: '/', subpathSupport: 'strip', setupInstructions: [ - "View real-time logs from all running containers", - "Filter and search across container logs", - "No configuration needed - auto-discovers containers" - ] + 'View real-time logs from all running containers', + 'Filter and search across container logs', + 'No configuration needed - auto-discovers containers', + ], }, - "watchtower": { - name: "Watchtower", - description: "Automatic Docker container image updates", - icon: "\uD83D\uDC53", - logo: "/assets/watchtower.png", - category: "Management", + 'watchtower': { + name: 'Watchtower', + description: 'Automatic Docker container image updates', + icon: '\uD83D\uDC53', + logo: '/assets/watchtower.png', + category: 'Management', popularity: 81, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "containrrr/watchtower:latest", - ports: ["{{PORT}}:8080"], + image: 'containrrr/watchtower:latest', + ports: ['{{PORT}}:8080'], volumes: [ - "/var/run/docker.sock:/var/run/docker.sock" + '/var/run/docker.sock:/var/run/docker.sock', ], environment: { - "WATCHTOWER_CLEANUP": "true", - "WATCHTOWER_SCHEDULE": "0 0 4 * * *", - "WATCHTOWER_HTTP_API_METRICS": "true", - "WATCHTOWER_HTTP_API_TOKEN": "{{GENERATED_SECRET}}" - } + 'WATCHTOWER_CLEANUP': 'true', + 'WATCHTOWER_SCHEDULE': '0 0 4 * * *', + 'WATCHTOWER_HTTP_API_METRICS': 'true', + 'WATCHTOWER_HTTP_API_TOKEN': '{{GENERATED_SECRET}}', + }, }, - subdomain: "watchtower", + subdomain: 'watchtower', defaultPort: 8089, - healthCheck: "/v1/update", + healthCheck: '/v1/update', subpathSupport: 'strip', setupInstructions: [ - "Watchtower checks for image updates daily at 4 AM by default", - "Customize schedule via WATCHTOWER_SCHEDULE (cron format)", - "Add labels to exclude specific containers from updates" - ] + 'Watchtower checks for image updates daily at 4 AM by default', + 'Customize schedule via WATCHTOWER_SCHEDULE (cron format)', + 'Add labels to exclude specific containers from updates', + ], }, - "authentik": { - name: "Authentik", - description: "Identity provider and single sign-on platform", - icon: "\uD83D\uDD10", - logo: "/assets/authentik.png", - category: "Security", + 'authentik': { + name: 'Authentik', + description: 'Identity provider and single sign-on platform', + icon: '\uD83D\uDD10', + logo: '/assets/authentik.png', + category: 'Security', popularity: 80, - difficulty: "Advanced", + difficulty: 'Advanced', docker: { - image: "ghcr.io/goauthentik/server:latest", - ports: ["{{PORT}}:9000"], + image: 'ghcr.io/goauthentik/server:latest', + ports: ['{{PORT}}:9000'], volumes: [ - "/opt/authentik/media:/media", - "/opt/authentik/templates:/templates" + '/opt/authentik/media:/media', + '/opt/authentik/templates:/templates', ], environment: { - "AUTHENTIK_SECRET_KEY": "{{GENERATED_SECRET}}", - "AUTHENTIK_ERROR_REPORTING__ENABLED": "false" - } + 'AUTHENTIK_SECRET_KEY': '{{GENERATED_SECRET}}', + 'AUTHENTIK_ERROR_REPORTING__ENABLED': 'false', + }, }, - subdomain: "auth", + subdomain: 'auth', defaultPort: 9010, - healthCheck: "/-/health/live/", + healthCheck: '/-/health/live/', subpathSupport: 'strip', setupInstructions: [ - "Requires a PostgreSQL database and Redis instance", - "Consider deploying via the Dev Environment recipe for full stack", - "Set up flows for authentication, enrollment, and recovery", - "Configure OAuth2/OIDC providers for SSO with other apps" - ] + 'Requires a PostgreSQL database and Redis instance', + 'Consider deploying via the Dev Environment recipe for full stack', + 'Set up flows for authentication, enrollment, and recovery', + 'Configure OAuth2/OIDC providers for SSO with other apps', + ], }, - "crowdsec": { - name: "CrowdSec", - description: "Collaborative intrusion prevention system", - icon: "\uD83D\uDEE1\uFE0F", - logo: "/assets/crowdsec.png", - category: "Security", + 'crowdsec': { + name: 'CrowdSec', + description: 'Collaborative intrusion prevention system', + icon: '\uD83D\uDEE1\uFE0F', + logo: '/assets/crowdsec.png', + category: 'Security', popularity: 74, - difficulty: "Intermediate", + difficulty: 'Intermediate', docker: { - image: "crowdsecurity/crowdsec:latest", - ports: ["{{PORT}}:8080"], + image: 'crowdsecurity/crowdsec:latest', + ports: ['{{PORT}}:8080'], volumes: [ - "/opt/crowdsec/config:/etc/crowdsec", - "/opt/crowdsec/data:/var/lib/crowdsec/data", - "/var/log:/var/log:ro" + '/opt/crowdsec/config:/etc/crowdsec', + '/opt/crowdsec/data:/var/lib/crowdsec/data', + '/var/log:/var/log:ro', ], - environment: {} + environment: {}, }, - subdomain: "crowdsec", + subdomain: 'crowdsec', defaultPort: 8091, - healthCheck: "/health", + healthCheck: '/health', subpathSupport: 'strip', setupInstructions: [ - "Register at app.crowdsec.net for community threat intelligence", - "Install bouncers on your reverse proxy for active blocking", - "CrowdSec analyzes logs and shares threat data with the community" - ] + 'Register at app.crowdsec.net for community threat intelligence', + 'Install bouncers on your reverse proxy for active blocking', + 'CrowdSec analyzes logs and shares threat data with the community', + ], }, - "minecraft": { - name: "Minecraft Server", - description: "Minecraft Java Edition dedicated server", - icon: "\u26CF\uFE0F", - logo: "/assets/minecraft.png", - category: "Gaming", + 'minecraft': { + name: 'Minecraft Server', + description: 'Minecraft Java Edition dedicated server', + icon: '\u26CF\uFE0F', + logo: '/assets/minecraft.png', + category: 'Gaming', popularity: 85, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "itzg/minecraft-server:latest", - ports: ["{{PORT}}:25565"], + image: 'itzg/minecraft-server:latest', + ports: ['{{PORT}}:25565'], volumes: [ - "/opt/minecraft/data:/data" + '/opt/minecraft/data:/data', ], environment: { - "EULA": "TRUE", - "TYPE": "VANILLA", - "VERSION": "LATEST", - "MEMORY": "2G", - "MAX_PLAYERS": "20", - "MOTD": "DashCaddy Minecraft Server" - } + 'EULA': 'TRUE', + 'TYPE': 'VANILLA', + 'VERSION': 'LATEST', + 'MEMORY': '2G', + 'MAX_PLAYERS': '20', + 'MOTD': 'DashCaddy Minecraft Server', + }, }, - subdomain: "mc", + subdomain: 'mc', defaultPort: 25565, healthCheck: null, subpathSupport: 'none', setupInstructions: [ - "Server accepts the Minecraft EULA automatically", - "Connect with your Minecraft client to the server IP:port", - "Configure server.properties in the data volume for customization", - "Supports Vanilla, Paper, Forge, Fabric via TYPE environment variable" - ] + 'Server accepts the Minecraft EULA automatically', + 'Connect with your Minecraft client to the server IP:port', + 'Configure server.properties in the data volume for customization', + 'Supports Vanilla, Paper, Forge, Fabric via TYPE environment variable', + ], }, - "valheim": { - name: "Valheim Server", - description: "Valheim dedicated server for multiplayer Viking adventures", - icon: "\u2694\uFE0F", - logo: "/assets/valheim.png", - category: "Gaming", + 'valheim': { + name: 'Valheim Server', + description: 'Valheim dedicated server for multiplayer Viking adventures', + icon: '\u2694\uFE0F', + logo: '/assets/valheim.png', + category: 'Gaming', popularity: 72, - difficulty: "Easy", + difficulty: 'Easy', docker: { - image: "lloesche/valheim-server:latest", - ports: ["{{PORT}}:2456/udp", "2457:2457/udp", "2458:2458/udp"], + image: 'lloesche/valheim-server:latest', + ports: ['{{PORT}}:2456/udp', '2457:2457/udp', '2458:2458/udp'], volumes: [ - "/opt/valheim/config:/config", - "/opt/valheim/data:/opt/valheim" + '/opt/valheim/config:/config', + '/opt/valheim/data:/opt/valheim', ], environment: { - "SERVER_NAME": "DashCaddy Valheim", - "WORLD_NAME": "DashCaddyWorld", - "SERVER_PASS": "{{GENERATED_SECRET}}", - "SERVER_PUBLIC": "false" - } + 'SERVER_NAME': 'DashCaddy Valheim', + 'WORLD_NAME': 'DashCaddyWorld', + 'SERVER_PASS': '{{GENERATED_SECRET}}', + 'SERVER_PUBLIC': 'false', + }, }, - subdomain: "valheim", + subdomain: 'valheim', defaultPort: 2456, healthCheck: null, subpathSupport: 'none', setupInstructions: [ - "Connect via Steam: Add Server > IP:2456", - "Default server password is auto-generated (check environment variables)", - "World data is persisted in the data volume", - "Requires at least 4GB RAM for smooth operation" - ] - } + 'Connect via Steam: Add Server > IP:2456', + 'Default server password is auto-generated (check environment variables)', + 'World data is persisted in the data volume', + 'Requires at least 4GB RAM for smooth operation', + ], + }, }; // Template categories for organization const TEMPLATE_CATEGORIES = { - "Media": { icon: "🎬", color: "#e74c3c" }, - "Media Management": { icon: "📋", color: "#3498db" }, - "Downloads": { icon: "⬇️", color: "#2ecc71" }, - "Productivity": { icon: "📝", color: "#f39c12" }, - "Development": { icon: "💻", color: "#9b59b6" }, - "Management": { icon: "⚙️", color: "#34495e" }, - "Monitoring": { icon: "📊", color: "#1abc9c" }, - "Networking": { icon: "🌐", color: "#e67e22" }, - "DNS": { icon: "🌐", color: "#3498db" }, - "Files": { icon: "📁", color: "#3498db" }, - "Communication": { icon: "💬", color: "#9b59b6" }, - "Home Automation": { icon: "🏠", color: "#27ae60" }, - "Database": { icon: "🗄️", color: "#8e44ad" }, - "Security": { icon: "🔐", color: "#c0392b" }, - "Photos": { icon: "📸", color: "#16a085" }, - "Utilities": { icon: "\uD83D\uDEE0\uFE0F", color: "#7f8c8d" }, - "Gaming": { icon: "\uD83C\uDFAE", color: "#e91e63" } + 'Media': { icon: '🎬', color: '#e74c3c' }, + 'Media Management': { icon: '📋', color: '#3498db' }, + 'Downloads': { icon: '⬇️', color: '#2ecc71' }, + 'Productivity': { icon: '📝', color: '#f39c12' }, + 'Development': { icon: '💻', color: '#9b59b6' }, + 'Management': { icon: '⚙️', color: '#34495e' }, + 'Monitoring': { icon: '📊', color: '#1abc9c' }, + 'Networking': { icon: '🌐', color: '#e67e22' }, + 'DNS': { icon: '🌐', color: '#3498db' }, + 'Files': { icon: '📁', color: '#3498db' }, + 'Communication': { icon: '💬', color: '#9b59b6' }, + 'Home Automation': { icon: '🏠', color: '#27ae60' }, + 'Database': { icon: '🗄️', color: '#8e44ad' }, + 'Security': { icon: '🔐', color: '#c0392b' }, + 'Photos': { icon: '📸', color: '#16a085' }, + 'Utilities': { icon: '\uD83D\uDEE0\uFE0F', color: '#7f8c8d' }, + 'Gaming': { icon: '\uD83C\uDFAE', color: '#e91e63' }, }; // Difficulty levels const DIFFICULTY_LEVELS = { - "Easy": { color: "#2ecc71", description: "Quick setup, minimal configuration" }, - "Intermediate": { color: "#f39c12", description: "Some configuration required" }, - "Advanced": { color: "#e74c3c", description: "Complex setup, technical knowledge needed" } + 'Easy': { color: '#2ecc71', description: 'Quick setup, minimal configuration' }, + 'Intermediate': { color: '#f39c12', description: 'Some configuration required' }, + 'Advanced': { color: '#e74c3c', description: 'Complex setup, technical knowledge needed' }, }; module.exports = { APP_TEMPLATES, TEMPLATE_CATEGORIES, - DIFFICULTY_LEVELS + DIFFICULTY_LEVELS, }; \ No newline at end of file diff --git a/dashcaddy-api/audit-logger.js b/dashcaddy-api/audit-logger.js index 3fdf5b8..60f614b 100644 --- a/dashcaddy-api/audit-logger.js +++ b/dashcaddy-api/audit-logger.js @@ -111,7 +111,7 @@ class AuditLogger { action: action || '', resource: resource || '', details: details || {}, - outcome: outcome || 'unknown' + outcome: outcome || 'unknown', }; await this.stateManager.update(entries => { diff --git a/dashcaddy-api/auth-manager.js b/dashcaddy-api/auth-manager.js index ed4d9ec..a7039b5 100644 --- a/dashcaddy-api/auth-manager.js +++ b/dashcaddy-api/auth-manager.js @@ -40,10 +40,10 @@ class AuthManager { { ...payload, iat: Math.floor(Date.now() / 1000), - scope: payload.scope || ['read', 'write'] + scope: payload.scope || ['read', 'write'], }, JWT_SECRET, - { expiresIn } + { expiresIn }, ); // SECURITY: Log event only, never log the actual token @@ -67,7 +67,7 @@ class AuthManager { userId: decoded.sub, scope: decoded.scope || [], iat: decoded.iat, - exp: decoded.exp + exp: decoded.exp, }; } catch (error) { if (error.name === 'TokenExpiredError') { @@ -111,7 +111,7 @@ class AuthManager { name, scopes, createdAt: new Date().toISOString(), - lastUsed: null + lastUsed: null, }; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; @@ -128,7 +128,7 @@ class AuthManager { id: keyId, name, scopes, - createdAt: metadata.createdAt + createdAt: metadata.createdAt, }; } catch (error) { console.error('[AuthManager] API key generation failed:', error.message); @@ -179,7 +179,7 @@ class AuthManager { // Update last used timestamp (non-blocking) this.updateLastUsed(keyId, metadata).catch(err => - console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message) + console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message), ); console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`); @@ -187,7 +187,7 @@ class AuthManager { return { keyId, scopes: metadata.scopes || [], - name: metadata.name + name: metadata.name, }; } catch (error) { console.error('[AuthManager] API key verification failed:', error.message); @@ -282,7 +282,7 @@ class AuthManager { try { const updatedMetadata = { ...metadata, - lastUsed: new Date().toISOString() + lastUsed: new Date().toISOString(), }; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; diff --git a/dashcaddy-api/backup-manager.js b/dashcaddy-api/backup-manager.js index 9a30d12..3ec8b28 100644 --- a/dashcaddy-api/backup-manager.js +++ b/dashcaddy-api/backup-manager.js @@ -165,7 +165,7 @@ class BackupManager extends EventEmitter { locations: savedLocations, encrypted: !!backup.encrypt, compressed: true, - status: 'success' + status: 'success', }; this.addToHistory(historyEntry); @@ -187,7 +187,7 @@ class BackupManager extends EventEmitter { timestamp: new Date().toISOString(), duration, status: 'failed', - error: error.message + error: error.message, }; this.addToHistory(historyEntry); @@ -205,7 +205,7 @@ class BackupManager extends EventEmitter { version: '1.0', timestamp: new Date().toISOString(), hostname: require('os').hostname(), - data: {} + data: {}, }; for (const source of include) { @@ -332,10 +332,10 @@ class BackupManager extends EventEmitter { HostConfig: { Binds: [ `${volumeName}:/volume:ro`, - `${backupDir}:/backup` + `${backupDir}:/backup`, ], - AutoRemove: true - } + AutoRemove: true, + }, }); // Start and wait for completion @@ -354,7 +354,7 @@ class BackupManager extends EventEmitter { path: backupFile, size: stats.size, timestamp: new Date().toISOString(), - status: 'success' + status: 'success', }); } } catch (volumeError) { @@ -362,7 +362,7 @@ class BackupManager extends EventEmitter { backupResults.push({ name: volume.Name, status: 'failed', - error: volumeError.message + error: volumeError.message, }); } } @@ -371,7 +371,7 @@ class BackupManager extends EventEmitter { timestamp: new Date().toISOString(), totalVolumes: volumes.length, successCount: backupResults.filter(r => r.status === 'success').length, - volumes: backupResults + volumes: backupResults, }; } catch (error) { console.error('[BackupManager] Error backing up volumes:', error.message); @@ -425,10 +425,10 @@ class BackupManager extends EventEmitter { HostConfig: { Binds: [ `${volumeName}:/volume`, - `${backupDir}:/backup:ro` + `${backupDir}:/backup:ro`, ], - AutoRemove: true - } + AutoRemove: true, + }, }); await container.start(); @@ -442,7 +442,7 @@ class BackupManager extends EventEmitter { restoreResults.push({ name: volumeName, status: 'success', - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); console.log(`[BackupManager] Volume ${volumeName} restored successfully`); @@ -451,7 +451,7 @@ class BackupManager extends EventEmitter { restoreResults.push({ name: volBackup.name, status: 'failed', - error: restoreError.message + error: restoreError.message, }); } } @@ -460,7 +460,7 @@ class BackupManager extends EventEmitter { timestamp: new Date().toISOString(), results: restoreResults, successCount: restoreResults.filter(r => r.status === 'success').length, - failedCount: restoreResults.filter(r => r.status === 'failed').length + failedCount: restoreResults.filter(r => r.status === 'failed').length, }; } @@ -498,7 +498,7 @@ class BackupManager extends EventEmitter { // Return: iv:authTag:encrypted (all base64) return Buffer.from( - iv.toString('base64') + ':' + authTag.toString('base64') + ':' + encrypted.toString('base64') + `${iv.toString('base64') }:${ authTag.toString('base64') }:${ encrypted.toString('base64')}`, ); } @@ -566,7 +566,7 @@ class BackupManager extends EventEmitter { return { type: 'local', path: filepath, - size: data.length + size: data.length, }; } @@ -652,7 +652,7 @@ class BackupManager extends EventEmitter { this.emit('restore-complete', { backupId, restored, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); console.log('[BackupManager] Restore completed successfully'); @@ -661,7 +661,7 @@ class BackupManager extends EventEmitter { this.emit('restore-failed', { backupId, error: error.message, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); throw error; } @@ -790,7 +790,7 @@ class BackupManager extends EventEmitter { return { backups: {}, - defaultRetention: { keep: 7 } + defaultRetention: { keep: 7 }, }; } diff --git a/dashcaddy-api/cache-config.js b/dashcaddy-api/cache-config.js index 7f1fa36..60da47d 100644 --- a/dashcaddy-api/cache-config.js +++ b/dashcaddy-api/cache-config.js @@ -13,7 +13,7 @@ const CACHE_CONFIGS = { max: 500, // Max 500 different services ttl: 60 * 60 * 1000, // 1 hour TTL updateAgeOnGet: true, // Refresh TTL on access - ttlAutopurge: true // Auto-cleanup expired entries + ttlAutopurge: true, // Auto-cleanup expired entries }, // IP-based router sessions (Frontier NVG468MQ) @@ -21,7 +21,7 @@ const CACHE_CONFIGS = { max: 1000, // Support up to 1000 IP addresses ttl: 24 * 60 * 60 * 1000, // 24 hour TTL updateAgeOnGet: true, - ttlAutopurge: true + ttlAutopurge: true, }, // DNS server authentication tokens (Technitium) @@ -29,7 +29,7 @@ const CACHE_CONFIGS = { max: 50, // Max 50 DNS servers ttl: 6 * 60 * 60 * 1000, // 6 hour TTL (matches SESSION_TTL.DNS_TOKEN) updateAgeOnGet: false, // Don't refresh - tokens have fixed expiry - ttlAutopurge: true + ttlAutopurge: true, }, // Tailscale network status @@ -37,7 +37,7 @@ const CACHE_CONFIGS = { max: 1, // Only one status object ttl: 60 * 1000, // 1 minute TTL updateAgeOnGet: false, - ttlAutopurge: true + ttlAutopurge: true, }, // Tailscale API responses (devices, ACLs) @@ -45,8 +45,8 @@ const CACHE_CONFIGS = { max: 5, // devices + ACL + misc ttl: 5 * 60 * 1000, // 5 min (matches sync interval) updateAgeOnGet: false, - ttlAutopurge: true - } + ttlAutopurge: true, + }, }; /** diff --git a/dashcaddy-api/comprehensive-test.js b/dashcaddy-api/comprehensive-test.js index b8d7071..812fc4c 100644 --- a/dashcaddy-api/comprehensive-test.js +++ b/dashcaddy-api/comprehensive-test.js @@ -17,15 +17,15 @@ const colors = { yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', - magenta: '\x1b[35m' + magenta: '\x1b[35m', }; -let testResults = { +const testResults = { passed: 0, failed: 0, warnings: 0, total: 0, - details: [] + details: [], }; function log(message, color = 'reset') { @@ -62,7 +62,7 @@ async function makeRequest(path, options = {}) { path: url.pathname + url.search, method: options.method || 'GET', headers: options.headers || {}, - timeout: options.timeout || 10000 + timeout: options.timeout || 10000, }; const req = http.request(requestOptions, (res) => { @@ -74,7 +74,7 @@ async function makeRequest(path, options = {}) { headers: res.headers, body: data, data: data && (data.startsWith('{') || data.startsWith('[')) ? - (() => { try { return JSON.parse(data); } catch(e) { return null; } })() : data + (() => { try { return JSON.parse(data); } catch(e) { return null; } })() : data, }); }); }); @@ -143,7 +143,7 @@ async function testCSRFProtection() { const response = await makeRequest('/api/test-endpoint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: { test: 'data' } + body: { test: 'data' }, }); if (response.data?.error?.includes('CSRF') || response.data?.message?.includes('CSRF')) { @@ -183,7 +183,7 @@ async function testRequestSizeLimits() { const response = await makeRequest('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(smallPayload) + body: JSON.stringify(smallPayload), }); if (response.statusCode !== 413) { @@ -465,7 +465,7 @@ async function runAllTests() { .forEach(t => log(` ⚠ ${t.name}: ${t.message}`, 'yellow')); } - log('\n' + '═'.repeat(60), 'cyan'); + log(`\n${ '═'.repeat(60)}`, 'cyan'); if (testResults.failed === 0) { log('\n✅ ALL AUTOMATED TESTS PASSED!', 'green'); diff --git a/dashcaddy-api/config-schema.js b/dashcaddy-api/config-schema.js index 9cf0948..dca4cc1 100644 --- a/dashcaddy-api/config-schema.js +++ b/dashcaddy-api/config-schema.js @@ -6,7 +6,7 @@ const VALID_TIMEZONES_SAMPLE = [ 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai', - 'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland' + 'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland', ]; /** @@ -27,7 +27,7 @@ function validateConfig(config) { if (typeof config.tld !== 'string') { errors.push('tld must be a string'); } else { - const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld; + const tld = config.tld.startsWith('.') ? config.tld : `.${ config.tld}`; if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) { errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`); } @@ -117,7 +117,7 @@ function validateConfig(config) { 'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted', 'configurationType', 'defaults', 'customLogo', 'customFavicon', 'dashboardTitle', 'tailscale', 'license', 'skipped', - 'routingMode', 'domain', 'email', 'defaultIP' + 'routingMode', 'domain', 'email', 'defaultIP', ]; for (const key of Object.keys(config)) { if (!knownKeys.includes(key)) { diff --git a/dashcaddy-api/constants.js b/dashcaddy-api/constants.js index 1c6b986..72290cc 100644 --- a/dashcaddy-api/constants.js +++ b/dashcaddy-api/constants.js @@ -105,7 +105,7 @@ const DOCKER = { TIMEOUT: 30000, // 30s — timeout for docker pull/create operations LOG_CONFIG: { Type: 'json-file', - Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container + Config: { 'max-size': '10m', 'max-file': '3' }, // 30MB max per container }, MAINTENANCE: { INTERVAL: 24 * 60 * 60 * 1000, // 24 hours diff --git a/dashcaddy-api/credential-manager.js b/dashcaddy-api/credential-manager.js index 8acdeb1..86116d6 100644 --- a/dashcaddy-api/credential-manager.js +++ b/dashcaddy-api/credential-manager.js @@ -19,7 +19,7 @@ class CredentialManager { this.CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes this.lockOptions = { retries: { retries: 10, minTimeout: 100, maxTimeout: 300 }, - stale: 30000 + stale: 30000, }; console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`); @@ -185,7 +185,7 @@ class CredentialManager { const value = credentials[key].value; decryptedEntries[key] = { plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value, - metadata: credentials[key].metadata + metadata: credentials[key].metadata, }; } @@ -198,7 +198,7 @@ class CredentialManager { rotated[key] = { value: cryptoUtils.encrypt(decryptedEntries[key].plaintext), metadata: decryptedEntries[key].metadata, - rotatedAt: new Date().toISOString() + rotatedAt: new Date().toISOString(), }; } @@ -303,7 +303,7 @@ class CredentialManager { credentials[key] = { value: cryptoUtils.encrypt(value), metadata, - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }; return credentials; }); @@ -360,7 +360,7 @@ class CredentialManager { const backup = { version: '1.0', exportedAt: new Date().toISOString(), - credentials + credentials, }; return cryptoUtils.encrypt(JSON.stringify(backup)); } diff --git a/dashcaddy-api/crypto-utils.js b/dashcaddy-api/crypto-utils.js index f534f10..bb2e5bf 100644 --- a/dashcaddy-api/crypto-utils.js +++ b/dashcaddy-api/crypto-utils.js @@ -336,5 +336,5 @@ module.exports = { deriveKey, rotateKey, decryptWithKey, - clearCachedKey + clearCachedKey, }; diff --git a/dashcaddy-api/csrf-protection.js b/dashcaddy-api/csrf-protection.js index 2e7661b..2d64802 100644 --- a/dashcaddy-api/csrf-protection.js +++ b/dashcaddy-api/csrf-protection.js @@ -68,7 +68,7 @@ function csrfCookieMiddleware(req, res, next) { secure: req.secure || req.protocol === 'https', // Only secure in HTTPS sameSite: 'strict', path: '/', - maxAge: 24 * 60 * 60 * 1000 // 24 hours + maxAge: 24 * 60 * 60 * 1000, // 24 hours }); next(); @@ -96,7 +96,7 @@ function csrfValidationMiddleware(req, res, next) { '/api/totp/verify', '/api/totp/verify-setup', '/health', - '/api/health' + '/api/health', ]; // Check if path starts with excluded prefix @@ -126,7 +126,7 @@ function csrfValidationMiddleware(req, res, next) { return res.status(403).json({ success: false, error: '[DC-100] CSRF token missing', - message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.' + message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.', }); } @@ -135,7 +135,7 @@ function csrfValidationMiddleware(req, res, next) { return res.status(403).json({ success: false, error: '[DC-100] CSRF token missing', - message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.' + message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.', }); } @@ -161,7 +161,7 @@ function csrfValidationMiddleware(req, res, next) { return res.status(403).json({ success: false, error: '[DC-101] CSRF token invalid', - message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.' + message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.', }); } } @@ -174,5 +174,5 @@ module.exports = { signToken, parseCookie, csrfCookieMiddleware, - csrfValidationMiddleware + csrfValidationMiddleware, }; diff --git a/dashcaddy-api/docker-maintenance.js b/dashcaddy-api/docker-maintenance.js index 25bcab1..fb1bc1f 100644 --- a/dashcaddy-api/docker-maintenance.js +++ b/dashcaddy-api/docker-maintenance.js @@ -55,7 +55,7 @@ class DockerMaintenance extends EventEmitter { spaceReclaimed: { images: 0, containers: 0, buildCache: 0, total: 0 }, diskUsage: null, warnings: [], - containersWithoutLogLimits: [] + containersWithoutLogLimits: [], }; try { @@ -72,7 +72,7 @@ class DockerMaintenance extends EventEmitter { try { const stopped = await docker.listContainers({ all: true, - filters: { status: ['exited', 'dead'] } + filters: { status: ['exited', 'dead'] }, }); for (const c of stopped) { // Skip DashCaddy-managed containers — user may want to restart them @@ -108,20 +108,20 @@ class DockerMaintenance extends EventEmitter { result.diskUsage = { images: { count: (df.Images || []).length, - sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0) + sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0), }, containers: { count: (df.Containers || []).length, - sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0) + sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0), }, volumes: { count: (df.Volumes?.Volumes || []).length, - sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0) + sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0), }, buildCache: { count: (df.BuildCache || []).length, - sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0) - } + sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0), + }, }; result.diskUsage.totalBytes = result.diskUsage.images.sizeBytes + @@ -149,7 +149,7 @@ class DockerMaintenance extends EventEmitter { if (!logConfig?.Config?.['max-size']) { result.containersWithoutLogLimits.push({ name: c.Names[0]?.replace(/^\//, '') || c.Id.slice(0, 12), - id: c.Id.slice(0, 12) + id: c.Id.slice(0, 12), }); } } catch (e) { @@ -158,7 +158,7 @@ class DockerMaintenance extends EventEmitter { } if (result.containersWithoutLogLimits.length > 0) { result.warnings.push( - `${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}` + `${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}`, ); } } catch (e) { @@ -204,7 +204,7 @@ class DockerMaintenance extends EventEmitter { return { running: this.running, lastRun: this.lastRun, - lastResult: this.lastResult + lastResult: this.lastResult, }; } } diff --git a/dashcaddy-api/docker-security.js b/dashcaddy-api/docker-security.js index 4a7df11..f462f48 100644 --- a/dashcaddy-api/docker-security.js +++ b/dashcaddy-api/docker-security.js @@ -39,7 +39,7 @@ class DockerSecurity { trustedDigests: {}, verificationMode: VERIFICATION_MODE, allowUnverified: true, - updateTrustedOnPull: true + updateTrustedOnPull: true, }; } @@ -124,7 +124,7 @@ class DockerSecurity { method: 'GET', headers: { 'Accept': 'application/vnd.docker.distribution.manifest.v2+json', - } + }, }; if (token) { @@ -198,7 +198,7 @@ class DockerSecurity { imageName, actualDigest, trustedDigest: trustedDigest || null, - action: 'unknown' + action: 'unknown', }; if (!trustedDigest) { @@ -280,7 +280,7 @@ class DockerSecurity { imageName, action: this.mode === 'permissive' ? 'accept' : 'warn', error: error.message, - reason: `Verification error (${this.mode} mode)` + reason: `Verification error (${this.mode} mode)`, }; } } @@ -335,7 +335,7 @@ class DockerSecurity { mode: this.mode, trustedImagesCount: Object.keys(this.config.trustedDigests).length, configFile: SECURITY_CONFIG_FILE, - updateTrustedOnPull: this.config.updateTrustedOnPull + updateTrustedOnPull: this.config.updateTrustedOnPull, }; } } diff --git a/dashcaddy-api/health-checker.js b/dashcaddy-api/health-checker.js index 6327d26..4762754 100644 --- a/dashcaddy-api/health-checker.js +++ b/dashcaddy-api/health-checker.js @@ -111,7 +111,7 @@ class HealthChecker extends EventEmitter { responseTime, statusCode: result.statusCode, message: result.message, - details: result.details + details: result.details, }; // Track consecutive failures for exponential backoff @@ -136,7 +136,7 @@ class HealthChecker extends EventEmitter { timestamp: new Date().toISOString(), status: 'down', responseTime, - error: error.message + error: error.message, }; this.recordStatus(serviceId, status); @@ -170,7 +170,7 @@ class HealthChecker extends EventEmitter { method, timeout: config.timeout || 20000, headers: config.headers || {}, - rejectUnauthorized: false // Trust internal CA certs (.sami TLD) + rejectUnauthorized: false, // Trust internal CA certs (.sami TLD) }; const req = protocol.request(options, (res) => { @@ -189,8 +189,8 @@ class HealthChecker extends EventEmitter { message: healthy ? 'Service is healthy' : 'Service check failed', details: { headers: res.headers, - bodyLength: data.length - } + bodyLength: data.length, + }, }); }); }); @@ -306,7 +306,7 @@ class HealthChecker extends EventEmitter { const existing = this.incidents.find(i => i.serviceId === serviceId && i.type === type && - i.status === 'open' + i.status === 'open', ); if (existing) { @@ -327,7 +327,7 @@ class HealthChecker extends EventEmitter { createdAt: status.timestamp, lastOccurrence: status.timestamp, occurrences: 1, - details: status + details: status, }; this.incidents.push(incident); @@ -343,7 +343,7 @@ class HealthChecker extends EventEmitter { const incident = this.incidents.find(i => i.serviceId === serviceId && i.type === type && - i.status === 'open' + i.status === 'open', ); if (incident) { @@ -402,7 +402,7 @@ class HealthChecker extends EventEmitter { const history = this.history[serviceId] || []; return history.filter(h => - new Date(h.timestamp).getTime() > cutoffTime + new Date(h.timestamp).getTime() > cutoffTime, ); } @@ -423,10 +423,10 @@ class HealthChecker extends EventEmitter { name: config?.name || serviceId, uptime: { '24h': uptime24h, - '7d': uptime7d + '7d': uptime7d, }, avgResponseTime, - sla: config?.sla + sla: config?.sla, }; } @@ -456,8 +456,8 @@ class HealthChecker extends EventEmitter { min: Math.min(...responseTimes), max: Math.max(...responseTimes), p95: this.calculatePercentile(responseTimes, 95), - p99: this.calculatePercentile(responseTimes, 99) - } + p99: this.calculatePercentile(responseTimes, 99), + }, }; } @@ -504,7 +504,7 @@ class HealthChecker extends EventEmitter { slowResponseThreshold: config.slowResponseThreshold || 5000, sla: config.sla, headers: config.headers || {}, - body: config.body + body: config.body, }; this.saveConfig(); @@ -531,7 +531,7 @@ class HealthChecker extends EventEmitter { for (const serviceId in this.history) { this.history[serviceId] = this.history[serviceId].filter(h => - new Date(h.timestamp).getTime() > cutoffTime + new Date(h.timestamp).getTime() > cutoffTime, ); } } diff --git a/dashcaddy-api/input-validator.js b/dashcaddy-api/input-validator.js index f1309ce..0d89d91 100644 --- a/dashcaddy-api/input-validator.js +++ b/dashcaddy-api/input-validator.js @@ -30,7 +30,7 @@ function validateDNSRecord(data) { if (!subdomainRegex.test(data.subdomain)) { errors.push({ field: 'subdomain', - message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)' + message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)', }); } @@ -80,7 +80,7 @@ function validateDNSRecord(data) { subdomain: data.subdomain.toLowerCase().trim(), domain: data.domain ? data.domain.toLowerCase().trim() : null, ip: data.ip.trim(), - ttl: data.ttl ? parseInt(data.ttl, 10) : 3600 + ttl: data.ttl ? parseInt(data.ttl, 10) : 3600, }; } @@ -99,7 +99,7 @@ function validateDockerDeployment(data) { if (!nameRegex.test(data.name)) { errors.push({ field: 'name', - message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens' + message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens', }); } @@ -119,7 +119,7 @@ function validateDockerDeployment(data) { if (!imageRegex.test(data.image)) { errors.push({ field: 'image', - message: 'Invalid Docker image format' + message: 'Invalid Docker image format', }); } @@ -146,7 +146,7 @@ function validateDockerDeployment(data) { if (!portRegex.test(port)) { errors.push({ field: `ports[${index}]`, - message: 'Invalid port format. Use "host:container" or "host:container/protocol"' + message: 'Invalid port format. Use "host:container" or "host:container/protocol"', }); } else { const [, hostPort, containerPort] = port.match(portRegex); @@ -193,7 +193,7 @@ function validateDockerDeployment(data) { if (!envKeyRegex.test(key)) { errors.push({ field: `environment.${key}`, - message: 'Invalid environment variable name' + message: 'Invalid environment variable name', }); } @@ -201,7 +201,7 @@ function validateDockerDeployment(data) { if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { errors.push({ field: `environment.${key}`, - message: 'Environment variable value must be string, number, or boolean' + message: 'Environment variable value must be string, number, or boolean', }); } }); @@ -219,7 +219,7 @@ function validateDockerDeployment(data) { image: data.image.trim(), ports: data.ports || [], volumes: data.volumes || [], - environment: data.environment || {} + environment: data.environment || {}, }; } @@ -248,7 +248,7 @@ function validateFilePath(filePath, allowedBasePaths = []) { 'C:\\Windows', 'C:\\Program Files', '/var/run', - '/var/lib/docker' + '/var/lib/docker', ]; const lowerPath = normalized.toLowerCase(); @@ -284,7 +284,7 @@ function validateVolumePath(volume, index) { if (!match) { errors.push({ field: `volumes[${index}]`, - message: 'Invalid volume format. Use "host:container" or "host:container:mode"' + message: 'Invalid volume format. Use "host:container" or "host:container:mode"', }); return errors; } @@ -297,7 +297,7 @@ function validateVolumePath(volume, index) { } catch (error) { errors.push({ field: `volumes[${index}].hostPath`, - message: `Invalid host path: ${error.message}` + message: `Invalid host path: ${error.message}`, }); } @@ -305,7 +305,7 @@ function validateVolumePath(volume, index) { if (containerPath.includes('..') || !path.isAbsolute(containerPath)) { errors.push({ field: `volumes[${index}].containerPath`, - message: 'Container path must be absolute and not contain ..' + message: 'Container path must be absolute and not contain ..', }); } @@ -313,7 +313,7 @@ function validateVolumePath(volume, index) { if (mode && !['ro', 'rw', 'z', 'Z'].includes(mode)) { errors.push({ field: `volumes[${index}].mode`, - message: 'Invalid volume mode. Use ro, rw, z, or Z' + message: 'Invalid volume mode. Use ro, rw, z, or Z', }); } @@ -333,7 +333,7 @@ function validateURL(url, options = {}) { require_protocol: options.requireProtocol !== false, require_valid_protocol: true, allow_underscores: false, - ...options + ...options, }; if (!validator.isURL(url, validatorOptions)) { @@ -451,7 +451,7 @@ function isPrivateIP(ip) { /^169\.254\./, /^::1$/, /^fc00:/, - /^fe80:/ + /^fe80:/, ]; return privateRanges.some(range => range.test(ip)); @@ -496,7 +496,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul auditLogger.logSecurityEvent('path_traversal_blocked', { requestedPath, reason: 'null_byte_detected', - severity: 'high' + severity: 'high', }); } throw new ValidationError('Invalid path - null byte detected', 'path'); @@ -510,7 +510,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul /\.\%2f/i, // .%2F (encoded ./) /%2e\./i, // %2E. /\.\\/, // .\ (Windows) - /%5c/i // URL encoded backslash + /%5c/i, // URL encoded backslash ]; if (suspiciousPatterns.some(pattern => pattern.test(requestedPath)) || @@ -520,7 +520,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul requestedPath, decodedPath, reason: 'traversal_sequence_detected', - severity: 'high' + severity: 'high', }); } throw new ValidationError('Path traversal detected', 'path'); @@ -581,7 +581,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul realPath, allowedRoots, reason: 'outside_allowed_roots', - severity: 'critical' + severity: 'critical', }); } throw new ValidationError('Access denied - path is outside allowed directories', 'path'); @@ -602,5 +602,5 @@ module.exports = { sanitizeString, isValidPort, isPrivateIP, - validateSecurePath + validateSecurePath, }; diff --git a/dashcaddy-api/jest.config.js b/dashcaddy-api/jest.config.js index 41fbdf5..8482b6f 100644 --- a/dashcaddy-api/jest.config.js +++ b/dashcaddy-api/jest.config.js @@ -11,17 +11,17 @@ module.exports = { 'update-manager.js', 'resource-monitor.js', 'credential-manager.js', - 'app-templates.js' + 'app-templates.js', ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, - statements: 80 - } + statements: 80, + }, }, setupFilesAfterEnv: ['/__tests__/jest.setup.js'], restoreMocks: true, - clearMocks: true + clearMocks: true, }; diff --git a/dashcaddy-api/keychain-manager.js b/dashcaddy-api/keychain-manager.js index 66f5908..1082581 100644 --- a/dashcaddy-api/keychain-manager.js +++ b/dashcaddy-api/keychain-manager.js @@ -182,7 +182,7 @@ class KeychainManager { try { execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], { input: value, - stdio: ['pipe', 'ignore', 'ignore'] + stdio: ['pipe', 'ignore', 'ignore'], }); return true; } catch { diff --git a/dashcaddy-api/license-keygen.js b/dashcaddy-api/license-keygen.js index 24761c9..7578f5e 100644 --- a/dashcaddy-api/license-keygen.js +++ b/dashcaddy-api/license-keygen.js @@ -177,7 +177,7 @@ function verifyCode(secret, code) { codeId, createdAt: createdDate.toISOString(), expiresAt: isLifetime ? null : expiresDate.toISOString(), - expired: isLifetime ? false : Date.now() > expiresDate.getTime() + expired: isLifetime ? false : Date.now() > expiresDate.getTime(), }; } catch (error) { return { valid: false, reason: error.message }; @@ -230,7 +230,7 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days const isLifetime = result.durationDays === 0; console.log('Code is VALID'); console.log(` Version: ${result.version}`); - console.log(` Duration: ${isLifetime ? 'LIFETIME' : result.durationDays + ' days'}`); + console.log(` Duration: ${isLifetime ? 'LIFETIME' : `${result.durationDays } days`}`); console.log(` Code ID: ${result.codeId}`); console.log(` Created: ${result.createdAt}`); console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`); @@ -293,16 +293,16 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days console.log(output); } } else { - const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : c.durationDays + ' days'}, ID: ${c.codeId})`); + const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : `${c.durationDays } days`}, ID: ${c.codeId})`); if (outputIndex !== -1) { - fs.writeFileSync(args[outputIndex + 1], codes.map(c => c.code).join('\n') + '\n'); + fs.writeFileSync(args[outputIndex + 1], `${codes.map(c => c.code).join('\n') }\n`); console.log(`${count} code(s) written to ${args[outputIndex + 1]}`); } else { lines.forEach(l => console.log(l)); } } - console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`); + console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : `${duration } days`}. Next ID: ${startId + count}`); } // Also export for use by license-manager.js diff --git a/dashcaddy-api/license-manager.js b/dashcaddy-api/license-manager.js index cda3568..47aad84 100644 --- a/dashcaddy-api/license-manager.js +++ b/dashcaddy-api/license-manager.js @@ -23,7 +23,7 @@ const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when l const PREMIUM_FEATURES = { sso: { name: 'Auto-Login SSO', description: 'Automatic single sign-on for deployed apps' }, recipes: { name: 'Recipes', description: 'Multi-container stack deployment' }, - swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' } + swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' }, }; class LicenseManager { @@ -48,13 +48,13 @@ class LicenseManager { if (this.isExpired()) { this.log.info?.('license', 'License has expired', { code: this._maskCode(this.activation.code), - expiredAt: this.activation.expiresAt + expiredAt: this.activation.expiresAt, }); } else { this.log.info?.('license', 'License loaded', { code: this._maskCode(this.activation.code), expiresAt: this.activation.expiresAt, - daysRemaining: this.daysRemaining() + daysRemaining: this.daysRemaining(), }); } } else { @@ -96,7 +96,7 @@ class LicenseManager { os.hostname(), os.platform(), os.arch(), - os.cpus()[0]?.model || 'unknown' + os.cpus()[0]?.model || 'unknown', ]; // Get primary MAC address const interfaces = os.networkInterfaces(); @@ -132,7 +132,7 @@ class LicenseManager { return { success: true, message: 'This code is already activated', - activation: this.getStatus() + activation: this.getStatus(), }; } @@ -170,7 +170,7 @@ class LicenseManager { expiresAt: expiresAt.toISOString(), machineId, validationMethod: 'offline', - features: Object.keys(PREMIUM_FEATURES) + features: Object.keys(PREMIUM_FEATURES), }; } else { // Online validation succeeded — use server response @@ -182,7 +182,7 @@ class LicenseManager { try { await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), { activatedAt: this.activation.activatedAt, - expiresAt: this.activation.expiresAt + expiresAt: this.activation.expiresAt, }); } catch (error) { this.log.error?.('license', 'Failed to store activation', { error: error.message }); @@ -196,14 +196,14 @@ class LicenseManager { code: this._maskCode(code), durationDays: this.activation.durationDays, expiresAt: this.activation.expiresAt, - method: this.activation.validationMethod + method: this.activation.validationMethod, }); const durationLabel = this.activation.lifetime ? 'lifetime' : `${this.activation.durationDays} days`; return { success: true, message: `License activated for ${durationLabel}`, - activation: this.getStatus() + activation: this.getStatus(), }; } @@ -247,7 +247,7 @@ class LicenseManager { active: false, tier: 'free', features: [], - premiumFeatures: PREMIUM_FEATURES + premiumFeatures: PREMIUM_FEATURES, }; } @@ -267,7 +267,7 @@ class LicenseManager { expired, features: expired ? [] : (this.activation.features || Object.keys(PREMIUM_FEATURES)), premiumFeatures: PREMIUM_FEATURES, - validationMethod: this.activation.validationMethod + validationMethod: this.activation.validationMethod, }; } @@ -320,7 +320,7 @@ class LicenseManager { featureName: featureInfo.name, featureDescription: featureInfo.description, currentTier: this.isExpired() ? 'free' : 'expired', - upgradeUrl: '/settings#license' + upgradeUrl: '/settings#license', }); }; } @@ -359,7 +359,7 @@ class LicenseManager { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, machineId }), - signal: AbortSignal.timeout(10000) // 10s timeout + signal: AbortSignal.timeout(10000), // 10s timeout }); if (!response.ok) { @@ -379,8 +379,8 @@ class LicenseManager { expiresAt: data.expiresAt, machineId, features: data.features || Object.keys(PREMIUM_FEATURES), - serverToken: data.token - } + serverToken: data.token, + }, }; } @@ -388,7 +388,7 @@ class LicenseManager { } catch (error) { // Server unreachable — return null to fallback to offline this.log.warn?.('license', 'License server unreachable, falling back to offline validation', { - error: error.message + error: error.message, }); return null; } @@ -405,9 +405,9 @@ class LicenseManager { body: JSON.stringify({ code: this.activation.code, machineId: this.activation.machineId, - serverToken: this.activation.serverToken + serverToken: this.activation.serverToken, }), - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); } @@ -431,7 +431,7 @@ class LicenseManager { tier: 'premium', expiresAt: this.activation.expiresAt, daysRemaining: this.daysRemaining(), - features: this.activation.features || Object.keys(PREMIUM_FEATURES) + features: this.activation.features || Object.keys(PREMIUM_FEATURES), }; } else { config.license = { active: false, tier: 'free' }; diff --git a/dashcaddy-api/log-digest.js b/dashcaddy-api/log-digest.js index 7242ba7..1605a2c 100644 --- a/dashcaddy-api/log-digest.js +++ b/dashcaddy-api/log-digest.js @@ -18,12 +18,12 @@ const ERROR_PATTERNS = [ /\berror\b/i, /\bfailed\b/i, /\bfatal\b/i, /\bpanic\b/i, /\bcrash(ed)?\b/i, /\bexception\b/i, /\btimeout\b/i, /\bOOM\b/, /\bout of memory\b/i, /\bkilled\b/i, - /\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i + /\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i, ]; const WARNING_PATTERNS = [ /\bwarn(ing)?\b/i, /\bdeprecated\b/i, /\bretry(ing)?\b/i, - /\bslow\b/i, /\blatency\b/i + /\bslow\b/i, /\blatency\b/i, ]; const EVENT_PATTERNS = [ @@ -31,7 +31,7 @@ const EVENT_PATTERNS = [ { pattern: /\b(stop(ped|ping)?|shutdown|exit(ed|ing)?|terminat(ed|ing)?)\b/i, type: 'shutdown' }, { pattern: /\b(restart(ed|ing)?|reload(ed|ing)?)\b/i, type: 'restart' }, { pattern: /\bhealth.?check.*(fail|unhealthy)\b/i, type: 'health_failure' }, - { pattern: /\b(update|upgrade|migration)\b/i, type: 'update' } + { pattern: /\b(update|upgrade|migration)\b/i, type: 'update' }, ]; class LogDigest extends EventEmitter { @@ -63,7 +63,7 @@ class LogDigest extends EventEmitter { // Collect logs every hour this.collectInterval = setInterval(() => { this._collectHourlyLogs().catch(e => - console.error('[LogDigest] Hourly collection failed:', e.message) + console.error('[LogDigest] Hourly collection failed:', e.message), ); }, DOCKER.DIGEST.COLLECT_INTERVAL); @@ -102,7 +102,7 @@ class LogDigest extends EventEmitter { const hourSummary = { hour: hourKey, timestamp: now.toISOString(), - services: {} + services: {}, }; try { @@ -123,7 +123,7 @@ class LogDigest extends EventEmitter { events: [], errorCount: 0, warningCount: 0, - totalLines: 0 + totalLines: 0, }; if (isRunning) { @@ -134,7 +134,7 @@ class LogDigest extends EventEmitter { stderr: true, since: sinceTimestamp, tail: DOCKER.DIGEST.LOG_TAIL, - timestamps: true + timestamps: true, }); const lines = this._parseDockerLogs(logBuffer); @@ -147,7 +147,7 @@ class LogDigest extends EventEmitter { if (serviceSummary.errors.length < 10) { serviceSummary.errors.push({ time: line.timestamp || hourKey, - text: line.text.slice(0, 500) + text: line.text.slice(0, 500), }); } continue; @@ -159,7 +159,7 @@ class LogDigest extends EventEmitter { if (serviceSummary.warnings.length < 5) { serviceSummary.warnings.push({ time: line.timestamp || hourKey, - text: line.text.slice(0, 300) + text: line.text.slice(0, 300), }); } continue; @@ -171,7 +171,7 @@ class LogDigest extends EventEmitter { serviceSummary.events.push({ type, time: line.timestamp || hourKey, - text: line.text.slice(0, 300) + text: line.text.slice(0, 300), }); break; } @@ -180,7 +180,7 @@ class LogDigest extends EventEmitter { } catch (logErr) { serviceSummary.errors.push({ time: now.toISOString(), - text: `Failed to fetch logs: ${logErr.message}` + text: `Failed to fetch logs: ${logErr.message}`, }); serviceSummary.errorCount++; } @@ -188,7 +188,7 @@ class LogDigest extends EventEmitter { serviceSummary.events.push({ type: 'not_running', time: now.toISOString(), - text: `Container is ${containerInfo.State}` + text: `Container is ${containerInfo.State}`, }); } @@ -237,7 +237,7 @@ class LogDigest extends EventEmitter { lines.push({ stream: streamType === 2 ? 'stderr' : 'stdout', text: message, - timestamp + timestamp, }); } offset += 8 + size; @@ -258,7 +258,7 @@ class LogDigest extends EventEmitter { const delay = next.getTime() - now.getTime(); this.digestTimeout = setTimeout(() => { this.generateDailyDigest().catch(e => - console.error('[LogDigest] Daily digest generation failed:', e.message) + console.error('[LogDigest] Daily digest generation failed:', e.message), ); // Reschedule for tomorrow if (this.running) this._scheduleDailyDigest(); @@ -288,7 +288,7 @@ class LogDigest extends EventEmitter { totalLines: 0, lastState: svc.state, topErrors: [], - events: [] + events: [], }; } const agg = serviceAgg[appId]; @@ -332,8 +332,8 @@ class LogDigest extends EventEmitter { totalServices: Object.keys(serviceAgg).length, servicesWithErrors: Object.values(serviceAgg).filter(s => s.totalErrors > 0).length, totalErrors: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalErrors, 0), - totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0) - } + totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0), + }, }; // Write formatted digest file @@ -369,7 +369,7 @@ class LogDigest extends EventEmitter { lines.push(''); // Service summary table - lines.push('-- Service Summary ' + '-'.repeat(36)); + lines.push(`-- Service Summary ${ '-'.repeat(36)}`); const services = Object.values(digest.services); if (services.length === 0) { lines.push(' No managed services found.'); @@ -387,14 +387,14 @@ class LogDigest extends EventEmitter { // Notable events const events = digest.notableEvents; if (events.length > 0) { - lines.push('-- Notable Events ' + '-'.repeat(37)); + lines.push(`-- Notable Events ${ '-'.repeat(37)}`); for (const evt of events) { const time = (evt.time || '').slice(11, 16) || '??:??'; lines.push(` [${time}] ${evt.service}: ${evt.text.slice(0, 80)}`); // Add guidance for where to look further const containerName = `${DOCKER.CONTAINER_PREFIX}${evt.appId}`; if (evt.type === 'health_failure' || evt.type === 'restart') { - const sinceDate = digest.date + 'T' + (evt.time || '').slice(11, 13) + ':00:00'; + const sinceDate = `${digest.date }T${ (evt.time || '').slice(11, 13) }:00:00`; lines.push(` See: docker logs ${containerName} --since ${sinceDate}`); } } @@ -404,7 +404,7 @@ class LogDigest extends EventEmitter { // Top errors per service const errServices = services.filter(s => s.totalErrors > 0); if (errServices.length > 0) { - lines.push('-- Error Details ' + '-'.repeat(38)); + lines.push(`-- Error Details ${ '-'.repeat(38)}`); for (const svc of errServices) { lines.push(` ${svc.name} (${svc.totalErrors} errors):`); for (const err of svc.topErrors) { @@ -419,7 +419,7 @@ class LogDigest extends EventEmitter { // Docker disk usage if (digest.diskUsage) { - lines.push('-- Docker Disk Usage ' + '-'.repeat(34)); + lines.push(`-- Docker Disk Usage ${ '-'.repeat(34)}`); const du = digest.diskUsage; lines.push(` Images: ${formatBytes(du.images.sizeBytes)} (${du.images.count} images)`); lines.push(` Containers: ${formatBytes(du.containers.sizeBytes)}`); @@ -439,7 +439,7 @@ class LogDigest extends EventEmitter { lines.push(` Hours collected: ${digest.hoursCollected}/24`); lines.push(hr); - return lines.join('\n') + '\n'; + return `${lines.join('\n') }\n`; } /** @@ -551,7 +551,7 @@ class LogDigest extends EventEmitter { date: today, hoursCollected: todayHours.length, lastCollect: this.lastCollect, - services: serviceAgg + services: serviceAgg, }; } @@ -560,7 +560,7 @@ class LogDigest extends EventEmitter { running: this.running, lastCollect: this.lastCollect, hourlySummaries: this.hourlySummaries.length, - digestDir: this.digestDir + digestDir: this.digestDir, }; } } @@ -569,7 +569,7 @@ function formatBytes(bytes) { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]; + return `${(bytes / Math.pow(1024, i)).toFixed(1) } ${ units[i]}`; } module.exports = new LogDigest(); diff --git a/dashcaddy-api/logger-utils.js b/dashcaddy-api/logger-utils.js index 30beae1..0ce7e7f 100644 --- a/dashcaddy-api/logger-utils.js +++ b/dashcaddy-api/logger-utils.js @@ -37,7 +37,7 @@ const SENSITIVE_FIELDS = [ 'masterKey', 'master_key', 'encryptionKey', - 'encryption_key' + 'encryption_key', ]; /** @@ -116,7 +116,7 @@ function safeLog(message, data = {}, additionalSensitiveKeys = []) { return { message, data: sanitizeForLog(data, additionalSensitiveKeys), - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; } @@ -124,5 +124,5 @@ module.exports = { sanitizeForLog, redactCredential, safeLog, - SENSITIVE_FIELDS + SENSITIVE_FIELDS, }; diff --git a/dashcaddy-api/metrics.js b/dashcaddy-api/metrics.js index 09b196d..149564c 100644 --- a/dashcaddy-api/metrics.js +++ b/dashcaddy-api/metrics.js @@ -11,11 +11,11 @@ class Metrics { total: 0, byStatus: {}, byMethod: {}, - byPath: {} + byPath: {}, }; this.errors = { total: 0, - byType: {} + byType: {}, }; this.business = { containersDeployed: 0, @@ -26,7 +26,7 @@ class Metrics { totpLogins: 0, siteAdded: 0, siteRemoved: 0, - credentialRotations: 0 + credentialRotations: 0, }; } @@ -78,19 +78,19 @@ class Metrics { perSecond: uptimeSec > 0 ? +(this.requests.total / uptimeSec).toFixed(2) : 0, byStatus: this.requests.byStatus, byMethod: this.requests.byMethod, - topEndpoints + topEndpoints, }, errors: { total: this.errors.total, rate: this.requests.total > 0 ? +((this.errors.total / this.requests.total) * 100).toFixed(2) : 0, - byType: this.errors.byType + byType: this.errors.byType, }, business: this.business, process: { memory: process.memoryUsage(), pid: process.pid, - nodeVersion: process.version - } + nodeVersion: process.version, + }, }; } diff --git a/dashcaddy-api/middleware.js b/dashcaddy-api/middleware.js index 653818f..bbb1916 100644 --- a/dashcaddy-api/middleware.js +++ b/dashcaddy-api/middleware.js @@ -27,7 +27,7 @@ const { CACHE_CONFIGS, createCache } = require('./cache-config'); module.exports = function configureMiddleware(app, { siteConfig, totpConfig, tailscaleConfig, metrics, auditLogger, authManager, log, cryptoUtils, - isValidContainerId, isTailscaleIP, getTailscaleStatus + isValidContainerId, isTailscaleIP, getTailscaleStatus, }) { // ── Container ID param validation ── @@ -44,7 +44,7 @@ module.exports = function configureMiddleware(app, { app.use(cors({ origin: corsOrigins, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - credentials: true + credentials: true, })); // ── Security headers with Helmet ── @@ -54,16 +54,16 @@ module.exports = function configureMiddleware(app, { defaultSrc: ["'self'"], styleSrc: ["'self'"], scriptSrc: ["'self'"], - imgSrc: ["'self'", "data:", "https:"], + imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'"], - fontSrc: ["'self'", "data:"], + fontSrc: ["'self'", 'data:'], objectSrc: ["'none'"], mediaSrc: ["'self'"], - frameSrc: ["'none'"] - } + frameSrc: ["'none'"], + }, }, crossOriginEmbedderPolicy: false, - crossOriginResourcePolicy: { policy: "cross-origin" } + crossOriginResourcePolicy: { policy: 'cross-origin' }, })); // ── Trust proxy (one hop — Caddy) ── @@ -95,7 +95,7 @@ module.exports = function configureMiddleware(app, { if (req.path !== '/health' && req.path !== '/api/health') { const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'debug'; log[level]('http', `${req.method} ${req.path} ${res.statusCode}`, { - ms: duration, ip: req.ip, id: req.id + ms: duration, ip: req.ip, id: req.id, }); } }); @@ -128,7 +128,7 @@ module.exports = function configureMiddleware(app, { success: false, error: '[DC-120] Access denied. This dashboard requires Tailscale connection.', requiresTailscale: true, - clientIP: clientIP + clientIP: clientIP, }); } @@ -151,7 +151,7 @@ module.exports = function configureMiddleware(app, { success: false, error: '[DC-121] Access denied. Device not in allowed tailnet.', requiresTailscale: true, - clientIP + clientIP, }); } } @@ -178,7 +178,7 @@ module.exports = function configureMiddleware(app, { '8h': 8 * 60 * 60 * 1000, '12h': 12 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, - 'never': null + 'never': null, }; // IP-based session store (solves cross-domain cookie issues with .sami TLD) @@ -222,7 +222,7 @@ module.exports = function configureMiddleware(app, { const key = cryptoUtils.loadOrCreateKey(); const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url'); res.setHeader('Set-Cookie', - `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` + `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`, ); } @@ -254,7 +254,7 @@ module.exports = function configureMiddleware(app, { function clearSessionCookie(res) { res.setHeader('Set-Cookie', - `${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax` + `${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`, ); } @@ -324,7 +324,7 @@ module.exports = function configureMiddleware(app, { if (req.totpSessionValid || isSessionValid(req)) { req.auth = { type: 'session', - scope: ['admin'] + scope: ['admin'], }; return next(); } @@ -340,7 +340,7 @@ module.exports = function configureMiddleware(app, { req.auth = { type: 'jwt', userId: jwtPayload.userId, - scope: jwtPayload.scope || [] + scope: jwtPayload.scope || [], }; return next(); } @@ -355,7 +355,7 @@ module.exports = function configureMiddleware(app, { type: 'apikey', keyId: keyData.keyId, name: keyData.name, - scope: keyData.scopes || [] + scope: keyData.scopes || [], }; return next(); } @@ -364,7 +364,7 @@ module.exports = function configureMiddleware(app, { if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') { req.auth = { type: 'none', - scope: ['admin'] + scope: ['admin'], }; return next(); } @@ -372,7 +372,7 @@ module.exports = function configureMiddleware(app, { return res.status(401).json({ success: false, error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', - requiresTotp: totpConfig.enabled + requiresTotp: totpConfig.enabled, }); }; @@ -385,7 +385,7 @@ module.exports = function configureMiddleware(app, { standardHeaders: true, legacyHeaders: false, skip: (req) => isTest || req.path === '/health' || req.path === '/api/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/auth/gate/') || req.path === '/api/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs', - message: { success: false, error: 'Too many requests, please try again later' } + message: { success: false, error: 'Too many requests, please try again later' }, }); const strictLimiter = rateLimit({ @@ -393,7 +393,7 @@ module.exports = function configureMiddleware(app, { standardHeaders: true, legacyHeaders: false, skip: () => isTest, - message: { success: false, error: 'Too many requests to this endpoint, please try again later' } + message: { success: false, error: 'Too many requests to this endpoint, please try again later' }, }); app.use(generalLimiter); @@ -407,7 +407,7 @@ module.exports = function configureMiddleware(app, { ...RATE_LIMITS.TOTP, standardHeaders: true, legacyHeaders: false, - message: { success: false, error: 'Too many TOTP attempts, please try again later' } + message: { success: false, error: 'Too many TOTP attempts, please try again later' }, }); app.use('/api/totp/verify', totpLimiter); app.use('/api/totp/verify-setup', totpLimiter); @@ -425,6 +425,6 @@ module.exports = function configureMiddleware(app, { clearIPSession, clearSessionCookie, isSessionValid, - ipSessions + ipSessions, }; }; 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 9c01953..348d663 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/platform-paths.js b/dashcaddy-api/platform-paths.js index 9ab658c..858ea88 100644 --- a/dashcaddy-api/platform-paths.js +++ b/dashcaddy-api/platform-paths.js @@ -47,17 +47,17 @@ const paths = { // Log paths (for allowed log file access) allowedLogPaths: isWindows ? [ - process.env.LOCALAPPDATA || 'C:\\Users', - process.env.APPDATA || 'C:\\Users', - 'C:\\ProgramData', - '/var/log', - '/opt' - ] + process.env.LOCALAPPDATA || 'C:\\Users', + process.env.APPDATA || 'C:\\Users', + 'C:\\ProgramData', + '/var/log', + '/opt', + ] : [ - '/var/log', - '/opt', - '/home' - ], + '/var/log', + '/opt', + '/home', + ], // Platform detection helpers isWindows, diff --git a/dashcaddy-api/port-lock-manager.js b/dashcaddy-api/port-lock-manager.js index 29528e4..ad08d78 100644 --- a/dashcaddy-api/port-lock-manager.js +++ b/dashcaddy-api/port-lock-manager.js @@ -16,10 +16,10 @@ const LOCK_RETRY_OPTIONS = { retries: 10, minTimeout: 100, maxTimeout: 1000, - randomize: true + randomize: true, }, stale: LOCK_STALE_THRESHOLD, - realpath: false + realpath: false, }; class PortLockManager { @@ -72,7 +72,7 @@ class PortLockManager { if (!fs.existsSync(lockFilePath)) { fs.writeFileSync(lockFilePath, JSON.stringify({ created: new Date().toISOString(), - port + port, })); } @@ -89,7 +89,7 @@ class PortLockManager { this.activeLocks.set(lockId, { ports: sortedPorts, releases: releaseFunctions, - timestamp: Date.now() + timestamp: Date.now(), }); console.log(`[PortLockManager] Successfully acquired all locks (ID: ${lockId})`); @@ -97,13 +97,13 @@ class PortLockManager { } catch (error) { // Release any locks we managed to acquire - console.error(`[PortLockManager] Failed to acquire all locks:`, error.message); + console.error('[PortLockManager] Failed to acquire all locks:', error.message); for (const release of releaseFunctions) { try { await release(); } catch (releaseError) { - console.error(`[PortLockManager] Error releasing lock during cleanup:`, releaseError.message); + console.error('[PortLockManager] Error releasing lock during cleanup:', releaseError.message); } } @@ -132,7 +132,7 @@ class PortLockManager { await release(); } catch (error) { errors.push(error.message); - console.error(`[PortLockManager] Error releasing lock:`, error.message); + console.error('[PortLockManager] Error releasing lock:', error.message); } } @@ -198,13 +198,13 @@ class PortLockManager { lockId, ports: info.ports, age: Date.now() - info.timestamp, - timestamp: new Date(info.timestamp).toISOString() + timestamp: new Date(info.timestamp).toISOString(), })); return { activeLocks: activeLocks.length, locks: activeLocks, - lockDirectory: LOCK_DIR + lockDirectory: LOCK_DIR, }; } diff --git a/dashcaddy-api/recipe-templates.js b/dashcaddy-api/recipe-templates.js index 25fad76..2f5f9a0 100644 --- a/dashcaddy-api/recipe-templates.js +++ b/dashcaddy-api/recipe-templates.js @@ -4,336 +4,336 @@ const RECIPE_TEMPLATES = { // === MEDIA & ENTERTAINMENT === - "htpc-suite": { - name: "HTPC Suite", - description: "Complete media automation: find, download, organize, and stream", - icon: "\uD83C\uDFAC", - category: "Media", - type: "recipe", - difficulty: "Intermediate", + 'htpc-suite': { + name: 'HTPC Suite', + description: 'Complete media automation: find, download, organize, and stream', + icon: '\uD83C\uDFAC', + category: 'Media', + type: 'recipe', + difficulty: 'Intermediate', popularity: 98, components: [ { - id: "prowlarr", - role: "Indexer Manager", - templateRef: "prowlarr", + id: 'prowlarr', + role: 'Indexer Manager', + templateRef: 'prowlarr', required: true, - order: 1 + order: 1, }, { - id: "qbittorrent", - role: "Download Client", - templateRef: "qbittorrent", + id: 'qbittorrent', + role: 'Download Client', + templateRef: 'qbittorrent', required: true, - order: 2 + order: 2, }, { - id: "sonarr", - role: "TV Show Manager", - templateRef: "sonarr", + id: 'sonarr', + role: 'TV Show Manager', + templateRef: 'sonarr', required: true, - order: 3 + order: 3, }, { - id: "radarr", - role: "Movie Manager", - templateRef: "radarr", + id: 'radarr', + role: 'Movie Manager', + templateRef: 'radarr', required: true, - order: 4 + order: 4, }, { - id: "lidarr", - role: "Music Manager", - templateRef: "lidarr", + id: 'lidarr', + role: 'Music Manager', + templateRef: 'lidarr', required: false, - order: 5 + order: 5, }, { - id: "overseerr", - role: "Request Manager", - templateRef: "seerr", + id: 'overseerr', + role: 'Request Manager', + templateRef: 'seerr', required: false, - order: 6 - } + order: 6, + }, ], sharedVolumes: { media: { - label: "Media Library", - description: "Root folder for all media (movies, TV, music)", - defaultPath: "/media", - usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"] + label: 'Media Library', + description: 'Root folder for all media (movies, TV, music)', + defaultPath: '/media', + usedBy: ['sonarr', 'radarr', 'lidarr', 'qbittorrent'], }, downloads: { - label: "Downloads", - description: "Shared downloads folder for all download clients", - defaultPath: "/downloads", - usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"] - } + label: 'Downloads', + description: 'Shared downloads folder for all download clients', + defaultPath: '/downloads', + usedBy: ['sonarr', 'radarr', 'lidarr', 'qbittorrent'], + }, }, autoConnect: { enabled: true, - description: "Automatically connects Sonarr/Radarr to Prowlarr and qBittorrent", + description: 'Automatically connects Sonarr/Radarr to Prowlarr and qBittorrent', steps: [ - { action: "configureProwlarrApps", targets: ["sonarr", "radarr", "lidarr"] }, - { action: "configureDownloadClient", client: "qbittorrent", targets: ["sonarr", "radarr", "lidarr"] } - ] + { action: 'configureProwlarrApps', targets: ['sonarr', 'radarr', 'lidarr'] }, + { action: 'configureDownloadClient', client: 'qbittorrent', targets: ['sonarr', 'radarr', 'lidarr'] }, + ], }, setupInstructions: [ - "All services share the same media and downloads folders", - "Prowlarr is pre-connected to Sonarr, Radarr, and Lidarr", - "Add indexers in Prowlarr \u2014 they sync automatically to all *arr apps", - "Add your media library root folders in Sonarr and Radarr", - "qBittorrent is pre-configured as the download client" - ] + 'All services share the same media and downloads folders', + 'Prowlarr is pre-connected to Sonarr, Radarr, and Lidarr', + 'Add indexers in Prowlarr \u2014 they sync automatically to all *arr apps', + 'Add your media library root folders in Sonarr and Radarr', + 'qBittorrent is pre-configured as the download client', + ], }, // === PRODUCTIVITY === - "nextcloud-complete": { - name: "Nextcloud Complete", - description: "Full productivity suite: cloud storage, office editing, and collaboration", - icon: "\u2601\uFE0F", - category: "Productivity", - type: "recipe", - difficulty: "Intermediate", + 'nextcloud-complete': { + name: 'Nextcloud Complete', + description: 'Full productivity suite: cloud storage, office editing, and collaboration', + icon: '\u2601\uFE0F', + category: 'Productivity', + type: 'recipe', + difficulty: 'Intermediate', popularity: 90, components: [ { - id: "nextcloud-db", - role: "Database", + id: 'nextcloud-db', + role: 'Database', required: true, order: 0, docker: { - image: "mariadb:11", + image: 'mariadb:11', ports: [], - volumes: ["/opt/nextcloud-db/data:/var/lib/mysql"], + volumes: ['/opt/nextcloud-db/data:/var/lib/mysql'], environment: { - "MYSQL_ROOT_PASSWORD": "{{GENERATED_PASSWORD}}", - "MYSQL_DATABASE": "nextcloud", - "MYSQL_USER": "nextcloud", - "MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}" - } + 'MYSQL_ROOT_PASSWORD': '{{GENERATED_PASSWORD}}', + 'MYSQL_DATABASE': 'nextcloud', + 'MYSQL_USER': 'nextcloud', + 'MYSQL_PASSWORD': '{{GENERATED_PASSWORD}}', + }, }, - internal: true + internal: true, }, { - id: "nextcloud-redis", - role: "Cache", + id: 'nextcloud-redis', + role: 'Cache', required: true, order: 0, docker: { - image: "redis:7-alpine", + image: 'redis:7-alpine', ports: [], - volumes: ["/opt/nextcloud-redis/data:/data"], - environment: {} + volumes: ['/opt/nextcloud-redis/data:/data'], + environment: {}, }, - internal: true + internal: true, }, { - id: "nextcloud", - role: "Cloud Platform", - templateRef: "nextcloud", + id: 'nextcloud', + role: 'Cloud Platform', + templateRef: 'nextcloud', required: true, order: 1, envOverrides: { - "MYSQL_HOST": "dashcaddy-nextcloud-db", - "MYSQL_DATABASE": "nextcloud", - "MYSQL_USER": "nextcloud", - "MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}", - "REDIS_HOST": "dashcaddy-nextcloud-redis" - } + 'MYSQL_HOST': 'dashcaddy-nextcloud-db', + 'MYSQL_DATABASE': 'nextcloud', + 'MYSQL_USER': 'nextcloud', + 'MYSQL_PASSWORD': '{{GENERATED_PASSWORD}}', + 'REDIS_HOST': 'dashcaddy-nextcloud-redis', + }, }, { - id: "collabora", - role: "Office Suite", + id: 'collabora', + role: 'Office Suite', required: false, order: 2, docker: { - image: "collabora/code:latest", - ports: ["{{PORT}}:9980"], + image: 'collabora/code:latest', + ports: ['{{PORT}}:9980'], volumes: [], environment: { - "aliasgroup1": "https://{{NEXTCLOUD_DOMAIN}}", - "extra_params": "--o:ssl.enable=false --o:ssl.termination=true" - } + 'aliasgroup1': 'https://{{NEXTCLOUD_DOMAIN}}', + 'extra_params': '--o:ssl.enable=false --o:ssl.termination=true', + }, }, - subdomain: "office", + subdomain: 'office', defaultPort: 9980, - healthCheck: "/" - } + healthCheck: '/', + }, ], network: { - name: "dashcaddy-nextcloud", - driver: "bridge" + name: 'dashcaddy-nextcloud', + driver: 'bridge', }, sharedVolumes: { data: { - label: "Cloud Storage", - description: "Nextcloud data directory for user files", - defaultPath: "/opt/nextcloud/data", - usedBy: ["nextcloud"] - } + label: 'Cloud Storage', + description: 'Nextcloud data directory for user files', + defaultPath: '/opt/nextcloud/data', + usedBy: ['nextcloud'], + }, }, setupInstructions: [ - "Complete the Nextcloud initial setup wizard in the browser", - "MariaDB and Redis are pre-configured and connected", - "If Collabora is enabled, configure it in Nextcloud: Settings \u2192 Nextcloud Office", - "Point Nextcloud Office to your Collabora URL (e.g., https://office.sami)", - "Configure email, 2FA, and other settings in Nextcloud admin panel" - ] + 'Complete the Nextcloud initial setup wizard in the browser', + 'MariaDB and Redis are pre-configured and connected', + 'If Collabora is enabled, configure it in Nextcloud: Settings \u2192 Nextcloud Office', + 'Point Nextcloud Office to your Collabora URL (e.g., https://office.sami)', + 'Configure email, 2FA, and other settings in Nextcloud admin panel', + ], }, // === DEVELOPMENT === - "dev-environment": { - name: "Dev Environment", - description: "Self-hosted development workflow: Git, CI/CD, IDE, and database", - icon: "\uD83D\uDCBB", - category: "Development", - type: "recipe", - difficulty: "Advanced", + 'dev-environment': { + name: 'Dev Environment', + description: 'Self-hosted development workflow: Git, CI/CD, IDE, and database', + icon: '\uD83D\uDCBB', + category: 'Development', + type: 'recipe', + difficulty: 'Advanced', popularity: 82, components: [ { - id: "dev-postgres", - role: "Database", + id: 'dev-postgres', + role: 'Database', required: true, order: 0, docker: { - image: "postgres:16-alpine", + image: 'postgres:16-alpine', ports: [], - volumes: ["/opt/dev-postgres/data:/var/lib/postgresql/data"], + volumes: ['/opt/dev-postgres/data:/var/lib/postgresql/data'], environment: { - "POSTGRES_DB": "gitea", - "POSTGRES_USER": "gitea", - "POSTGRES_PASSWORD": "{{GENERATED_PASSWORD}}" - } + 'POSTGRES_DB': 'gitea', + 'POSTGRES_USER': 'gitea', + 'POSTGRES_PASSWORD': '{{GENERATED_PASSWORD}}', + }, }, - internal: true + internal: true, }, { - id: "gitea", - role: "Git Server", - templateRef: "gitea", + id: 'gitea', + role: 'Git Server', + templateRef: 'gitea', required: true, order: 1, envOverrides: { - "GITEA__database__DB_TYPE": "postgres", - "GITEA__database__HOST": "dashcaddy-dev-postgres:5432", - "GITEA__database__NAME": "gitea", - "GITEA__database__USER": "gitea", - "GITEA__database__PASSWD": "{{GENERATED_PASSWORD}}" - } + 'GITEA__database__DB_TYPE': 'postgres', + 'GITEA__database__HOST': 'dashcaddy-dev-postgres:5432', + 'GITEA__database__NAME': 'gitea', + 'GITEA__database__USER': 'gitea', + 'GITEA__database__PASSWD': '{{GENERATED_PASSWORD}}', + }, }, { - id: "drone", - role: "CI/CD Pipeline", - templateRef: "drone", + id: 'drone', + role: 'CI/CD Pipeline', + templateRef: 'drone', required: false, - order: 2 + order: 2, }, { - id: "vscode-server", - role: "Web IDE", - templateRef: "vscode-server", + id: 'vscode-server', + role: 'Web IDE', + templateRef: 'vscode-server', required: false, - order: 3 - } + order: 3, + }, ], network: { - name: "dashcaddy-dev", - driver: "bridge" + name: 'dashcaddy-dev', + driver: 'bridge', }, setupInstructions: [ - "Gitea is pre-configured with PostgreSQL database", - "Complete the Gitea initial setup wizard in the browser", - "If Drone CI is enabled, connect it to Gitea via OAuth application", - "VS Code Server provides a full IDE in your browser", - "All development services share a Docker network for inter-service communication" - ] + 'Gitea is pre-configured with PostgreSQL database', + 'Complete the Gitea initial setup wizard in the browser', + 'If Drone CI is enabled, connect it to Gitea via OAuth application', + 'VS Code Server provides a full IDE in your browser', + 'All development services share a Docker network for inter-service communication', + ], }, // === HOME AUTOMATION === - "smart-home": { - name: "Smart Home Hub", - description: "Home automation: control, automate, and monitor IoT devices", - icon: "\uD83C\uDFE0", - category: "Home Automation", - type: "recipe", - difficulty: "Intermediate", + 'smart-home': { + name: 'Smart Home Hub', + description: 'Home automation: control, automate, and monitor IoT devices', + icon: '\uD83C\uDFE0', + category: 'Home Automation', + type: 'recipe', + difficulty: 'Intermediate', popularity: 88, components: [ { - id: "mosquitto", - role: "MQTT Broker", + id: 'mosquitto', + role: 'MQTT Broker', required: true, order: 0, docker: { - image: "eclipse-mosquitto:2", - ports: ["1883:1883", "9001:9001"], + image: 'eclipse-mosquitto:2', + ports: ['1883:1883', '9001:9001'], volumes: [ - "/opt/mosquitto/config:/mosquitto/config", - "/opt/mosquitto/data:/mosquitto/data", - "/opt/mosquitto/log:/mosquitto/log" + '/opt/mosquitto/config:/mosquitto/config', + '/opt/mosquitto/data:/mosquitto/data', + '/opt/mosquitto/log:/mosquitto/log', ], - environment: {} + environment: {}, }, - subdomain: "mqtt", + subdomain: 'mqtt', defaultPort: 1883, internal: false, - setupNote: "MQTT broker for IoT device communication" + setupNote: 'MQTT broker for IoT device communication', }, { - id: "homeassistant", - role: "Automation Hub", - templateRef: "homeassistant", + id: 'homeassistant', + role: 'Automation Hub', + templateRef: 'homeassistant', required: true, - order: 1 + order: 1, }, { - id: "nodered", - role: "Flow Automation", - templateRef: "nodered", + id: 'nodered', + role: 'Flow Automation', + templateRef: 'nodered', required: true, - order: 2 + order: 2, }, { - id: "zigbee2mqtt", - role: "Zigbee Bridge", + id: 'zigbee2mqtt', + role: 'Zigbee Bridge', required: false, order: 3, docker: { - image: "koenkk/zigbee2mqtt:latest", - ports: ["{{PORT}}:8080"], - volumes: ["/opt/zigbee2mqtt/data:/app/data"], + image: 'koenkk/zigbee2mqtt:latest', + ports: ['{{PORT}}:8080'], + volumes: ['/opt/zigbee2mqtt/data:/app/data'], environment: { - "TZ": "{{TIMEZONE}}" - } + 'TZ': '{{TIMEZONE}}', + }, }, - subdomain: "zigbee", + subdomain: 'zigbee', defaultPort: 8080, - healthCheck: "/", - note: "Requires a Zigbee USB adapter (e.g., Sonoff Zigbee 3.0 USB Dongle Plus)" - } + healthCheck: '/', + note: 'Requires a Zigbee USB adapter (e.g., Sonoff Zigbee 3.0 USB Dongle Plus)', + }, ], network: { - name: "dashcaddy-smarthome", - driver: "bridge" + name: 'dashcaddy-smarthome', + driver: 'bridge', }, setupInstructions: [ - "Mosquitto MQTT broker is ready for IoT device connections on port 1883", - "Complete the Home Assistant onboarding wizard in the browser", - "Connect Home Assistant to MQTT: Settings \u2192 Integrations \u2192 MQTT", - "Node-RED provides visual flow automation \u2014 connect it to MQTT for device control", - "If Zigbee2MQTT is enabled, it requires a physical Zigbee USB adapter" - ] - } + 'Mosquitto MQTT broker is ready for IoT device connections on port 1883', + 'Complete the Home Assistant onboarding wizard in the browser', + 'Connect Home Assistant to MQTT: Settings \u2192 Integrations \u2192 MQTT', + 'Node-RED provides visual flow automation \u2014 connect it to MQTT for device control', + 'If Zigbee2MQTT is enabled, it requires a physical Zigbee USB adapter', + ], + }, }; // Recipe category metadata (separate from app categories) const RECIPE_CATEGORIES = { - "Media": { icon: "\uD83C\uDFAC", color: "#e74c3c", description: "Media streaming and automation stacks" }, - "Productivity": { icon: "\u2601\uFE0F", color: "#3498db", description: "Cloud storage and office suites" }, - "Development": { icon: "\uD83D\uDCBB", color: "#9b59b6", description: "Self-hosted development environments" }, - "Home Automation": { icon: "\uD83C\uDFE0", color: "#27ae60", description: "IoT and smart home control" } + 'Media': { icon: '\uD83C\uDFAC', color: '#e74c3c', description: 'Media streaming and automation stacks' }, + 'Productivity': { icon: '\u2601\uFE0F', color: '#3498db', description: 'Cloud storage and office suites' }, + 'Development': { icon: '\uD83D\uDCBB', color: '#9b59b6', description: 'Self-hosted development environments' }, + 'Home Automation': { icon: '\uD83C\uDFE0', color: '#27ae60', description: 'IoT and smart home control' }, }; module.exports = { RECIPE_TEMPLATES, RECIPE_CATEGORIES }; diff --git a/dashcaddy-api/resource-monitor.js b/dashcaddy-api/resource-monitor.js index 5c5cf0d..5bc34f3 100644 --- a/dashcaddy-api/resource-monitor.js +++ b/dashcaddy-api/resource-monitor.js @@ -144,28 +144,28 @@ class ResourceMonitor extends EventEmitter { timestamp: new Date().toISOString(), cpu: { percent: Math.round(cpuPercent * 100) / 100, - usage: stats.cpu_stats.cpu_usage.total_usage + usage: stats.cpu_stats.cpu_usage.total_usage, }, memory: { usage: memoryUsage, limit: memoryLimit, percent: Math.round(memoryPercent * 100) / 100, usageMB: Math.round(memoryUsage / 1024 / 1024), - limitMB: Math.round(memoryLimit / 1024 / 1024) + limitMB: Math.round(memoryLimit / 1024 / 1024), }, network: { rxBytes: networkRx, txBytes: networkTx, rxMB: Math.round(networkRx / 1024 / 1024 * 100) / 100, - txMB: Math.round(networkTx / 1024 / 1024 * 100) / 100 + txMB: Math.round(networkTx / 1024 / 1024 * 100) / 100, }, disk: { readBytes: blockRead, writeBytes: blockWrite, readMB: Math.round(blockRead / 1024 / 1024 * 100) / 100, - writeMB: Math.round(blockWrite / 1024 / 1024 * 100) / 100 + writeMB: Math.round(blockWrite / 1024 / 1024 * 100) / 100, }, - pids: stats.pids_stats?.current || 0 + pids: stats.pids_stats?.current || 0, }); }); }); @@ -178,7 +178,7 @@ class ResourceMonitor extends EventEmitter { if (!this.stats.has(containerId)) { this.stats.set(containerId, { name: containerName, - history: [] + history: [], }); } @@ -189,7 +189,7 @@ class ResourceMonitor extends EventEmitter { // Keep only recent stats (based on retention policy) const cutoffTime = Date.now() - (STATS_RETENTION_HOURS * 60 * 60 * 1000); containerStats.history = containerStats.history.filter(s => - new Date(s.timestamp).getTime() > cutoffTime + new Date(s.timestamp).getTime() > cutoffTime, ); } @@ -216,7 +216,7 @@ class ResourceMonitor extends EventEmitter { severity: 'warning', message: `CPU usage ${stats.cpu.percent.toFixed(1)}% exceeds threshold ${alertConfig.cpuThreshold}%`, value: stats.cpu.percent, - threshold: alertConfig.cpuThreshold + threshold: alertConfig.cpuThreshold, }); } @@ -227,7 +227,7 @@ class ResourceMonitor extends EventEmitter { severity: 'warning', message: `Memory usage ${stats.memory.percent.toFixed(1)}% exceeds threshold ${alertConfig.memoryThreshold}%`, value: stats.memory.percent, - threshold: alertConfig.memoryThreshold + threshold: alertConfig.memoryThreshold, }); } @@ -240,7 +240,7 @@ class ResourceMonitor extends EventEmitter { severity: 'warning', message: `Disk I/O ${diskIO.toFixed(1)} MB/s exceeds threshold ${alertConfig.diskIOThreshold} MB/s`, value: diskIO, - threshold: alertConfig.diskIOThreshold + threshold: alertConfig.diskIOThreshold, }); } } @@ -254,7 +254,7 @@ class ResourceMonitor extends EventEmitter { timestamp: new Date().toISOString(), alerts, stats, - config: alertConfig + config: alertConfig, }); // Auto-restart if configured @@ -278,7 +278,7 @@ class ResourceMonitor extends EventEmitter { containerId, containerName, timestamp: new Date().toISOString(), - reason: alerts + reason: alerts, }); } catch (error) { console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message); @@ -306,7 +306,7 @@ class ResourceMonitor extends EventEmitter { const cutoffTime = Date.now() - (hours * 60 * 60 * 1000); return containerStats.history.filter(s => - new Date(s.timestamp).getTime() > cutoffTime + new Date(s.timestamp).getTime() > cutoffTime, ); } @@ -325,16 +325,16 @@ class ResourceMonitor extends EventEmitter { current: cpuValues[cpuValues.length - 1], avg: cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length, max: Math.max(...cpuValues), - min: Math.min(...cpuValues) + min: Math.min(...cpuValues), }, memory: { current: memoryValues[memoryValues.length - 1], avg: memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length, max: Math.max(...memoryValues), - min: Math.min(...memoryValues) + min: Math.min(...memoryValues), }, dataPoints: history.length, - timeRange: hours + timeRange: hours, }; } @@ -352,7 +352,7 @@ class ResourceMonitor extends EventEmitter { name: data.name, current, aggregated, - alertConfig: this.alerts.get(containerId) + alertConfig: this.alerts.get(containerId), }; } @@ -370,7 +370,7 @@ class ResourceMonitor extends EventEmitter { diskIOThreshold: config.diskIOThreshold || null, cooldownMinutes: config.cooldownMinutes || 15, autoRestart: config.autoRestart || false, - notificationChannels: config.notificationChannels || [] + notificationChannels: config.notificationChannels || [], }); this.saveAlertConfig(); @@ -400,7 +400,7 @@ class ResourceMonitor extends EventEmitter { for (const [containerId, data] of this.stats.entries()) { data.history = data.history.filter(s => - new Date(s.timestamp).getTime() > cutoffTime + new Date(s.timestamp).getTime() > cutoffTime, ); // Remove container stats if no recent data @@ -471,7 +471,7 @@ class ResourceMonitor extends EventEmitter { return { stats: Object.fromEntries(this.stats), alerts: Object.fromEntries(this.alerts), - exportedAt: new Date().toISOString() + exportedAt: new Date().toISOString(), }; } diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index 8026771..fc2f651 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -62,7 +62,7 @@ module.exports = function(ctx, helpers) { ctx.log.info('deploy', 'DashCA: Using existing index.html'); } - ctx.log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath); + ctx.log.info('deploy', `DashCA: For full features, copy certificate files to ${ destPath}`); ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully'); } catch (error) { ctx.log.error('deploy', 'DashCA deployment error', { error: error.message }); @@ -121,14 +121,14 @@ module.exports = function(ctx, helpers) { PortBindings: {}, Binds: translatedVolumes, RestartPolicy: { Name: 'unless-stopped' }, - LogConfig: DOCKER.LOG_CONFIG + LogConfig: DOCKER.LOG_CONFIG, }, Env: Object.entries(processedTemplate.docker.environment || {}).map(([k, v]) => `${k}=${v}`), Labels: { 'sami.managed': 'true', 'sami.app': appId, 'sami.subdomain': userConfig.subdomain, - 'sami.deployed': new Date().toISOString() - } + 'sami.deployed': new Date().toISOString(), + }, }; processedTemplate.docker.ports.forEach(portMapping => { @@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) { try { const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); + ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); } } catch (pruneErr) { ctx.log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message }); @@ -324,7 +324,7 @@ module.exports = function(ctx, helpers) { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [], customVolumes: config.customVolumes || undefined, - useExisting: false + useExisting: false, }, container: template.isStaticSite ? null : { image: processedTemplate.docker.image, @@ -340,14 +340,14 @@ module.exports = function(ctx, helpers) { } return env; })(), - capabilities: processedTemplate.docker.capabilities || undefined + capabilities: processedTemplate.docker.capabilities || undefined, }, caddy: { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [], subpathSupport: template.subpathSupport || 'strip', - routingMode: ctx.siteConfig.routingMode - } + routingMode: ctx.siteConfig.routingMode, + }, }; await ctx.addServiceToConfig({ @@ -358,7 +358,7 @@ module.exports = function(ctx, helpers) { tailscaleOnly: config.tailscaleOnly || false, routingMode: ctx.siteConfig.routingMode, deployedAt: new Date().toISOString(), - deploymentManifest + deploymentManifest, }); ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); @@ -366,7 +366,7 @@ module.exports = function(ctx, helpers) { success: true, containerId, usedExisting, url: serviceUrl, message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`, - setupInstructions: template.setupInstructions || [] + setupInstructions: template.setupInstructions || [], }; if (dnsWarning) response.warning = dnsWarning; diff --git a/dashcaddy-api/routes/apps/helpers.js b/dashcaddy-api/routes/apps/helpers.js index 6e9d76b..d674bb3 100644 --- a/dashcaddy-api/routes/apps/helpers.js +++ b/dashcaddy-api/routes/apps/helpers.js @@ -38,16 +38,16 @@ module.exports = function(ctx) { const templateImage = template.docker.image.split(':')[0]; for (const container of containers) { const containerImage = container.Image.split(':')[0]; - if (containerImage === templateImage || containerImage.endsWith('/' + templateImage)) { + if (containerImage === templateImage || containerImage.endsWith(`/${ templateImage}`)) { const ports = container.Ports.filter(p => p.PublicPort).map(p => ({ - hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type + hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type, })); return { id: container.Id, shortId: container.Id.slice(0, 12), name: container.Names[0]?.replace(/^\//, '') || 'unknown', image: container.Image, status: container.Status, state: container.State, ports, primaryPort: ports.length > 0 ? ports[0].hostPort : null, - labels: container.Labels || {} + labels: container.Labels || {}, }; } } @@ -72,7 +72,7 @@ module.exports = function(ctx) { '{{PORT}}': config.port || template.defaultPort, '{{MEDIA_PATH}}': mediaPaths[0] || '/media', '{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC', - '{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex') + '{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex'), }; function replaceInObject(obj) { @@ -117,7 +117,7 @@ module.exports = function(ctx) { const basePath = `/${config.subdomain}`; // Some apps need the full URL, not just the path if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) { - processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/'; + processed.docker.environment[template.urlBaseEnv] = `${ctx.buildServiceUrl(config.subdomain) }/`; } else { processed.docker.environment[template.urlBaseEnv] = basePath; } @@ -137,7 +137,7 @@ module.exports = function(ctx) { config.mediaPath.split(',').map(p => p.trim()).filter(Boolean).forEach(p => allowedRoots.push(path.resolve(p))); } const isAllowed = allowedRoots.some(root => - normalizedHost === root || normalizedHost.startsWith(root + path.sep) + normalizedHost === root || normalizedHost.startsWith(root + path.sep), ); if (!isAllowed) { ctx.log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots }); @@ -162,76 +162,76 @@ module.exports = function(ctx) { c += ` root * ${sitePath}\n\n`; if (tailscaleOnly) { - c += ` @blocked not remote_ip 100.64.0.0/10\n`; - c += ` respond @blocked "Access denied. Tailscale connection required." 403\n\n`; + c += ' @blocked not remote_ip 100.64.0.0/10\n'; + c += ' respond @blocked "Access denied. Tailscale connection required." 403\n\n'; } if (apiProxy) { - c += ` handle /api/* {\n`; + c += ' handle /api/* {\n'; c += ` reverse_proxy ${apiProxy}\n`; - c += ` }\n\n`; + c += ' }\n\n'; } - c += ` @crt path *.crt\n`; - c += ` handle @crt {\n`; - c += ` header Content-Type application/x-x509-ca-cert\n`; - c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; - c += ` header Cache-Control "public, max-age=86400"\n`; - c += ` file_server\n`; - c += ` }\n\n`; - c += ` @der path *.der\n`; - c += ` handle @der {\n`; - c += ` header Content-Type application/x-x509-ca-cert\n`; - c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; - c += ` header Cache-Control "public, max-age=86400"\n`; - c += ` file_server\n`; - c += ` }\n\n`; - c += ` @mobileconfig path *.mobileconfig\n`; - c += ` handle @mobileconfig {\n`; - c += ` header Content-Type application/x-apple-aspen-config\n`; - c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; - c += ` header Cache-Control "public, max-age=86400"\n`; - c += ` file_server\n`; - c += ` }\n\n`; - c += ` @ps1 path *.ps1\n`; - c += ` handle @ps1 {\n`; - c += ` header Content-Type text/plain\n`; - c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; - c += ` file_server\n`; - c += ` }\n\n`; - c += ` @sh path *.sh\n`; - c += ` handle @sh {\n`; - c += ` header Content-Type text/x-shellscript\n`; - c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`; - c += ` file_server\n`; - c += ` }\n\n`; - c += ` # Static site with SPA fallback\n`; - c += ` handle {\n`; - c += ` @notFile not file {path}\n`; - c += ` rewrite @notFile /index.html\n`; - c += ` file_server\n`; - c += ` }\n\n`; - c += ` # No cache for HTML\n`; - c += ` @htmlfiles {\n`; - c += ` path *.html\n`; - c += ` path /\n`; - c += ` }\n`; - c += ` header @htmlfiles Cache-Control "no-store"\n`; + c += ' @crt path *.crt\n'; + c += ' handle @crt {\n'; + c += ' header Content-Type application/x-x509-ca-cert\n'; + c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; + c += ' header Cache-Control "public, max-age=86400"\n'; + c += ' file_server\n'; + c += ' }\n\n'; + c += ' @der path *.der\n'; + c += ' handle @der {\n'; + c += ' header Content-Type application/x-x509-ca-cert\n'; + c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; + c += ' header Cache-Control "public, max-age=86400"\n'; + c += ' file_server\n'; + c += ' }\n\n'; + c += ' @mobileconfig path *.mobileconfig\n'; + c += ' handle @mobileconfig {\n'; + c += ' header Content-Type application/x-apple-aspen-config\n'; + c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; + c += ' header Cache-Control "public, max-age=86400"\n'; + c += ' file_server\n'; + c += ' }\n\n'; + c += ' @ps1 path *.ps1\n'; + c += ' handle @ps1 {\n'; + c += ' header Content-Type text/plain\n'; + c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; + c += ' file_server\n'; + c += ' }\n\n'; + c += ' @sh path *.sh\n'; + c += ' handle @sh {\n'; + c += ' header Content-Type text/x-shellscript\n'; + c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n'; + c += ' file_server\n'; + c += ' }\n\n'; + c += ' # Static site with SPA fallback\n'; + c += ' handle {\n'; + c += ' @notFile not file {path}\n'; + c += ' rewrite @notFile /index.html\n'; + c += ' file_server\n'; + c += ' }\n\n'; + c += ' # No cache for HTML\n'; + c += ' @htmlfiles {\n'; + c += ' path *.html\n'; + c += ' path /\n'; + c += ' }\n'; + c += ' header @htmlfiles Cache-Control "no-store"\n'; return c; } // HTTPS block let config = `${domain} {\n`; - config += ` tls internal\n\n`; + config += ' tls internal\n\n'; config += siteBlockContent(); - config += `}`; + config += '}'; // HTTP companion block for devices that haven't trusted the CA yet if (httpAccess) { - config += `\n\n# HTTP access for first-time certificate installation\n`; + config += '\n\n# HTTP access for first-time certificate installation\n'; config += `http://${domain} {\n`; config += siteBlockContent(); - config += `}`; + config += '}'; } return config; @@ -254,7 +254,7 @@ module.exports = function(ctx) { } else if (healthPath && port && httpCheckFailed < 5) { try { const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, { - signal: AbortSignal.timeout(3000), redirect: 'manual' + signal: AbortSignal.timeout(3000), redirect: 'manual', }); if (response.ok || (response.status >= 300 && response.status < 400)) { ctx.log.info('docker', 'Health check passed', { containerId, status: response.status }); @@ -290,7 +290,7 @@ module.exports = function(ctx) { await ctx.caddy.reload(existing); return; } - const result = await ctx.caddy.modify(c => c + `\n${config}\n`); + const result = await ctx.caddy.modify(c => `${c }\n${config}\n`); if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`); await ctx.caddy.verifySite(domain); } @@ -405,6 +405,6 @@ module.exports = function(ctx) { removeSubpathConfig, ensureMainDomainBlock, RESERVED_SUBPATHS, - generateStaticSiteConfig + generateStaticSiteConfig, }; }; diff --git a/dashcaddy-api/routes/apps/removal.js b/dashcaddy-api/routes/apps/removal.js index 2e14356..1045403 100644 --- a/dashcaddy-api/routes/apps/removal.js +++ b/dashcaddy-api/routes/apps/removal.js @@ -26,7 +26,7 @@ module.exports = function(ctx, helpers) { try { const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); + ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); } } catch (pruneErr) { ctx.log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message }); @@ -42,7 +42,7 @@ module.exports = function(ctx, helpers) { try { const domain = ctx.buildDomain(subdomain); const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', { - token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true' + token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true', }); let recordIp = ip || 'localhost'; if (getResult.status === 'ok' && getResult.response?.records) { @@ -50,7 +50,7 @@ module.exports = function(ctx, helpers) { if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress; } const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { - token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp + token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp, }); results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed'); ctx.log.info('dns', 'DNS record removal', { result: results.dns }); diff --git a/dashcaddy-api/routes/apps/restore.js b/dashcaddy-api/routes/apps/restore.js index 91158a0..a9fd416 100644 --- a/dashcaddy-api/routes/apps/restore.js +++ b/dashcaddy-api/routes/apps/restore.js @@ -37,7 +37,7 @@ module.exports = function(ctx, helpers) { return res.json({ success: true, message: 'No services have deployment manifests to restore', - results: [] + results: [], }); } @@ -51,7 +51,7 @@ module.exports = function(ctx, helpers) { id: service.id, name: service.name, status: 'failed', - error: error.message + error: error.message, }); } } @@ -63,7 +63,7 @@ module.exports = function(ctx, helpers) { res.json({ success: true, message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`, - results + results, }); }, 'apps-restore-all')); @@ -81,7 +81,7 @@ module.exports = function(ctx, helpers) { hasManifest: !!service.deploymentManifest, templateId: service.deploymentManifest?.templateId || service.appTemplate || null, deployedAt: service.deployedAt || null, - containerRunning: false + containerRunning: false, }; // Check if container is currently running @@ -125,7 +125,7 @@ module.exports = function(ctx, helpers) { name: service.name, status: 'restored', type: 'static', - message: `Static site "${service.name}" config preserved` + message: `Static site "${service.name}" config preserved`, }; } @@ -140,7 +140,7 @@ module.exports = function(ctx, helpers) { id: service.id, name: service.name, status: 'skipped', - message: 'Container already running' + message: 'Container already running', }; } } catch (e) { @@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) { id: service.id, name: service.name, status: 'skipped', - message: 'Container already running (found by name)' + message: 'Container already running (found by name)', }; } // Exists but not running — remove stale container @@ -178,7 +178,7 @@ module.exports = function(ctx, helpers) { id: service.id, name: service.name, status: 'failed', - error: 'No container configuration in manifest' + error: 'No container configuration in manifest', }; } @@ -189,7 +189,7 @@ module.exports = function(ctx, helpers) { } catch (e) { // Check if image exists locally const images = await ctx.docker.client.listImages({ - filters: { reference: [manifest.container.image] } + filters: { reference: [manifest.container.image] }, }); if (images.length === 0) { throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`); @@ -206,7 +206,7 @@ module.exports = function(ctx, helpers) { PortBindings: {}, Binds: manifest.container.volumes || [], RestartPolicy: { Name: 'unless-stopped' }, - LogConfig: DOCKER.LOG_CONFIG + LogConfig: DOCKER.LOG_CONFIG, }, Env: Object.entries(manifest.container.environment || {}).map(([k, v]) => `${k}=${v}`), Labels: { @@ -214,8 +214,8 @@ module.exports = function(ctx, helpers) { 'sami.app': manifest.templateId, 'sami.subdomain': manifest.config.subdomain, 'sami.deployed': new Date().toISOString(), - 'sami.restored': 'true' - } + 'sami.restored': 'true', + }, }; // Set up port bindings @@ -287,7 +287,7 @@ module.exports = function(ctx, helpers) { status: 'restored', type: 'container', containerId: container.id, - message: `${service.name} restored successfully` + message: `${service.name} restored successfully`, }; } diff --git a/dashcaddy-api/routes/apps/templates.js b/dashcaddy-api/routes/apps/templates.js index d1cef07..796cb3d 100644 --- a/dashcaddy-api/routes/apps/templates.js +++ b/dashcaddy-api/routes/apps/templates.js @@ -11,7 +11,7 @@ module.exports = function(ctx, helpers) { success: true, templates: ctx.APP_TEMPLATES, categories: ctx.TEMPLATE_CATEGORIES, - difficultyLevels: ctx.DIFFICULTY_LEVELS + difficultyLevels: ctx.DIFFICULTY_LEVELS, }); }, 'apps-templates')); @@ -71,7 +71,7 @@ module.exports = function(ctx, helpers) { try { const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain); const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { - token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost' + token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost', }); results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage; ctx.log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); @@ -139,7 +139,7 @@ module.exports = function(ctx, helpers) { success: true, message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`, newUrl: `https://${ctx.buildDomain(newSubdomain)}`, - results + results, }); }, 'update-subdomain')); diff --git a/dashcaddy-api/routes/arr/config.js b/dashcaddy-api/routes/arr/config.js index e3a8723..31b3ca1 100644 --- a/dashcaddy-api/routes/arr/config.js +++ b/dashcaddy-api/routes/arr/config.js @@ -11,12 +11,12 @@ module.exports = function(ctx, helpers) { const results = { radarr: null, sonarr: null }; // Step 1: Authenticate with Overseerr via Plex token - let overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`; + const overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`; const overseerrSession = await helpers.getOverseerrSession(); if (!overseerrSession) { return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', { - hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.' + hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.', }); } @@ -30,8 +30,8 @@ module.exports = function(ctx, helpers) { headers: { 'Content-Type': 'application/json', 'Cookie': overseerrSession.cookie, - ...options.headers - } + ...options.headers, + }, }); return response; }; @@ -41,12 +41,12 @@ module.exports = function(ctx, helpers) { const statusRes = await overseerrFetch('/api/v1/status'); if (!statusRes.ok) { return ctx.errorResponse(res, 502, 'Cannot connect to Overseerr', { - hint: 'Make sure Overseerr is running on port 5055' + hint: 'Make sure Overseerr is running on port 5055', }); } } catch (e) { return ctx.errorResponse(res, 502, `Cannot reach Overseerr: ${e.message}`, { - hint: 'Check if Overseerr container is running' + hint: 'Check if Overseerr container is running', }); } @@ -59,14 +59,14 @@ module.exports = function(ctx, helpers) { // Fetch quality profiles from Radarr const profilesRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/qualityprofile`, { - headers: { 'X-Api-Key': radarr.apiKey } + headers: { 'X-Api-Key': radarr.apiKey }, }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders from Radarr const rootFoldersRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, { - headers: { 'X-Api-Key': radarr.apiKey } + headers: { 'X-Api-Key': radarr.apiKey }, }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/movies'; @@ -87,12 +87,12 @@ module.exports = function(ctx, helpers) { minimumAvailability: 'released', isDefault: true, externalUrl: radarr.url, - tags: [] + tags: [], }; const radarrRes = await overseerrFetch('/api/v1/settings/radarr', { method: 'POST', - body: JSON.stringify(radarrConfig) + body: JSON.stringify(radarrConfig), }); if (radarrRes.ok) { @@ -115,14 +115,14 @@ module.exports = function(ctx, helpers) { // Fetch quality profiles from Sonarr const profilesRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/qualityprofile`, { - headers: { 'X-Api-Key': sonarr.apiKey } + headers: { 'X-Api-Key': sonarr.apiKey }, }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders from Sonarr const rootFoldersRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/rootfolder`, { - headers: { 'X-Api-Key': sonarr.apiKey } + headers: { 'X-Api-Key': sonarr.apiKey }, }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; @@ -131,7 +131,7 @@ module.exports = function(ctx, helpers) { let languageProfileId = 1; try { const langRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, { - headers: { 'X-Api-Key': sonarr.apiKey } + headers: { 'X-Api-Key': sonarr.apiKey }, }); if (langRes.ok) { const langProfiles = await langRes.json(); @@ -158,12 +158,12 @@ module.exports = function(ctx, helpers) { isDefault: true, enableSeasonFolders: true, externalUrl: sonarr.url, - tags: [] + tags: [], }; const sonarrRes = await overseerrFetch('/api/v1/settings/sonarr', { method: 'POST', - body: JSON.stringify(sonarrConfig) + body: JSON.stringify(sonarrConfig), }); if (sonarrRes.ok) { @@ -182,7 +182,7 @@ module.exports = function(ctx, helpers) { res.json({ success: anyConfigured, message: anyConfigured ? 'Services configured in Overseerr' : 'Configuration failed', - results + results, }); }, 'arr-configure-overseerr')); @@ -210,7 +210,7 @@ module.exports = function(ctx, helpers) { } // Normalize URL - remove trailing slash - let baseUrl = url.replace(/\/+$/, ''); + const baseUrl = url.replace(/\/+$/, ''); // Build the API endpoint let apiEndpoint; @@ -233,7 +233,7 @@ module.exports = function(ctx, helpers) { const response = await ctx.fetchT(apiEndpoint, { method: 'GET', headers, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); if (response.ok) { @@ -244,7 +244,7 @@ module.exports = function(ctx, helpers) { return res.json({ success: true, version, - appName + appName, }); } else if (response.status === 401) { return ctx.errorResponse(res, 401, 'Invalid API key'); @@ -288,7 +288,7 @@ module.exports = function(ctx, helpers) { containerName: container.Names[0]?.replace(/^\//, ''), port: exposedPort, url: `http://host.docker.internal:${exposedPort}`, - localUrl: `http://localhost:${exposedPort}` + localUrl: `http://localhost:${exposedPort}`, }; // Extract API key for arr services @@ -305,7 +305,7 @@ module.exports = function(ctx, helpers) { radarrFound: !!detected.radarr?.apiKey, sonarrFound: !!detected.sonarr?.apiKey, lidarrFound: !!detected.lidarr?.apiKey, - prowlarrFound: !!detected.prowlarr?.apiKey + prowlarrFound: !!detected.prowlarr?.apiKey, }; ctx.log.info('arr', 'Detected services', summary); @@ -313,14 +313,14 @@ module.exports = function(ctx, helpers) { if (!summary.overseerrFound) { return ctx.errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', { detected, - summary + summary, }); } if (!summary.radarrFound && !summary.sonarrFound) { return ctx.errorResponse(res, 400, 'No Radarr or Sonarr found with valid API keys. Deploy at least one first.', { detected, - summary + summary, }); } @@ -331,7 +331,7 @@ module.exports = function(ctx, helpers) { return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', { setupUrl: detected.overseerr.localUrl, detected, - summary + summary, }); } @@ -344,8 +344,8 @@ module.exports = function(ctx, helpers) { headers: { 'Content-Type': 'application/json', 'Cookie': overseerrSession.cookie, - ...options.headers - } + ...options.headers, + }, }); }; @@ -356,14 +356,14 @@ module.exports = function(ctx, helpers) { try { // Fetch quality profiles from Radarr const profilesRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/qualityprofile`, { - headers: { 'X-Api-Key': detected.radarr.apiKey } + headers: { 'X-Api-Key': detected.radarr.apiKey }, }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders from Radarr const rootFoldersRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/rootfolder`, { - headers: { 'X-Api-Key': detected.radarr.apiKey } + headers: { 'X-Api-Key': detected.radarr.apiKey }, }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/movies'; @@ -384,12 +384,12 @@ module.exports = function(ctx, helpers) { minimumAvailability: 'released', isDefault: true, externalUrl: detected.radarr.localUrl, - tags: [] + tags: [], }; const resp = await overseerrFetch('/api/v1/settings/radarr', { method: 'POST', - body: JSON.stringify(radarrConfig) + body: JSON.stringify(radarrConfig), }); configResults.radarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`; @@ -403,14 +403,14 @@ module.exports = function(ctx, helpers) { try { // Fetch quality profiles from Sonarr const profilesRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/qualityprofile`, { - headers: { 'X-Api-Key': detected.sonarr.apiKey } + headers: { 'X-Api-Key': detected.sonarr.apiKey }, }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders from Sonarr const rootFoldersRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/rootfolder`, { - headers: { 'X-Api-Key': detected.sonarr.apiKey } + headers: { 'X-Api-Key': detected.sonarr.apiKey }, }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; @@ -419,7 +419,7 @@ module.exports = function(ctx, helpers) { let languageProfileId = 1; try { const langRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, { - headers: { 'X-Api-Key': detected.sonarr.apiKey } + headers: { 'X-Api-Key': detected.sonarr.apiKey }, }); if (langRes.ok) { const langProfiles = await langRes.json(); @@ -444,12 +444,12 @@ module.exports = function(ctx, helpers) { isDefault: true, enableSeasonFolders: true, externalUrl: detected.sonarr.localUrl, - tags: [] + tags: [], }; const resp = await overseerrFetch('/api/v1/settings/sonarr', { method: 'POST', - body: JSON.stringify(sonarrConfig) + body: JSON.stringify(sonarrConfig), }); configResults.sonarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`; @@ -466,7 +466,7 @@ module.exports = function(ctx, helpers) { 'deploymentSuccess', 'Arr Stack Auto-Connected', `Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`, - 'success' + 'success', ); } @@ -475,7 +475,7 @@ module.exports = function(ctx, helpers) { message: anyConfigured ? 'Auto-setup completed successfully!' : 'Configuration failed', detected, configResults, - summary + summary, }); }, 'arr-auto-setup')); diff --git a/dashcaddy-api/routes/arr/credentials.js b/dashcaddy-api/routes/arr/credentials.js index 6d52f9b..f652e1b 100644 --- a/dashcaddy-api/routes/arr/credentials.js +++ b/dashcaddy-api/routes/arr/credentials.js @@ -39,7 +39,7 @@ module.exports = function(ctx, helpers) { service, source: url ? 'external' : 'local', url: url || null, - storedAt: new Date().toISOString() + storedAt: new Date().toISOString(), }; // Test connection if URL is known @@ -77,7 +77,7 @@ module.exports = function(ctx, helpers) { return ctx.errorResponse(res, 400, 'Invalid seedbox base URL'); } await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, { - storedAt: new Date().toISOString() + storedAt: new Date().toISOString(), }); } @@ -87,7 +87,7 @@ module.exports = function(ctx, helpers) { success: true, message: `${service} API key stored`, connectionTest, - url: resolvedUrl + url: resolvedUrl, }); }, 'arr-credentials-store')); @@ -106,7 +106,7 @@ module.exports = function(ctx, helpers) { url: metadata?.url || null, lastVerified: metadata?.lastVerified || null, version: metadata?.version || null, - source: metadata?.source || null + source: metadata?.source || null, }; } diff --git a/dashcaddy-api/routes/arr/detect.js b/dashcaddy-api/routes/arr/detect.js index 5af17ce..d0c70a8 100644 --- a/dashcaddy-api/routes/arr/detect.js +++ b/dashcaddy-api/routes/arr/detect.js @@ -13,7 +13,7 @@ module.exports = function(ctx, helpers) { sonarr: null, overseerr: null, lidarr: null, - prowlarr: null + prowlarr: null, }; // Service detection patterns @@ -35,7 +35,7 @@ module.exports = function(ctx, helpers) { image: container.Image, port: exposedPort, status: container.State, - url: helpers.getServiceUrl(containerName, exposedPort) + url: helpers.getServiceUrl(containerName, exposedPort), }; // Get API key for arr services (not Plex or Overseerr) @@ -58,8 +58,8 @@ module.exports = function(ctx, helpers) { plexReady: !!(detected.plex?.token), radarrReady: !!(detected.radarr?.apiKey), sonarrReady: !!(detected.sonarr?.apiKey), - overseerrRunning: !!detected.overseerr - } + overseerrRunning: !!detected.overseerr, + }, }); }, 'arr-detect')); @@ -86,7 +86,7 @@ module.exports = function(ctx, helpers) { containerId: container.Id, containerName: container.Names[0]?.replace(/^\//, ''), port: portInfo?.PublicPort || config.port, - status: container.State + status: container.State, }; } } @@ -122,7 +122,7 @@ module.exports = function(ctx, helpers) { hasToken: false, containerId: null, containerName: null, - version: null + version: null, }; // Check Docker first @@ -143,7 +143,7 @@ module.exports = function(ctx, helpers) { // Store for later use await ctx.credentialManager.store('arr.plex.token', token, { service: 'plex', source: 'local', url: entry.url, - lastVerified: new Date().toISOString() + lastVerified: new Date().toISOString(), }); } else { entry.status = 'needs_key'; @@ -160,7 +160,7 @@ module.exports = function(ctx, helpers) { try { const radarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, { headers: { 'Cookie': session.cookie }, - signal: AbortSignal.timeout(5000) + signal: AbortSignal.timeout(5000), }); if (radarrCheck.ok) { const radarrSettings = await radarrCheck.json(); @@ -170,7 +170,7 @@ module.exports = function(ctx, helpers) { try { const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, { headers: { 'Cookie': session.cookie }, - signal: AbortSignal.timeout(5000) + signal: AbortSignal.timeout(5000), }); if (sonarrCheck.ok) { const sonarrSettings = await sonarrCheck.json(); @@ -180,7 +180,7 @@ module.exports = function(ctx, helpers) { try { const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, { headers: { 'Cookie': session.cookie }, - signal: AbortSignal.timeout(5000) + signal: AbortSignal.timeout(5000), }); if (plexCheck.ok) { const plexSettings = await plexCheck.json(); @@ -273,7 +273,7 @@ module.exports = function(ctx, helpers) { fullyConnected: statuses.filter(s => s.status === 'connected').length, needsApiKey: statuses.filter(s => s.status === 'needs_key').length, errors: statuses.filter(s => s.status === 'error').length, - readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2 + readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2, }; res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary }); diff --git a/dashcaddy-api/routes/arr/helpers.js b/dashcaddy-api/routes/arr/helpers.js index 2936f51..93c3468 100644 --- a/dashcaddy-api/routes/arr/helpers.js +++ b/dashcaddy-api/routes/arr/helpers.js @@ -12,7 +12,7 @@ module.exports = function(ctx) { const exec = await dockerContainer.exec({ Cmd: ['cat', '/config/config.xml'], AttachStdout: true, - AttachStderr: true + AttachStderr: true, }); const stream = await exec.start(); @@ -38,7 +38,7 @@ module.exports = function(ctx) { try { const containers = await ctx.docker.client.listContainers({ all: false }); const container = containers.find(c => - c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')) + c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')), ); if (!container) return null; @@ -47,7 +47,7 @@ module.exports = function(ctx) { const exec = await dockerContainer.exec({ Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'], AttachStdout: true, - AttachStderr: true + AttachStderr: true, }); const stream = await exec.start(); @@ -97,7 +97,7 @@ module.exports = function(ctx) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ authToken: plexToken }), - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); if (!authRes.ok) { @@ -125,7 +125,7 @@ module.exports = function(ctx) { // 1. Get Plex server identity (for return info) const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); if (!identityRes.ok) throw new Error('Cannot reach Plex server'); const identity = await identityRes.json(); @@ -136,16 +136,16 @@ module.exports = function(ctx) { const plexConfig = { ip: 'host.docker.internal', port: APP_PORTS.plex, - useSsl: false + useSsl: false, }; const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Cookie': sessionCookie + 'Cookie': sessionCookie, }, - body: JSON.stringify(plexConfig) + body: JSON.stringify(plexConfig), }); if (!configRes.ok) { @@ -157,7 +157,7 @@ module.exports = function(ctx) { await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, { method: 'POST', headers: { 'Cookie': sessionCookie }, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); } catch (e) { ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message }); @@ -168,7 +168,7 @@ module.exports = function(ctx) { try { const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { headers: { 'Cookie': sessionCookie }, - signal: AbortSignal.timeout(5000) + signal: AbortSignal.timeout(5000), }); if (libRes.ok) { const plexSettings = await libRes.json(); @@ -188,7 +188,7 @@ module.exports = function(ctx) { try { const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { headers: { 'X-Api-Key': prowlarrApiKey }, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); existingApps = existingRes.ok ? await existingRes.json() : []; } catch (e) { @@ -217,8 +217,8 @@ module.exports = function(ctx) { { name: 'prowlarrUrl', value: prowlarrUrl }, { name: 'baseUrl', value: config.url }, { name: 'apiKey', value: config.apiKey }, - { name: 'syncCategories', value: syncCategories } - ] + { name: 'syncCategories', value: syncCategories }, + ], }; try { @@ -226,10 +226,10 @@ module.exports = function(ctx) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Api-Key': prowlarrApiKey + 'X-Api-Key': prowlarrApiKey, }, body: JSON.stringify(payload), - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`; } catch (e) { @@ -262,7 +262,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(apiEndpoint, { method: 'GET', headers, - signal: AbortSignal.timeout(15000) + signal: AbortSignal.timeout(15000), }); if (response.ok) { @@ -297,6 +297,6 @@ module.exports = function(ctx) { getOverseerrApiKey, connectPlexToOverseerr, configureProwlarrApps, - testServiceConnection + testServiceConnection, }; }; diff --git a/dashcaddy-api/routes/arr/plex.js b/dashcaddy-api/routes/arr/plex.js index d351d23..92b99e4 100644 --- a/dashcaddy-api/routes/arr/plex.js +++ b/dashcaddy-api/routes/arr/plex.js @@ -14,7 +14,7 @@ module.exports = function(ctx, helpers) { if (!plexToken) { return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', { - hint: 'Deploy Plex with a claim token or manually configure it.' + hint: 'Deploy Plex with a claim token or manually configure it.', }); } @@ -32,7 +32,7 @@ module.exports = function(ctx, helpers) { // Fetch libraries const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, { headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); if (!libRes.ok) { @@ -45,7 +45,7 @@ module.exports = function(ctx, helpers) { title: dir.title, type: dir.type, count: parseInt(dir.count) || 0, - scannedAt: dir.scannedAt + scannedAt: dir.scannedAt, })); // Get server name @@ -54,7 +54,7 @@ module.exports = function(ctx, helpers) { try { const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(5000) + signal: AbortSignal.timeout(5000), }); if (identityRes.ok) { const identity = await identityRes.json(); @@ -66,7 +66,7 @@ module.exports = function(ctx, helpers) { // Store token for future use await ctx.credentialManager.store('arr.plex.token', plexToken, { service: 'plex', source: 'local', url: plexUrl, - lastVerified: new Date().toISOString() + lastVerified: new Date().toISOString(), }); res.json({ success: true, serverName, version, libraries }); diff --git a/dashcaddy-api/routes/arr/smart-connect.js b/dashcaddy-api/routes/arr/smart-connect.js index ce6b3fb..9b8b933 100644 --- a/dashcaddy-api/routes/arr/smart-connect.js +++ b/dashcaddy-api/routes/arr/smart-connect.js @@ -44,7 +44,7 @@ module.exports = function(ctx, helpers) { steps.push({ step: `Test ${svc.charAt(0).toUpperCase() + svc.slice(1)} connection`, status: test.success ? 'success' : 'failed', - details: test.success ? `v${test.version}` : test.error + details: test.success ? `v${test.version}` : test.error, }); if (test.success) { @@ -55,12 +55,12 @@ module.exports = function(ctx, helpers) { const stored = await ctx.credentialManager.store(`arr.${svc}.apikey`, apiKey, { service: svc, source: 'external', url, lastVerified: new Date().toISOString(), - version: test.version + version: test.version, }); steps.push({ step: `Save ${svc} credentials`, status: stored ? 'success' : 'failed', - details: stored ? 'Encrypted and saved' : 'Storage failed' + details: stored ? 'Encrypted and saved' : 'Storage failed', }); } } @@ -94,7 +94,7 @@ module.exports = function(ctx, helpers) { steps.push({ step: 'Get Overseerr API key', status: 'failed', - details: 'Could not authenticate with Overseerr (Plex not running or not linked)' + details: 'Could not authenticate with Overseerr (Plex not running or not linked)', }); } else { steps.push({ step: 'Get Overseerr API key', status: 'success', details: 'Extracted from container' }); @@ -110,7 +110,7 @@ module.exports = function(ctx, helpers) { // Fetch quality profiles const profilesRes = await ctx.fetchT(`${radarrUrl}/api/v3/qualityprofile`, { headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; @@ -118,7 +118,7 @@ module.exports = function(ctx, helpers) { // Fetch root folders const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, { headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/movies'; @@ -141,20 +141,20 @@ module.exports = function(ctx, helpers) { minimumAvailability: 'released', isDefault: true, externalUrl: connectedServices.radarr.url, - tags: [] + tags: [], }; const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, body: JSON.stringify(radarrConfig), - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); steps.push({ step: 'Configure Radarr in Overseerr', status: radarrRes.ok ? 'success' : 'failed', - details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text() + details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text(), }); } catch (e) { steps.push({ step: 'Configure Radarr in Overseerr', status: 'failed', details: e.message }); @@ -170,14 +170,14 @@ module.exports = function(ctx, helpers) { const profilesRes = await ctx.fetchT(`${sonarrUrl}/api/v3/qualityprofile`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; @@ -186,7 +186,7 @@ module.exports = function(ctx, helpers) { try { const langRes = await ctx.fetchT(`${sonarrUrl}/api/v3/languageprofile`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, - signal: AbortSignal.timeout(5000) + signal: AbortSignal.timeout(5000), }); if (langRes.ok) { const langProfiles = await langRes.json(); @@ -212,20 +212,20 @@ module.exports = function(ctx, helpers) { isDefault: true, enableSeasonFolders: true, externalUrl: connectedServices.sonarr.url, - tags: [] + tags: [], }; const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, body: JSON.stringify(sonarrConfig), - signal: AbortSignal.timeout(10000) + signal: AbortSignal.timeout(10000), }); steps.push({ step: 'Configure Sonarr in Overseerr', status: sonarrRes.ok ? 'success' : 'failed', - details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text() + details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text(), }); } catch (e) { steps.push({ step: 'Configure Sonarr in Overseerr', status: 'failed', details: e.message }); @@ -239,7 +239,7 @@ module.exports = function(ctx, helpers) { steps.push({ step: 'Connect Plex to Overseerr', status: 'success', - details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced` + details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced`, }); } catch (e) { steps.push({ step: 'Connect Plex to Overseerr', status: 'failed', details: e.message }); @@ -259,13 +259,13 @@ module.exports = function(ctx, helpers) { const prowlarrResults = await helpers.configureProwlarrApps( connectedServices.prowlarr.url.replace(/\/+$/, ''), connectedServices.prowlarr.apiKey, - appsToConnect + appsToConnect, ); for (const [app, status] of Object.entries(prowlarrResults)) { steps.push({ step: `Add ${app.charAt(0).toUpperCase() + app.slice(1)} to Prowlarr`, status: status === 'configured' || status === 'already_configured' ? 'success' : 'failed', - details: status + details: status, }); } } catch (e) { @@ -283,14 +283,14 @@ module.exports = function(ctx, helpers) { 'deploymentSuccess', 'Smart Arr Connect Complete', `${succeeded}/${steps.length} steps completed successfully`, - 'success' + 'success', ); } res.json({ success: succeeded > 0, steps, - summary: { totalSteps: steps.length, succeeded, failed } + summary: { totalSteps: steps.length, succeeded, failed }, }); }, 'smart-connect')); diff --git a/dashcaddy-api/routes/auth/keys.js b/dashcaddy-api/routes/auth/keys.js index d1fa933..0d91352 100644 --- a/dashcaddy-api/routes/auth/keys.js +++ b/dashcaddy-api/routes/auth/keys.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, - y: 365 * 24 * 60 * 60 * 1000 + y: 365 * 24 * 60 * 60 * 1000, }; return value * (multipliers[unit] || multipliers.h); @@ -54,7 +54,7 @@ module.exports = function(ctx) { const keyData = await ctx.authManager.generateAPIKey( name.trim(), - scopes || ['read', 'write'] + scopes || ['read', 'write'], ); res.json({ @@ -64,7 +64,7 @@ module.exports = function(ctx) { name: keyData.name, scopes: keyData.scopes, createdAt: keyData.createdAt, - warning: 'Save this key securely - it will not be shown again' + warning: 'Save this key securely - it will not be shown again', }); }, 'auth-keys-generate')); @@ -109,9 +109,9 @@ module.exports = function(ctx) { const token = await ctx.authManager.generateJWT( { sub: userId || 'dashcaddy-admin', - scope: ['admin'] // Session-generated JWTs have admin scope + scope: ['admin'], // Session-generated JWTs have admin scope }, - expiresIn || '24h' + expiresIn || '24h', ); // Calculate expiration timestamp @@ -122,7 +122,7 @@ module.exports = function(ctx) { success: true, token, expiresAt, - usage: 'Include in Authorization header as: Bearer ' + usage: 'Include in Authorization header as: Bearer ', }); }, 'auth-jwt-generate')); diff --git a/dashcaddy-api/routes/auth/session-handlers.js b/dashcaddy-api/routes/auth/session-handlers.js index 534b55b..aaca704 100644 --- a/dashcaddy-api/routes/auth/session-handlers.js +++ b/dashcaddy-api/routes/auth/session-handlers.js @@ -29,7 +29,7 @@ module.exports = function(ctx) { const { spawnSync } = require('child_process'); const proc = spawnSync('wget', [ '-q', '-S', `--post-data=${routerBody}`, '-O', '/dev/null', - `${baseUrl}/cgi-bin/login.ha` + `${baseUrl}/cgi-bin/login.ha`, ], { timeout: 5000, encoding: 'utf8' }); const result = (proc.stderr || '').split('\n').slice(0, 2).join('\n'); const locationMatch = result.match(/Location:\s*(.+)/); diff --git a/dashcaddy-api/routes/auth/totp.js b/dashcaddy-api/routes/auth/totp.js index d899577..a593c8a 100644 --- a/dashcaddy-api/routes/auth/totp.js +++ b/dashcaddy-api/routes/auth/totp.js @@ -10,8 +10,8 @@ module.exports = function(ctx) { config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, - isSetUp: ctx.totpConfig.isSetUp - } + isSetUp: ctx.totpConfig.isSetUp, + }, }); }, 'totp-config-get')); @@ -35,7 +35,7 @@ module.exports = function(ctx) { const otpauth = authenticator.keyuri('user', 'DashCaddy', secret); const qrDataUrl = await QRCode.toDataURL(otpauth, { width: 256, margin: 2, - color: { dark: '#ffffff', light: '#00000000' } + color: { dark: '#ffffff', light: '#00000000' }, }); res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret }); @@ -166,7 +166,7 @@ module.exports = function(ctx) { if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) { return ctx.errorResponse(res, 400, 'Invalid session duration', { - validOptions: Object.keys(ctx.session.durations) + validOptions: Object.keys(ctx.session.durations), }); } @@ -180,7 +180,7 @@ module.exports = function(ctx) { await ctx.saveTotpConfig(); res.json({ success: true, - config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp } + config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }, }); }, 'totp-config')); diff --git a/dashcaddy-api/routes/browse.js b/dashcaddy-api/routes/browse.js index 8223b0d..49a911f 100644 --- a/dashcaddy-api/routes/browse.js +++ b/dashcaddy-api/routes/browse.js @@ -24,7 +24,7 @@ module.exports = function(ctx) { const allRoots = BROWSE_ROOTS.map(r => ({ name: r.hostPath, path: r.hostPath, - containerPath: r.containerPath + containerPath: r.containerPath, })); const roots = []; @@ -45,7 +45,7 @@ module.exports = function(ctx) { const allRoots = BROWSE_ROOTS.map(r => ({ name: r.hostPath, path: r.hostPath, - type: 'drive' + type: 'drive', })); const roots = []; for (const r of allRoots) { @@ -58,12 +58,12 @@ module.exports = function(ctx) { } const matchingRoot = BROWSE_ROOTS.find(r => - requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, '') + requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, ''), ); if (!matchingRoot) { return ctx.errorResponse(res, 400, 'Path not in browseable roots', { - availableRoots: BROWSE_ROOTS.map(r => r.hostPath) + availableRoots: BROWSE_ROOTS.map(r => r.hostPath), }); } @@ -80,7 +80,7 @@ module.exports = function(ctx) { requestedPath, containerFullPath, allowedRoots, error: error.message, ip: req.ip, - userAgent: req.get('user-agent') + userAgent: req.get('user-agent'), }); return ctx.errorResponse(res, 403, 'Access denied - path traversal detected'); } @@ -108,7 +108,7 @@ module.exports = function(ctx) { .map(entry => ({ name: entry.name, path: path.join(requestedPath, entry.name).replace(/\\/g, '/'), - type: 'folder' + type: 'folder', })) .sort((a, b) => a.name.localeCompare(b.name)); @@ -119,7 +119,7 @@ module.exports = function(ctx) { path: requestedPath, parent: path.dirname(requestedPath).replace(/\\/g, '/') || null, items: result.data, - ...(result.pagination && { pagination: result.pagination }) + ...(result.pagination && { pagination: result.pagination }), }); }, 'browse-dir')); @@ -128,12 +128,12 @@ module.exports = function(ctx) { const mediaServerPatterns = [ 'plex', 'jellyfin', 'emby', 'kodi', 'navidrome', 'airsonic', 'subsonic', 'funkwhale', 'beets', 'lidarr', 'sonarr', 'radarr', - 'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli' + 'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli', ]; const excludePatterns = [ '/config', '/cache', '/transcode', '/data/config', '/app', - '/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile' + '/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile', ]; const containers = await ctx.docker.client.listContainers({ all: false }); @@ -155,7 +155,7 @@ module.exports = function(ctx) { let hostPath, containerPath; if (parts[0].length === 1 && /[A-Za-z]/.test(parts[0])) { - hostPath = parts[0] + ':' + parts[1]; + hostPath = `${parts[0] }:${ parts[1]}`; containerPath = parts[2] || ''; } else { hostPath = parts[0]; @@ -164,7 +164,7 @@ module.exports = function(ctx) { const isExcluded = excludePatterns.some(p => containerPath.toLowerCase().includes(p.toLowerCase()) || - hostPath.toLowerCase().includes(p.toLowerCase()) + hostPath.toLowerCase().includes(p.toLowerCase()), ); if (isExcluded) continue; if (seenPaths.has(hostPath)) continue; @@ -175,7 +175,7 @@ module.exports = function(ctx) { detectedMounts.push({ hostPath, containerPath, folderName, sourceContainer: containerInfo.Names[0]?.replace('/', '') || containerInfo.Id.slice(0, 12), - sourceImage: containerInfo.Image.split('/').pop().split(':')[0] + sourceImage: containerInfo.Image.split('/').pop().split(':')[0], }); } } @@ -185,7 +185,7 @@ module.exports = function(ctx) { mounts: detectedMounts, message: detectedMounts.length > 0 ? `Found ${detectedMounts.length} media mount(s) from existing containers` - : 'No existing media mounts detected' + : 'No existing media mounts detected', }); }, 'detect-media-mounts')); diff --git a/dashcaddy-api/routes/ca.js b/dashcaddy-api/routes/ca.js index 0597c35..987e1f0 100644 --- a/dashcaddy-api/routes/ca.js +++ b/dashcaddy-api/routes/ca.js @@ -25,22 +25,22 @@ module.exports = function(ctx) { } const certInfo = JSON.parse(await fsp.readFile(certInfoFile, 'utf8')); - const expirationDate = new Date(certInfo.validUntil); - const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); + const expirationDate = new Date(certInfo.validUntil); + const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); - res.json({ - success: true, - certificate: { - name: certInfo.name, - fingerprint: certInfo.fingerprint, - validFrom: certInfo.validFrom, - validUntil: certInfo.validUntil, - daysUntilExpiration, - algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256', - serialNumber: certInfo.serialNumber, - downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt` - } - }); + res.json({ + success: true, + certificate: { + name: certInfo.name, + fingerprint: certInfo.fingerprint, + validFrom: certInfo.validFrom, + validUntil: certInfo.validUntil, + daysUntilExpiration, + algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256', + serialNumber: certInfo.serialNumber, + downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt`, + }, + }); }, 'ca-info')); // Serve root CA certificate directly (works even without DashCA deployed) @@ -99,7 +99,7 @@ module.exports = function(ctx) { // Look for template in multiple locations (packaged app vs dev) const templatePaths = [ path.join(__dirname, '..', 'scripts', templateName), - path.join('/app', 'scripts', templateName) + path.join('/app', 'scripts', templateName), ]; let templateContent; @@ -208,12 +208,12 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`; const serverCertContent = await fsp.readFile(certFile, 'utf8'); const intermediateCertContent = await fsp.readFile(intermediateCert, 'utf8'); const rootCertContent = await fsp.readFile(rootCert, 'utf8'); - await fsp.writeFile(fullChainFile, serverCertContent + '\n' + intermediateCertContent + '\n' + rootCertContent); + await fsp.writeFile(fullChainFile, `${serverCertContent }\n${ intermediateCertContent }\n${ rootCertContent}`); execSync(`openssl pkcs12 -export -out "${pfxFile}" -inkey "${keyFile}" -in "${certFile}" -certfile "${intermediateCert}" -password "pass:${password}"`, { stdio: 'pipe' }); const keyContent = await fsp.readFile(keyFile, 'utf8'); - await fsp.writeFile(pemFile, keyContent + '\n' + serverCertContent + '\n' + intermediateCertContent); + await fsp.writeFile(pemFile, `${keyContent }\n${ serverCertContent }\n${ intermediateCertContent}`); } if (format === 'pfx') { @@ -260,26 +260,26 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`; const certFile = path.join(certsDir, domain, 'server.crt'); if (!await exists(certFile)) return null; - try { - const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString(); - const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain; - const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : ''; - const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : ''; - const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : ''; + try { + const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString(); + const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain; + const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : ''; + const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : ''; + const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : ''; - const expirationDate = new Date(notAfter); - const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); + const expirationDate = new Date(notAfter); + const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); - return { - domain, subject, - validFrom: notBefore, validUntil: notAfter, - daysUntilExpiration, fingerprint, - status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid' - }; - } catch { - return null; - } - }))).filter(Boolean); + return { + domain, subject, + validFrom: notBefore, validUntil: notAfter, + daysUntilExpiration, fingerprint, + status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid', + }; + } catch { + return null; + } + }))).filter(Boolean); res.json({ success: true, certificates }); }, 'ca-certs')); diff --git a/dashcaddy-api/routes/config/assets.js b/dashcaddy-api/routes/config/assets.js index db71aa8..8e76676 100644 --- a/dashcaddy-api/routes/config/assets.js +++ b/dashcaddy-api/routes/config/assets.js @@ -56,7 +56,7 @@ module.exports = function(ctx) { res.json({ success: true, path: `/assets/${safeFilename}`, - message: `Logo saved to ${filePath}` + message: `Logo saved to ${filePath}`, }); }, 'assets-upload')); @@ -75,7 +75,7 @@ module.exports = function(ctx) { customLogo: config.customLogo || config.customLogoDark || null, position: config.logoPosition || 'left', dashboardTitle: config.dashboardTitle || 'DashCaddy', - isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo + isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo, }); }, 'logo-get')); @@ -153,7 +153,7 @@ module.exports = function(ctx) { path: pathDark || pathLight, position: config.logoPosition || 'left', dashboardTitle: config.dashboardTitle || 'DashCaddy', - message: 'Branding settings saved' + message: 'Branding settings saved', }); }, 'logo-upload')); @@ -186,7 +186,7 @@ module.exports = function(ctx) { res.json({ success: true, - message: 'Branding reset to defaults' + message: 'Branding reset to defaults', }); }, 'logo-delete')); @@ -199,7 +199,7 @@ module.exports = function(ctx) { res.json({ success: true, customFavicon: config.customFavicon || null, - isDefault: !config.customFavicon + isDefault: !config.customFavicon, }); }, 'favicon-get')); @@ -237,8 +237,8 @@ module.exports = function(ctx) { sharp(buffer) .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .png() - .toBuffer() - ) + .toBuffer(), + ), ); // Convert to ICO @@ -261,7 +261,7 @@ module.exports = function(ctx) { res.json({ success: true, path: '/assets/favicon.ico', - message: 'Favicon created successfully' + message: 'Favicon created successfully', }); }, 'favicon')); @@ -285,7 +285,7 @@ module.exports = function(ctx) { res.json({ success: true, - message: 'Favicon reset to default' + message: 'Favicon reset to default', }); }, 'favicon-delete')); diff --git a/dashcaddy-api/routes/config/backup.js b/dashcaddy-api/routes/config/backup.js index b742e3e..c6acd64 100644 --- a/dashcaddy-api/routes/config/backup.js +++ b/dashcaddy-api/routes/config/backup.js @@ -34,7 +34,7 @@ module.exports = function(ctx) { dashcaddyVersion: '1.0.0', files: {}, themes: {}, - assets: {} + assets: {}, }; // Collect all configuration files (encryption key now included for self-contained restore) @@ -48,7 +48,7 @@ module.exports = function(ctx) { { key: 'encryptionKey', path: ENCRYPTION_KEY_FILE, required: false }, { key: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false }, { key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false }, - { key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false } + { key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false }, ]; for (const file of filesToBackup) { @@ -59,12 +59,12 @@ module.exports = function(ctx) { try { backup.files[file.key] = { type: 'json', - data: JSON.parse(content) + data: JSON.parse(content), }; } catch { backup.files[file.key] = { type: 'text', - data: content + data: content, }; } } else if (file.required) { @@ -85,7 +85,7 @@ module.exports = function(ctx) { const otpauth = authenticator.keyuri('user', 'DashCaddy', secret); const qrDataUrl = await QRCode.toDataURL(otpauth, { width: 256, margin: 2, - color: { dark: '#000000', light: '#ffffff' } + color: { dark: '#000000', light: '#ffffff' }, }); backup.totp = { qrCode: qrDataUrl, issuer: 'DashCaddy' }; } @@ -140,7 +140,7 @@ module.exports = function(ctx) { valid: true, version: backup.version, exportedAt: backup.exportedAt, - files: {} + files: {}, }; // Check each file in the backup @@ -154,7 +154,7 @@ module.exports = function(ctx) { encryptionKey: { path: ENCRYPTION_KEY_FILE, description: 'Encryption key (for credentials)' }, totpConfig: { path: ctx.TOTP_CONFIG_FILE, description: 'TOTP authentication config' }, tailscaleConfig: { path: ctx.TAILSCALE_CONFIG_FILE, description: 'Tailscale config' }, - notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' } + notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' }, }; for (const [key, value] of Object.entries(backup.files)) { @@ -167,7 +167,7 @@ module.exports = function(ctx) { inBackup: true, currentExists, action: currentExists ? 'overwrite' : 'create', - type: value.type + type: value.type, }; } } @@ -204,7 +204,7 @@ module.exports = function(ctx) { // Require TOTP verification for restores that include security-sensitive files const sensitiveKeys = ['credentials', 'totpConfig', 'encryptionKey']; const restoresSensitive = sensitiveKeys.some(key => - backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key) + backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key), ); if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) { if (!totpCode || !/^\d{6}$/.test(totpCode)) { @@ -223,7 +223,7 @@ module.exports = function(ctx) { const results = { restored: [], skipped: [], - errors: [] + errors: [], }; const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key'); @@ -236,7 +236,7 @@ module.exports = function(ctx) { encryptionKey: ENCRYPTION_KEY_FILE, totpConfig: ctx.TOTP_CONFIG_FILE, tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE, - notifications: ctx.NOTIFICATIONS_FILE + notifications: ctx.NOTIFICATIONS_FILE, }; // Restore each file @@ -286,7 +286,7 @@ module.exports = function(ctx) { const loadResponse = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, { method: 'POST', headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: caddyContent + body: caddyContent, }); if (loadResponse.ok) { @@ -345,7 +345,7 @@ module.exports = function(ctx) { if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true }); for (const [slug, data] of Object.entries(backup.themes)) { if (/^[a-z0-9-]+$/.test(slug)) { - fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(data, null, 2), 'utf8'); + fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(data, null, 2), 'utf8'); } } results.restored.push(`themes:${Object.keys(backup.themes).length}`); @@ -376,7 +376,7 @@ module.exports = function(ctx) { message: success ? `Restored ${results.restored.length} file(s) successfully` : `Restore completed with ${results.errors.length} error(s)`, - results + results, }); ctx.log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length }); diff --git a/dashcaddy-api/routes/containers.js b/dashcaddy-api/routes/containers.js index 9bde8e0..9a6f136 100644 --- a/dashcaddy-api/routes/containers.js +++ b/dashcaddy-api/routes/containers.js @@ -46,90 +46,90 @@ module.exports = function(ctx) { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); - // Get container info - const containerInfo = await container.inspect(); - const imageName = containerInfo.Config.Image; - const containerName = containerInfo.Name.replace(/^\//, ''); + // Get container info + const containerInfo = await container.inspect(); + const imageName = containerInfo.Config.Image; + const containerName = containerInfo.Name.replace(/^\//, ''); - ctx.log.info('docker', 'Updating container', { containerName, imageName }); + ctx.log.info('docker', 'Updating container', { containerName, imageName }); - // Pull the latest image - ctx.log.info('docker', `Pulling latest image: ${imageName}`); - await ctx.docker.pull(imageName); + // Pull the latest image + ctx.log.info('docker', `Pulling latest image: ${imageName}`); + await ctx.docker.pull(imageName); - // Get current container config for recreation - const hostConfig = containerInfo.HostConfig; - const config = { - Image: imageName, - name: containerName, - Env: containerInfo.Config.Env, - ExposedPorts: containerInfo.Config.ExposedPorts, - Labels: containerInfo.Config.Labels, - HostConfig: { - Binds: hostConfig.Binds, - PortBindings: hostConfig.PortBindings, - RestartPolicy: hostConfig.RestartPolicy, - NetworkMode: hostConfig.NetworkMode, - ExtraHosts: hostConfig.ExtraHosts, - Privileged: hostConfig.Privileged, - CapAdd: hostConfig.CapAdd, - CapDrop: hostConfig.CapDrop, - Devices: hostConfig.Devices, - LogConfig: DOCKER.LOG_CONFIG // Ensure log rotation on updated containers - }, - NetworkingConfig: {} + // Get current container config for recreation + const hostConfig = containerInfo.HostConfig; + const config = { + Image: imageName, + name: containerName, + Env: containerInfo.Config.Env, + ExposedPorts: containerInfo.Config.ExposedPorts, + Labels: containerInfo.Config.Labels, + HostConfig: { + Binds: hostConfig.Binds, + PortBindings: hostConfig.PortBindings, + RestartPolicy: hostConfig.RestartPolicy, + NetworkMode: hostConfig.NetworkMode, + ExtraHosts: hostConfig.ExtraHosts, + Privileged: hostConfig.Privileged, + CapAdd: hostConfig.CapAdd, + CapDrop: hostConfig.CapDrop, + Devices: hostConfig.Devices, + LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers + }, + NetworkingConfig: {}, + }; + + // Get network settings if using a custom network + if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { + const networkName = hostConfig.NetworkMode; + config.NetworkingConfig.EndpointsConfig = { + [networkName]: containerInfo.NetworkSettings.Networks[networkName], }; + } - // Get network settings if using a custom network - if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { - const networkName = hostConfig.NetworkMode; - config.NetworkingConfig.EndpointsConfig = { - [networkName]: containerInfo.NetworkSettings.Networks[networkName] - }; + // Stop and remove old container + ctx.log.info('docker', 'Stopping container', { containerName }); + await container.stop().catch(() => {}); // Ignore if already stopped + ctx.log.info('docker', 'Removing container', { containerName }); + await container.remove(); + + // Wait for port release (Windows/Docker Desktop can be slow to free ports) + await new Promise(r => setTimeout(r, 3000)); + + // Create and start new container + ctx.log.info('docker', 'Creating new container', { containerName }); + let newContainer; + try { + newContainer = await ctx.docker.client.createContainer(config); + ctx.log.info('docker', 'Starting container', { containerName }); + await newContainer.start(); + } catch (startError) { + // Clean up the failed container so it doesn't block future attempts + ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); + if (newContainer) { + try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } } + throw startError; + } - // Stop and remove old container - ctx.log.info('docker', 'Stopping container', { containerName }); - await container.stop().catch(() => {}); // Ignore if already stopped - ctx.log.info('docker', 'Removing container', { containerName }); - await container.remove(); + const newContainerInfo = await newContainer.inspect(); - // Wait for port release (Windows/Docker Desktop can be slow to free ports) - await new Promise(r => setTimeout(r, 3000)); - - // Create and start new container - ctx.log.info('docker', 'Creating new container', { containerName }); - let newContainer; - try { - newContainer = await ctx.docker.client.createContainer(config); - ctx.log.info('docker', 'Starting container', { containerName }); - await newContainer.start(); - } catch (startError) { - // Clean up the failed container so it doesn't block future attempts - ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); - if (newContainer) { - try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } - } - throw startError; + // Prune dangling images after update + try { + const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + if (pruneResult.SpaceReclaimed > 0) { + ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); } + } catch (pruneErr) { + ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); + } - const newContainerInfo = await newContainer.inspect(); - - // Prune dangling images after update - try { - const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); - if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); - } - } catch (pruneErr) { - ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); - } - - res.json({ - success: true, - message: `Container ${containerName} updated successfully`, - newContainerId: newContainerInfo.Id - }); + res.json({ + success: true, + message: `Container ${containerName} updated successfully`, + newContainerId: newContainerInfo.Id, + }); }, 'container-update')); // Check for available updates (compares local and remote image digests) @@ -148,7 +148,7 @@ module.exports = function(ctx) { const pullStream = await ctx.docker.pull(imageName); const downloadedLayers = pullStream.filter(e => - e.status === 'Downloading' || e.status === 'Download complete' + e.status === 'Downloading' || e.status === 'Download complete', ); updateAvailable = downloadedLayers.length > 0; @@ -167,7 +167,7 @@ module.exports = function(ctx) { success: true, imageName, updateAvailable, - currentDigest: localDigest + currentDigest: localDigest, }); }, 'container-check-update')); @@ -178,7 +178,7 @@ module.exports = function(ctx) { stdout: true, stderr: true, tail: 100, - timestamps: true + timestamps: true, }); res.json({ success: true, logs: logs.toString() }); }, 'container-logs')); @@ -194,7 +194,7 @@ module.exports = function(ctx) { router.get('/discover', ctx.asyncHandler(async (req, res) => { const containers = await ctx.docker.client.listContainers({ all: true }); const samiContainers = containers.filter(container => - container.Labels && container.Labels['sami.managed'] === 'true' + container.Labels && container.Labels['sami.managed'] === 'true', ); const discoveredContainers = samiContainers.map(container => ({ @@ -205,7 +205,7 @@ module.exports = function(ctx) { status: container.Status, appTemplate: container.Labels['sami.app'], subdomain: container.Labels['sami.subdomain'], - ports: container.Ports + ports: container.Ports, })); const paginationParams = parsePaginationParams(req.query); diff --git a/dashcaddy-api/routes/dns.js b/dashcaddy-api/routes/dns.js index 498c7d5..a4c8795 100644 --- a/dashcaddy-api/routes/dns.js +++ b/dashcaddy-api/routes/dns.js @@ -113,7 +113,7 @@ module.exports = function(ctx) { const zone = parts.slice(1).join('.') || ctx.siteConfig.tld.replace(/^\./, ''); const result = await ctx.dns.call(dnsServer, '/api/zones/records/add', { - token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true' + token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true', }); if (result.status === 'ok') { @@ -151,7 +151,7 @@ module.exports = function(ctx) { try { const result = await ctx.dns.call(dnsServer, '/api/zones/records/get', { - token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true' + token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true', }); if (result.status === 'ok' && result.response && result.response.records) { @@ -218,7 +218,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(technitiumUrl, { method: 'GET', headers: { 'Accept': 'text/plain' }, - timeout: 10000 + timeout: 10000, }); if (!response.ok) { @@ -232,7 +232,7 @@ module.exports = function(ctx) { server: server, count: 0, logs: [], - message: 'No logs available for this server' + message: 'No logs available for this server', }); } return ctx.errorResponse(res, response.status, ctx.safeErrorMessage(errorJson.errorMessage || errorText)); @@ -255,7 +255,7 @@ module.exports = function(ctx) { server: server, count: 0, logs: [], - message: 'No logs available for this server' + message: 'No logs available for this server', }); } // Invalidate cached token on auth errors so next request re-authenticates @@ -287,7 +287,7 @@ module.exports = function(ctx) { class: match[6].trim(), rcode: match[7].trim(), answer: match[8].trim() || null, - raw: line + raw: line, }; } return { raw: line, parsed: false }; @@ -299,7 +299,7 @@ module.exports = function(ctx) { server: server, logFile: logFileName, count: parsedLogs.length, - logs: parsedLogs + logs: parsedLogs, }); } catch (error) { @@ -319,7 +319,7 @@ module.exports = function(ctx) { hasCredentials, hasToken, tokenExpiry: ctx.dns.getTokenExpiry(), - isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null + isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null, }); }, 'dns-token-status')); @@ -394,7 +394,7 @@ module.exports = function(ctx) { return res.json({ success: anySuccess, message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed', - results + results, }); } @@ -430,7 +430,7 @@ module.exports = function(ctx) { res.json({ success: true, message: 'DNS credentials saved and verified (encrypted)', - tokenExpiry: ctx.dns.getTokenExpiry() + tokenExpiry: ctx.dns.getTokenExpiry(), }); }, 'dns-credentials')); @@ -495,7 +495,7 @@ module.exports = function(ctx) { res.json({ success: true, message: 'Token refreshed successfully', - tokenExpiry: ctx.dns.getTokenExpiry() + tokenExpiry: ctx.dns.getTokenExpiry(), }); } else { ctx.errorResponse(res, 401, result.error); @@ -529,8 +529,8 @@ module.exports = function(ctx) { method: 'GET', headers: { 'Accept': 'application/json', - 'User-Agent': APP.USER_AGENTS.API - } + 'User-Agent': APP.USER_AGENTS.API, + }, }); const text = await response.text(); @@ -550,7 +550,7 @@ module.exports = function(ctx) { updateTitle: result.response.updateTitle || null, updateMessage: result.response.updateMessage || null, downloadLink: result.response.downloadLink || null, - instructionsLink: result.response.instructionsLink || null + instructionsLink: result.response.instructionsLink || null, }); } else { ctx.errorResponse(res, 500, result.errorMessage || 'Check failed'); @@ -586,7 +586,7 @@ module.exports = function(ctx) { // Check if update is available const checkResponse = await ctx.fetchT( `http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`, - { method: 'GET', headers: { 'Accept': 'application/json' } } + { method: 'GET', headers: { 'Accept': 'application/json' } }, ); const checkText = await checkResponse.text(); @@ -604,7 +604,7 @@ module.exports = function(ctx) { success: true, message: 'Already up to date', currentVersion: checkResult.response.currentVersion, - updated: false + updated: false, }); } @@ -620,7 +620,7 @@ module.exports = function(ctx) { downloadLink: checkResult.response.downloadLink || null, instructionsLink: checkResult.response.instructionsLink || null, updated: false, - manualUpdateRequired: true + manualUpdateRequired: true, }); } catch (error) { ctx.log.error('dns', 'DNS update error', { error: error.message }); diff --git a/dashcaddy-api/routes/errorlogs.js b/dashcaddy-api/routes/errorlogs.js index fe4ebcc..328dc7e 100644 --- a/dashcaddy-api/routes/errorlogs.js +++ b/dashcaddy-api/routes/errorlogs.js @@ -14,22 +14,22 @@ module.exports = function(ctx) { } const logContent = await fsp.readFile(ctx.ERROR_LOG_FILE, 'utf8'); - const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim()); + const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim()); - const logs = logEntries.map(entry => { - const lines = entry.trim().split('\n'); - const firstLine = lines[0] || ''; - const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/); + const logs = logEntries.map(entry => { + const lines = entry.trim().split('\n'); + const firstLine = lines[0] || ''; + const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/); - if (match) { - return { - timestamp: match[1], - context: match[2], - error: match[3] - }; - } - return null; - }).filter(Boolean); + if (match) { + return { + timestamp: match[1], + context: match[2], + error: match[3], + }; + } + return null; + }).filter(Boolean); res.json({ success: true, logs: logs.slice(-50).reverse() }); }, 'error-logs-get')); diff --git a/dashcaddy-api/routes/health.js b/dashcaddy-api/routes/health.js index ac1cbe5..0e7a5ee 100644 --- a/dashcaddy-api/routes/health.js +++ b/dashcaddy-api/routes/health.js @@ -34,7 +34,7 @@ module.exports = function(ctx) { try { let url = null; - let checkType = 'http'; + const checkType = 'http'; // Determine URL to check url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); @@ -52,7 +52,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(url, { method: 'HEAD', signal: controller.signal, - redirect: 'follow' + redirect: 'follow', }); clearTimeout(timeout); @@ -60,7 +60,7 @@ module.exports = function(ctx) { status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', statusCode: response.status, url, - checkedAt: new Date().toISOString() + checkedAt: new Date().toISOString(), }; } catch (fetchError) { clearTimeout(timeout); @@ -73,7 +73,7 @@ module.exports = function(ctx) { const getResponse = await ctx.fetchT(url, { method: 'GET', signal: getController.signal, - redirect: 'follow' + redirect: 'follow', }); clearTimeout(getTimeout); @@ -81,14 +81,14 @@ module.exports = function(ctx) { status: getResponse.ok || getResponse.status < 500 ? 'healthy' : 'unhealthy', statusCode: getResponse.status, url, - checkedAt: new Date().toISOString() + checkedAt: new Date().toISOString(), }; } catch (e) { health[serviceId] = { status: 'unhealthy', reason: e.name === 'AbortError' ? 'Timeout' : e.message, url, - checkedAt: new Date().toISOString() + checkedAt: new Date().toISOString(), }; } } @@ -96,7 +96,7 @@ module.exports = function(ctx) { health[serviceId] = { status: 'error', reason: e.message, - checkedAt: new Date().toISOString() + checkedAt: new Date().toISOString(), }; } })); @@ -113,7 +113,7 @@ module.exports = function(ctx) { success: true, health: paginatedHealth, checkedAt: lastHealthCheck, - ...(result.pagination && { pagination: result.pagination }) + ...(result.pagination && { pagination: result.pagination }), }); }, 'health-services')); @@ -123,7 +123,7 @@ module.exports = function(ctx) { success: true, health: serviceHealthCache, lastCheck: lastHealthCheck, - cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null + cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null, }); }, 'health-cached')); @@ -157,7 +157,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(url, { method: 'GET', signal: controller.signal, - redirect: 'follow' + redirect: 'follow', }); clearTimeout(timeout); @@ -168,8 +168,8 @@ module.exports = function(ctx) { status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', statusCode: response.status, url, - checkedAt: new Date().toISOString() - } + checkedAt: new Date().toISOString(), + }, }); } catch (e) { clearTimeout(timeout); @@ -180,8 +180,8 @@ module.exports = function(ctx) { status: 'unhealthy', reason: e.name === 'AbortError' ? 'Timeout' : e.message, url, - checkedAt: new Date().toISOString() - } + checkedAt: new Date().toISOString(), + }, }); } }, 'health-service')); @@ -201,7 +201,7 @@ module.exports = function(ctx) { return res.json({ status: 'error', message: 'Root CA certificate not found', - daysUntilExpiration: null + daysUntilExpiration: null, }); } @@ -232,14 +232,14 @@ module.exports = function(ctx) { status: status, message: message, daysUntilExpiration: daysUntilExpiration, - expiresAt: notAfter + expiresAt: notAfter, }); } catch (error) { await ctx.logError('GET /api/health/ca', error); res.json({ status: 'error', message: error.message, - daysUntilExpiration: null + daysUntilExpiration: null, }); } }, 'health-ca')); diff --git a/dashcaddy-api/routes/license.js b/dashcaddy-api/routes/license.js index 11656f2..5039c86 100644 --- a/dashcaddy-api/routes/license.js +++ b/dashcaddy-api/routes/license.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { res.json({ success: true, message: result.message, - license: result.activation + license: result.activation, }); } else { ctx.errorResponse(res, 400, result.message); @@ -53,8 +53,8 @@ module.exports = function(ctx) { tier: status.tier, ...(available ? {} : { upgradeUrl: '/settings#license', - message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium` - }) + message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium`, + }), }); }, 'license-feature-check')); diff --git a/dashcaddy-api/routes/logs.js b/dashcaddy-api/routes/logs.js index e59e13d..3670a93 100644 --- a/dashcaddy-api/routes/logs.js +++ b/dashcaddy-api/routes/logs.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { name: c.Names[0]?.replace(/^\//, '') || 'unknown', image: c.Image, status: c.State, - created: c.Created + created: c.Created, })); const paginationParams = parsePaginationParams(req.query); @@ -46,7 +46,7 @@ module.exports = function(ctx) { const logs = await container.logs({ stdout: true, stderr: true, - tail, since, timestamps + tail, since, timestamps, }); // Parse Docker log stream (demultiplex stdout/stderr) @@ -65,7 +65,7 @@ module.exports = function(ctx) { if (line) { lines.push({ stream: streamType === 2 ? 'stderr' : 'stdout', - text: line + text: line, }); } offset += 8 + size; @@ -75,7 +75,7 @@ module.exports = function(ctx) { success: true, containerId, containerName, logs: lines, - count: lines.length + count: lines.length, }); }, 'logs-container')); @@ -100,7 +100,7 @@ module.exports = function(ctx) { const logStream = await container.logs({ stdout: true, stderr: true, - follow: true, tail: 50, timestamps: true + follow: true, tail: 50, timestamps: true, }); let buffer = Buffer.alloc(0); @@ -119,7 +119,7 @@ module.exports = function(ctx) { const data = JSON.stringify({ stream: streamType === 2 ? 'stderr' : 'stdout', text: line, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); res.write(`data: ${data}\n\n`); } @@ -248,7 +248,7 @@ module.exports = function(ctx) { const logs = tailLines.map(line => ({ stream: 'stdout', text: line, - timestamp: extractTimestamp(line) + timestamp: extractTimestamp(line), })); res.json({ @@ -256,7 +256,7 @@ module.exports = function(ctx) { logPath: normalizedPath, logs, count: logs.length, - totalLines: lines.length + totalLines: lines.length, }); }, 'logs-file')); diff --git a/dashcaddy-api/routes/monitoring.js b/dashcaddy-api/routes/monitoring.js index 699624b..69170e6 100644 --- a/dashcaddy-api/routes/monitoring.js +++ b/dashcaddy-api/routes/monitoring.js @@ -96,17 +96,17 @@ module.exports = function(ctx) { image: containerInfo.Image, status: containerInfo.State, cpu: { - percent: Math.round(cpuPercent * 100) / 100 + percent: Math.round(cpuPercent * 100) / 100, }, memory: { used: memUsage, limit: memLimit, - percent: Math.round(memPercent * 100) / 100 + percent: Math.round(memPercent * 100) / 100, }, network: { rx: netRx, - tx: netTx - } + tx: netTx, + }, }); } catch (e) { // Skip containers we can't get stats for @@ -151,15 +151,15 @@ module.exports = function(ctx) { status: info.State.Status, started: info.State.StartedAt, cpu: { - percent: Math.round(cpuPercent * 100) / 100 + percent: Math.round(cpuPercent * 100) / 100, }, memory: { used: memUsage, limit: memLimit, - percent: Math.round((memUsage / memLimit) * 100 * 100) / 100 + percent: Math.round((memUsage / memLimit) * 100 * 100) / 100, }, - network: { rx: netRx, tx: netTx } - } + network: { rx: netRx, tx: netTx }, + }, }); }, 'stats-container')); diff --git a/dashcaddy-api/routes/notifications.js b/dashcaddy-api/routes/notifications.js index d8b77ad..f005d21 100644 --- a/dashcaddy-api/routes/notifications.js +++ b/dashcaddy-api/routes/notifications.js @@ -7,116 +7,116 @@ module.exports = function(ctx) { // GET /config — Get notification configuration (sensitive data redacted) router.get('/config', ctx.asyncHandler(async (req, res) => { - const notificationConfig = ctx.notification.getConfig(); - // Return config without sensitive data - const safeConfig = { - enabled: notificationConfig.enabled, - providers: { - discord: { - enabled: notificationConfig.providers.discord?.enabled || false, - configured: !!notificationConfig.providers.discord?.webhookUrl - }, - telegram: { - enabled: notificationConfig.providers.telegram?.enabled || false, - configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId) - }, - ntfy: { - enabled: notificationConfig.providers.ntfy?.enabled || false, - configured: !!notificationConfig.providers.ntfy?.topic, - serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh' - } + const notificationConfig = ctx.notification.getConfig(); + // Return config without sensitive data + const safeConfig = { + enabled: notificationConfig.enabled, + providers: { + discord: { + enabled: notificationConfig.providers.discord?.enabled || false, + configured: !!notificationConfig.providers.discord?.webhookUrl, }, - events: notificationConfig.events, - healthCheck: notificationConfig.healthCheck - }; - res.json({ success: true, config: safeConfig }); + telegram: { + enabled: notificationConfig.providers.telegram?.enabled || false, + configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId), + }, + ntfy: { + enabled: notificationConfig.providers.ntfy?.enabled || false, + configured: !!notificationConfig.providers.ntfy?.topic, + serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh', + }, + }, + events: notificationConfig.events, + healthCheck: notificationConfig.healthCheck, + }; + res.json({ success: true, config: safeConfig }); }, 'notifications-config-get')); // POST /config — Update notification configuration router.post('/config', ctx.asyncHandler(async (req, res) => { - const { enabled, providers, events, healthCheck } = req.body; - const notificationConfig = ctx.notification.getConfig(); + const { enabled, providers, events, healthCheck } = req.body; + const notificationConfig = ctx.notification.getConfig(); - // Validate provider webhook URLs and tokens - if (providers) { - if (providers.discord?.webhookUrl) { - try { - validateURL(providers.discord.webhookUrl); - } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL'); - } - } - if (providers.telegram?.botToken) { - try { - validateToken(providers.telegram.botToken); - } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format'); - } - } - if (providers.ntfy?.serverUrl) { - try { - validateURL(providers.ntfy.serverUrl); - } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid ntfy server URL'); - } - } - if (providers.ntfy?.topic) { - const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/; - if (!topicRegex.test(providers.ntfy.topic)) { - return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)'); - } + // Validate provider webhook URLs and tokens + if (providers) { + if (providers.discord?.webhookUrl) { + try { + validateURL(providers.discord.webhookUrl); + } catch (validationErr) { + return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL'); } } - - // Update enabled state - if (typeof enabled === 'boolean') { - notificationConfig.enabled = enabled; - } - - // Update providers (only update provided fields) - if (providers) { - if (providers.discord) { - notificationConfig.providers.discord = { - ...notificationConfig.providers.discord, - ...providers.discord - }; - } - if (providers.telegram) { - notificationConfig.providers.telegram = { - ...notificationConfig.providers.telegram, - ...providers.telegram - }; - } - if (providers.ntfy) { - notificationConfig.providers.ntfy = { - ...notificationConfig.providers.ntfy, - ...providers.ntfy - }; + if (providers.telegram?.botToken) { + try { + validateToken(providers.telegram.botToken); + } catch (validationErr) { + return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format'); } } - - // Update events - if (events) { - notificationConfig.events = { ...notificationConfig.events, ...events }; - } - - // Update health check settings - if (healthCheck) { - const wasEnabled = notificationConfig.healthCheck?.enabled; - notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck }; - - // Restart daemon if settings changed - if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) { - if (notificationConfig.healthCheck.enabled) { - ctx.notification.startHealthDaemon(); - } else { - ctx.notification.stopHealthDaemon(); - } + if (providers.ntfy?.serverUrl) { + try { + validateURL(providers.ntfy.serverUrl); + } catch (validationErr) { + return ctx.errorResponse(res, 400, 'Invalid ntfy server URL'); } } + if (providers.ntfy?.topic) { + const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/; + if (!topicRegex.test(providers.ntfy.topic)) { + return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)'); + } + } + } - await ctx.notification.saveConfig(); - res.json({ success: true, message: 'Notification config updated' }); + // Update enabled state + if (typeof enabled === 'boolean') { + notificationConfig.enabled = enabled; + } + + // Update providers (only update provided fields) + if (providers) { + if (providers.discord) { + notificationConfig.providers.discord = { + ...notificationConfig.providers.discord, + ...providers.discord, + }; + } + if (providers.telegram) { + notificationConfig.providers.telegram = { + ...notificationConfig.providers.telegram, + ...providers.telegram, + }; + } + if (providers.ntfy) { + notificationConfig.providers.ntfy = { + ...notificationConfig.providers.ntfy, + ...providers.ntfy, + }; + } + } + + // Update events + if (events) { + notificationConfig.events = { ...notificationConfig.events, ...events }; + } + + // Update health check settings + if (healthCheck) { + const wasEnabled = notificationConfig.healthCheck?.enabled; + notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck }; + + // Restart daemon if settings changed + if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) { + if (notificationConfig.healthCheck.enabled) { + ctx.notification.startHealthDaemon(); + } else { + ctx.notification.stopHealthDaemon(); + } + } + } + + await ctx.notification.saveConfig(); + res.json({ success: true, message: 'Notification config updated' }); }, 'notifications-config-update')); // POST /test — Test notification delivery @@ -159,7 +159,7 @@ module.exports = function(ctx) { res.json({ success: true, history: notificationHistory.slice(0, limit), - total: notificationHistory.length + total: notificationHistory.length, }); } }, 'notifications-history')); @@ -177,7 +177,7 @@ module.exports = function(ctx) { res.json({ success: true, lastCheck: notificationConfig.healthCheck.lastCheck, - containersMonitored: Object.keys(ctx.notification.getHealthState()).length + containersMonitored: Object.keys(ctx.notification.getHealthState()).length, }); }, 'notifications-health-check')); diff --git a/dashcaddy-api/routes/recipes/deploy.js b/dashcaddy-api/routes/recipes/deploy.js index 2111d90..44d437d 100644 --- a/dashcaddy-api/routes/recipes/deploy.js +++ b/dashcaddy-api/routes/recipes/deploy.js @@ -42,7 +42,7 @@ module.exports = function(ctx) { await ctx.docker.client.createNetwork({ Name: networkName, Driver: recipe.network.driver || 'bridge', - Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId } + Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId }, }); ctx.log.info('recipe', 'Created Docker network', { networkName }); } catch (e) { @@ -62,18 +62,18 @@ module.exports = function(ctx) { try { ctx.log.info('recipe', `Deploying component: ${component.id}`, { role: component.role, - internal: component.internal || false + internal: component.internal || false, }); const result = await deployComponent(component, recipe, config, generatedPasswords, networkName); deployedComponents.push(result); ctx.log.info('recipe', `Component deployed: ${component.id}`, { - containerId: result.containerId?.substring(0, 12) + containerId: result.containerId?.substring(0, 12), }); } catch (componentError) { ctx.log.error('recipe', `Component failed: ${component.id}`, { - error: componentError.message + error: componentError.message, }); errors.push({ componentId: component.id, role: component.role, error: componentError.message }); // Continue deploying other components — partial success is better than total failure @@ -96,7 +96,7 @@ module.exports = function(ctx) { recipeId: recipeId, recipeRole: deployed.role, tailscaleOnly: config.sharedConfig?.tailscaleOnly || false, - deployedAt: new Date().toISOString() + deployedAt: new Date().toISOString(), }); } } @@ -119,18 +119,18 @@ module.exports = function(ctx) { role: c.role, containerId: c.containerId?.substring(0, 12), url: c.url, - internal: c.internal + internal: c.internal, })), errors: errors.length > 0 ? errors : undefined, message: errors.length > 0 ? `${recipe.name} partially deployed (${deployedComponents.length}/${componentsToDeploy.length} components)` : `${recipe.name} deployed successfully!`, - setupInstructions: recipe.setupInstructions + setupInstructions: recipe.setupInstructions, }; ctx.notification.send('deploymentSuccess', 'Recipe Deployed', `**${recipe.name}** recipe deployed (${deployedComponents.length} components).`, - 'success' + 'success', ); res.json(response); @@ -146,7 +146,7 @@ module.exports = function(ctx) { } } catch (cleanupError) { ctx.log.warn('recipe', 'Cleanup failed for component', { - componentId: deployed.id, error: cleanupError.message + componentId: deployed.id, error: cleanupError.message, }); } } @@ -162,7 +162,7 @@ module.exports = function(ctx) { } ctx.notification.send('deploymentFailed', 'Recipe Failed', - `Failed to deploy **${recipe.name}**: ${error.message}`, 'error' + `Failed to deploy **${recipe.name}**: ${error.message}`, 'error', ); ctx.errorResponse(res, 500, error.message); @@ -254,7 +254,7 @@ module.exports = function(ctx) { HostConfig: { PortBindings: {}, Binds: dockerConfig.volumes || [], - RestartPolicy: { Name: 'unless-stopped' } + RestartPolicy: { Name: 'unless-stopped' }, }, Env: Object.entries(dockerConfig.environment || {}).map(([k, v]) => `${k}=${v}`), Labels: { @@ -264,8 +264,8 @@ module.exports = function(ctx) { 'sami.recipe.component': component.id, 'sami.recipe.role': component.role, 'sami.subdomain': subdomain, - 'sami.deployed': new Date().toISOString() - } + 'sami.deployed': new Date().toISOString(), + }, }; // Configure ports @@ -288,7 +288,7 @@ module.exports = function(ctx) { } catch (e) { ctx.log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`); const images = await ctx.docker.client.listImages({ - filters: { reference: [dockerConfig.image] } + filters: { reference: [dockerConfig.image] }, }); if (images.length === 0) throw new Error(`Image not found: ${dockerConfig.image}`); } @@ -324,7 +324,7 @@ module.exports = function(ctx) { const primaryPort = port || dockerConfig.ports[0].split(/[:/]/)[0]; const caddyConfig = ctx.caddy.generateConfig( subdomain, hostIp, primaryPort, - { tailscaleOnly: sharedConfig.tailscaleOnly || false } + { tailscaleOnly: sharedConfig.tailscaleOnly || false }, ); try { const helpers = require('../apps/helpers')(ctx); @@ -344,7 +344,7 @@ module.exports = function(ctx) { internal: component.internal || false, templateRef: component.templateRef, logo, - url + url, }; } diff --git a/dashcaddy-api/routes/recipes/index.js b/dashcaddy-api/routes/recipes/index.js index ed8b415..33baba6 100644 --- a/dashcaddy-api/routes/recipes/index.js +++ b/dashcaddy-api/routes/recipes/index.js @@ -29,9 +29,9 @@ module.exports = function(ctx) { required: c.required, internal: c.internal || false, templateRef: c.templateRef || null, - note: c.note || null + note: c.note || null, })), - setupInstructions: recipe.setupInstructions + setupInstructions: recipe.setupInstructions, })); res.json({ success: true, templates, categories: RECIPE_CATEGORIES }); diff --git a/dashcaddy-api/routes/recipes/manage.js b/dashcaddy-api/routes/recipes/manage.js index 135da68..6e1e5f8 100644 --- a/dashcaddy-api/routes/recipes/manage.js +++ b/dashcaddy-api/routes/recipes/manage.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { if (!recipeGroups[service.recipeId]) { recipeGroups[service.recipeId] = { recipeId: service.recipeId, - components: [] + components: [], }; } recipeGroups[service.recipeId].components.push({ @@ -25,7 +25,7 @@ module.exports = function(ctx) { logo: service.logo, containerId: service.containerId, recipeRole: service.recipeRole, - deployedAt: service.deployedAt + deployedAt: service.deployedAt, }); } @@ -48,7 +48,7 @@ module.exports = function(ctx) { // Check if this container is already listed (by containerId) const existing = recipeGroups[recipeId].components.find( - c => c.containerId === containerInfo.Id + c => c.containerId === containerInfo.Id, ); if (existing) continue; @@ -59,7 +59,7 @@ module.exports = function(ctx) { recipeRole: labels['sami.recipe.role'] || 'Unknown', internal: true, state: containerInfo.State, - status: containerInfo.Status + status: containerInfo.Status, }); } } catch (e) { @@ -242,7 +242,7 @@ module.exports = function(ctx) { ctx.notification.send('recipeRemoved', 'Recipe Removed', `Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`, - 'info' + 'info', ); ctx.log.info('recipe', 'Recipe removed', { recipeId, results }); @@ -271,7 +271,7 @@ module.exports = function(ctx) { Id: c.Id, component: c.Labels['sami.recipe.component'] || c.Names[0]?.replace('/', ''), role: c.Labels['sami.recipe.role'] || 'Unknown', - state: c.State + state: c.State, })); } @@ -293,7 +293,7 @@ module.exports = function(ctx) { */ async function removeCaddyBlock(subdomain) { const domain = ctx.buildDomain(subdomain); - let content = await ctx.caddy.read(); + const content = await ctx.caddy.read(); // Find and remove the block for this domain const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index d06836d..29b9ea4 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -99,7 +99,7 @@ module.exports = function(ctx) { isUp: false, statusCode: 502, responseTime, - error: error.message + error: error.message, }; } @@ -108,7 +108,7 @@ module.exports = function(ctx) { isUp: isServiceUp(statusCode), statusCode, responseTime, - url + url, }; } @@ -169,7 +169,7 @@ module.exports = function(ctx) { success: true, hasApiKey: !!(arrKey || svcKey), hasBasicAuth: !!username, - username: username || null + username: username || null, }); } catch (error) { res.json({ success: true, hasApiKey: false, hasBasicAuth: false }); @@ -249,7 +249,7 @@ module.exports = function(ctx) { services.forEach(service => addId(service.id)); const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) => - probeServiceStatus(id, serviceMap.get(id)) + probeServiceStatus(id, serviceMap.get(id)), ); const statuses = {}; @@ -261,7 +261,7 @@ module.exports = function(ctx) { res.json({ success: true, checkedAt: new Date().toISOString(), - statuses + statuses, }); }, 'services-status')); @@ -343,7 +343,7 @@ module.exports = function(ctx) { res.json({ success: true, message: `Successfully imported ${services.length} services`, - count: services.length + count: services.length, }); }, 'services-import')); @@ -396,12 +396,12 @@ module.exports = function(ctx) { const oldDomain = ctx.buildDomain(oldSubdomain); const newDomain = ctx.buildDomain(newSubdomain); - let content = await ctx.caddy.read(); + const content = await ctx.caddy.read(); const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp( `${escapedOldDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, - 's' + 's', ); const oldBlockMatch = content.match(siteBlockRegex); @@ -414,7 +414,7 @@ module.exports = function(ctx) { const finalPort = port || existingPort; const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, { - tailscaleOnly: tailscaleOnly || false + tailscaleOnly: tailscaleOnly || false, }); const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig)); @@ -445,7 +445,7 @@ module.exports = function(ctx) { id: newSubdomain, port: port || services[serviceIndex].port, ip: ip || services[serviceIndex].ip, - tailscaleOnly: tailscaleOnly || false + tailscaleOnly: tailscaleOnly || false, }; results.services = 'updated'; } else { @@ -459,7 +459,7 @@ module.exports = function(ctx) { res.json({ success: true, message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`, - results + results, }); }, 'services-update')); diff --git a/dashcaddy-api/routes/sites.js b/dashcaddy-api/routes/sites.js index 65762dd..03f3b4a 100644 --- a/dashcaddy-api/routes/sites.js +++ b/dashcaddy-api/routes/sites.js @@ -25,7 +25,7 @@ module.exports = function(ctx) { const response = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, { method: 'POST', headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: caddyfileContent + body: caddyfileContent, }); if (!response.ok) { @@ -39,80 +39,80 @@ module.exports = function(ctx) { // Get Certificate Authorities from Caddyfile router.get('/caddy/cas', ctx.asyncHandler(async (req, res) => { - const content = await ctx.caddy.read(); - const cas = []; + const content = await ctx.caddy.read(); + const cas = []; - const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs; - let pkiMatch; - while ((pkiMatch = pkiRegex.exec(content)) !== null) { - const pkiBlock = pkiMatch[1]; - let caMatch; - const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs; - while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) { - const caName = caMatch[1]; - const caBlock = caMatch[2]; - const ca = { id: caName, name: caName, root: {}, intermediate: {} }; + const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs; + let pkiMatch; + while ((pkiMatch = pkiRegex.exec(content)) !== null) { + const pkiBlock = pkiMatch[1]; + let caMatch; + const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs; + while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) { + const caName = caMatch[1]; + const caBlock = caMatch[2]; + const ca = { id: caName, name: caName, root: {}, intermediate: {} }; - const nameMatch = /name\s+"([^"]+)"/.exec(caBlock); - if (nameMatch) ca.name = nameMatch[1]; + const nameMatch = /name\s+"([^"]+)"/.exec(caBlock); + if (nameMatch) ca.name = nameMatch[1]; - const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock); - const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock); - if (rootCnMatch) ca.root_cn = rootCnMatch[1]; - if (intCnMatch) ca.intermediate_cn = intCnMatch[1]; + const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock); + const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock); + if (rootCnMatch) ca.root_cn = rootCnMatch[1]; + if (intCnMatch) ca.intermediate_cn = intCnMatch[1]; - const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock); - if (rootMatch) { - const rootBlock = rootMatch[1]; - const certMatch = /cert\s+(\S+)/.exec(rootBlock); - const keyMatch = /key\s+(\S+)/.exec(rootBlock); - if (certMatch) ca.root.cert = certMatch[1]; - if (keyMatch) ca.root.key = keyMatch[1]; - } - - const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock); - if (intMatch) { - const intBlock = intMatch[1]; - const certMatch = /cert\s+(\S+)/.exec(intBlock); - const keyMatch = /key\s+(\S+)/.exec(intBlock); - if (certMatch) ca.intermediate.cert = certMatch[1]; - if (keyMatch) ca.intermediate.key = keyMatch[1]; - } - - cas.push(ca); + const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock); + if (rootMatch) { + const rootBlock = rootMatch[1]; + const certMatch = /cert\s+(\S+)/.exec(rootBlock); + const keyMatch = /key\s+(\S+)/.exec(rootBlock); + if (certMatch) ca.root.cert = certMatch[1]; + if (keyMatch) ca.root.key = keyMatch[1]; } - } - const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g; - let tlsMatch; - while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) { - cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' }); - } - - const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || []; - const tlsInternalCAs = new Set(); - for (const block of siteBlocks) { - const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block); - if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]); - if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) { - tlsInternalCAs.add('local'); + const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock); + if (intMatch) { + const intBlock = intMatch[1]; + const certMatch = /cert\s+(\S+)/.exec(intBlock); + const keyMatch = /key\s+(\S+)/.exec(intBlock); + if (certMatch) ca.intermediate.cert = certMatch[1]; + if (keyMatch) ca.intermediate.key = keyMatch[1]; } - } - for (const caName of tlsInternalCAs) { - if (!cas.find(c => c.name === caName)) { - cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' }); - } - } - if (cas.length === 0 && /tls\s+internal/.test(content)) { - cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' }); - } - const caList = cas.map(ca => ({ - id: ca.id || ca.name, - name: ca.name, - displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name - })); - res.json({ status: 'success', data: { cas: caList } }); + cas.push(ca); + } + } + + const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g; + let tlsMatch; + while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) { + cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' }); + } + + const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || []; + const tlsInternalCAs = new Set(); + for (const block of siteBlocks) { + const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block); + if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]); + if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) { + tlsInternalCAs.add('local'); + } + } + for (const caName of tlsInternalCAs) { + if (!cas.find(c => c.name === caName)) { + cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' }); + } + } + if (cas.length === 0 && /tls\s+internal/.test(content)) { + cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' }); + } + + const caList = cas.map(ca => ({ + id: ca.id || ca.name, + name: ca.name, + displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name, + })); + res.json({ status: 'success', data: { cas: caList } }); }, 'caddy-get-cas')); // Remove a site from Caddyfile @@ -123,7 +123,7 @@ module.exports = function(ctx) { const result = await ctx.caddy.modify((content) => { const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp( - `\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g' + `\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g', ); const modified = content.replace(siteBlockRegex, '\n'); if (modified.length === content.length) return null; @@ -149,7 +149,7 @@ module.exports = function(ctx) { const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i; if (!upstreamRegex.test(upstream)) return ctx.errorResponse(res, 400, 'Invalid upstream format. Use host:port'); - let content = await ctx.caddy.read(); + const content = await ctx.caddy.read(); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g'); if (siteBlockRegex.test(content)) { @@ -200,7 +200,7 @@ module.exports = function(ctx) { } const sslConfig = sslType === 'letsencrypt' ? '' : 'tls internal'; - const hostHeader = preserveHost ? `\n header_up Host {upstream_hostport}` : ''; + const hostHeader = preserveHost ? '\n header_up Host {upstream_hostport}' : ''; const urlObj = new URL(externalUrl); @@ -238,7 +238,7 @@ module.exports = function(ctx) { await ctx.addServiceToConfig({ id: subdomain, name: serviceName, logo, isExternal: true, externalUrl, - deployedAt: new Date().toISOString() + deployedAt: new Date().toISOString(), }); ctx.log.info('deploy', 'Service added to dashboard', { subdomain }); } catch (serviceError) { @@ -248,7 +248,7 @@ module.exports = function(ctx) { const response = { success: true, - message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}` + message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`, }; if (dnsWarning) response.warning = dnsWarning; res.json(response); diff --git a/dashcaddy-api/routes/tailscale.js b/dashcaddy-api/routes/tailscale.js index 07b807b..a322736 100644 --- a/dashcaddy-api/routes/tailscale.js +++ b/dashcaddy-api/routes/tailscale.js @@ -16,7 +16,7 @@ module.exports = function(ctx) { success: true, installed: false, connected: false, - message: 'Tailscale not available or not running' + message: 'Tailscale not available or not running', }); } @@ -30,7 +30,7 @@ module.exports = function(ctx) { os: peer.OS, online: peer.Online, lastSeen: peer.LastSeen, - user: peer.UserID + user: peer.UserID, }); } } @@ -44,11 +44,11 @@ module.exports = function(ctx) { hostname: status.Self?.HostName, ip: localIP, tailnetName: status.MagicDNSSuffix, - online: status.Self?.Online + online: status.Self?.Online, }, config: ctx.tailscale.config, devices, - deviceCount: devices.length + deviceCount: devices.length, }); }, 'tailscale-status')); @@ -65,7 +65,7 @@ module.exports = function(ctx) { res.json({ success: true, message: 'Tailscale configuration updated', - config: ctx.tailscale.config + config: ctx.tailscale.config, }); }, 'tailscale-config')); @@ -83,7 +83,7 @@ module.exports = function(ctx) { isTailscale, clientIP, forwardedFor: forwardedFor || null, - realIP: realIP || null + realIP: realIP || null, }); }, 'tailscale-check')); @@ -102,7 +102,7 @@ module.exports = function(ctx) { hostname: peer.HostName, ip: peer.TailscaleIPs?.[0], os: peer.OS, - user: peer.UserID + user: peer.UserID, }); } } @@ -114,7 +114,7 @@ module.exports = function(ctx) { ip: status.Self.TailscaleIPs?.[0], os: status.Self.OS, user: status.Self.UserID, - isSelf: true + isSelf: true, }); } @@ -129,7 +129,7 @@ module.exports = function(ctx) { return ctx.errorResponse(res, 400, 'subdomain is required'); } - let content = await ctx.caddy.read(); + const content = await ctx.caddy.read(); const domain = ctx.buildDomain(subdomain); const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's'); @@ -149,7 +149,7 @@ module.exports = function(ctx) { const newConfig = ctx.caddy.generateConfig(subdomain, ip, port || '80', { tailscaleOnly: tailscaleOnly !== false, - allowedIPs: allowedIPs || [] + allowedIPs: allowedIPs || [], }); const caddyResult = await ctx.caddy.modify(c => c.replace(blockRegex, newConfig)); @@ -170,7 +170,7 @@ module.exports = function(ctx) { res.json({ success: true, message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`, - tailscaleOnly: tailscaleOnly !== false + tailscaleOnly: tailscaleOnly !== false, }); }, 'tailscale-protect')); @@ -188,7 +188,7 @@ module.exports = function(ctx) { const tokenRes = await fetch(TAILSCALE.OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials` + body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`, }); if (!tokenRes.ok) { @@ -199,7 +199,7 @@ module.exports = function(ctx) { // Test with the device list to verify scopes const testRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, { - headers: { Authorization: `Bearer ${tokenData.access_token}` } + headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); if (!testRes.ok) { @@ -259,7 +259,7 @@ module.exports = function(ctx) { res.json({ success: true, devices: ctx.tailscale.config.devices || [], - lastSync: ctx.tailscale.config.lastSync + lastSync: ctx.tailscale.config.lastSync, }); }, 'tailscale-api-devices')); @@ -274,7 +274,7 @@ module.exports = function(ctx) { res.json({ success: true, devices: devices || [], - lastSync: ctx.tailscale.config.lastSync + lastSync: ctx.tailscale.config.lastSync, }); }, 'tailscale-sync')); @@ -287,7 +287,7 @@ module.exports = function(ctx) { } const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, { - headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, }); if (!aclRes.ok) { return ctx.errorResponse(res, aclRes.status, `ACL fetch failed: HTTP ${aclRes.status}`); @@ -299,7 +299,7 @@ module.exports = function(ctx) { groups: Object.keys(acl.groups || {}), tagOwners: Object.keys(acl.tagOwners || {}), aclRuleCount: (acl.acls || []).length, - sshRuleCount: (acl.ssh || []).length + sshRuleCount: (acl.ssh || []).length, }; res.json({ success: true, acl, summary }); diff --git a/dashcaddy-api/routes/themes.js b/dashcaddy-api/routes/themes.js index db80d67..fe92822 100644 --- a/dashcaddy-api/routes/themes.js +++ b/dashcaddy-api/routes/themes.js @@ -46,15 +46,15 @@ module.exports = function(ctx) { const themeData = { name, ...colors }; if (lightBg) themeData.lightBg = true; - fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(themeData, null, 2), 'utf8'); + fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(themeData, null, 2), 'utf8'); - res.json({ success: true, message: name + ' theme saved' }); + res.json({ success: true, message: `${name } theme saved` }); }); // Delete a theme router.delete('/themes/:slug', (req, res) => { const { slug } = req.params; - const filePath = path.join(THEMES_DIR, slug + '.json'); + const filePath = path.join(THEMES_DIR, `${slug }.json`); if (!fs.existsSync(filePath)) { return res.status(404).json({ success: false, error: 'Theme not found' }); @@ -64,7 +64,7 @@ module.exports = function(ctx) { const name = data.name || slug; fs.unlinkSync(filePath); - res.json({ success: true, message: name + ' theme deleted' }); + res.json({ success: true, message: `${name } theme deleted` }); }); return router; diff --git a/dashcaddy-api/scripts/webhook-handler.js b/dashcaddy-api/scripts/webhook-handler.js index 2b14fd6..89a889f 100644 --- a/dashcaddy-api/scripts/webhook-handler.js +++ b/dashcaddy-api/scripts/webhook-handler.js @@ -31,7 +31,7 @@ let buildRunning = false; function log(msg) { const line = `[webhook] ${new Date().toISOString()} ${msg}`; console.log(line); - fs.appendFileSync(LOG_FILE, line + '\n'); + fs.appendFileSync(LOG_FILE, `${line }\n`); } function verifySignature(body, signature) { @@ -39,7 +39,7 @@ function verifySignature(body, signature) { const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), - Buffer.from(hmac) + Buffer.from(hmac), ); } @@ -124,7 +124,7 @@ const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ accepted: true })); } catch (e) { - log('Failed to parse webhook payload: ' + e.message); + log(`Failed to parse webhook payload: ${ e.message}`); res.writeHead(400); res.end('Invalid payload'); } diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index c5ba1de..afa4266 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -185,7 +185,7 @@ class SelfUpdater extends EventEmitter { const frontendSrc = this._findDir(stagingDir, 'status'); if (frontendSrc) { await this._copyDir(frontendSrc, this.config.frontendDir, [ - 'dist', 'css', 'assets', 'vendor', 'index.html', 'sw.js' + 'dist', 'css', 'assets', 'vendor', 'index.html', 'sw.js', ]); this.emit('update-progress', { step: 'frontend-updated', version: remoteInfo.version }); } @@ -209,7 +209,7 @@ class SelfUpdater extends EventEmitter { }; await fsp.writeFile( path.join(this.config.updatesDir, 'trigger.json'), - JSON.stringify(trigger, null, 2) + JSON.stringify(trigger, null, 2), ); // The host-side systemd service will handle the rest. @@ -312,7 +312,7 @@ class SelfUpdater extends EventEmitter { this.status = 'waiting'; await fsp.writeFile( path.join(this.config.updatesDir, 'trigger.json'), - JSON.stringify(trigger, null, 2) + JSON.stringify(trigger, null, 2), ); this._addToHistory({ @@ -412,12 +412,12 @@ class SelfUpdater extends EventEmitter { try { resolve(JSON.parse(data)); } catch (e) { - reject(new Error('Invalid JSON from ' + url)); + reject(new Error(`Invalid JSON from ${ url}`)); } }); }); req.on('error', reject); - req.on('timeout', () => { req.destroy(); reject(new Error('Timeout fetching ' + url)); }); + req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout fetching ${ url}`)); }); }); } @@ -459,7 +459,7 @@ class SelfUpdater extends EventEmitter { try { execSync(`tar xzf "${tarballPath}" -C "${destDir}" --strip-components=1`, { stdio: 'pipe' }); } catch (e) { - throw new Error('Failed to extract tarball: ' + e.message); + throw new Error(`Failed to extract tarball: ${ e.message}`); } } diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 400e1cd..10db30e 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -10,7 +10,7 @@ const { execSync } = require('child_process'); const path = require('path'); const { ValidationError, validateFilePath, validateURL, validateToken, - validateServiceConfig, sanitizeString, isValidPort, validateSecurePath + validateServiceConfig, sanitizeString, isValidPort, validateSecurePath, } = require('./input-validator'); const validatorLib = require('validator'); const credentialManager = require('./credential-manager'); @@ -128,7 +128,7 @@ licenseManager.loadSecret(LICENSE_SECRET_FILE); // ===== Site configuration loaded from config.json (#5) ===== // These are read at startup and refreshed on config save. // All code should use these instead of hardcoded values. -let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' }; +const siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' }; function loadSiteConfig() { try { @@ -147,7 +147,7 @@ function loadSiteConfig() { } siteConfig.tld = raw.tld || '.home'; - if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld; + if (!siteConfig.tld.startsWith('.')) siteConfig.tld = `.${ siteConfig.tld}`; siteConfig.caName = raw.caName || ''; siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || ''; siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT; @@ -199,7 +199,7 @@ async function callDns(server, apiPath, params) { const response = await fetchT(url, { method: 'GET', headers: { 'Accept': 'application/json' }, - agent: httpsAgent + agent: httpsAgent, }, TIMEOUTS.HTTP_LONG); return response.json(); } @@ -323,7 +323,7 @@ async function getServiceById(serviceId) { async function findContainerByName(name, opts = { all: false }) { const containers = await docker.listContainers(opts); const match = containers.find(c => - c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())) + c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())), ); return match || null; } @@ -348,7 +348,7 @@ async function requireDnsToken(providedToken) { if (providedToken) return providedToken; const result = await ensureValidDnsToken(); if (result.success) return result.token; - const err = new Error('No valid DNS token available. ' + result.error); + const err = new Error(`No valid DNS token available. ${ result.error}`); err.statusCode = 401; throw err; } @@ -430,9 +430,9 @@ async function logError(context, error, additionalInfo = {}) { error: { message: error.message || error, stack: error.stack, - code: error.code + code: error.code, }, - ...additionalInfo + ...additionalInfo, }; // Format log line with request context @@ -446,7 +446,7 @@ async function logError(context, error, additionalInfo = {}) { try { const stats = await fsp.stat(ERROR_LOG_FILE); if (stats.size > MAX_ERROR_LOG_SIZE) { - const rotated = ERROR_LOG_FILE + '.1'; + const rotated = `${ERROR_LOG_FILE }.1`; if (await exists(rotated)) await fsp.unlink(rotated); await fsp.rename(ERROR_LOG_FILE, rotated); } @@ -519,7 +519,7 @@ let tailscaleConfig = { oauthConfigured: false, // true when OAuth credentials are stored tailnet: null, // tailnet name for API calls (e.g., "example.com" or "-") syncInterval: 300, // seconds between API syncs (default 5 min) - lastSync: null // ISO timestamp of last successful sync + lastSync: null, // ISO timestamp of last successful sync }; // Load Tailscale config from file @@ -605,7 +605,7 @@ async function getTailscaleAccessToken() { const res = await fetch(TAILSCALE.OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials` + body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`, }); if (!res.ok) { @@ -617,7 +617,7 @@ async function getTailscaleAccessToken() { const data = await res.json(); _tsTokenCache = { token: data.access_token, - expiresAt: Date.now() + (data.expires_in || 3600) * 1000 + expiresAt: Date.now() + (data.expires_in || 3600) * 1000, }; return data.access_token; } @@ -629,7 +629,7 @@ async function syncFromTailscaleAPI() { if (!token || !tailnet) return null; const res = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, { - headers: { Authorization: `Bearer ${token}` } + headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`Tailscale API: HTTP ${res.status}`); @@ -647,7 +647,7 @@ async function syncFromTailscaleAPI() { tags: d.tags || [], lastSeen: d.lastSeen, clientVersion: d.clientVersion, - isExternal: d.isExternal || false + isExternal: d.isExternal || false, })); tailscaleConfig.devices = devices; @@ -670,7 +670,7 @@ function startTailscaleSyncTimer() { log.warn('tailscale', 'API sync failed', { error: error.message }); } }, interval); - log.info('tailscale', 'API sync enabled', { interval: interval / 1000 + 's' }); + log.info('tailscale', 'API sync enabled', { interval: `${interval / 1000 }s` }); } function stopTailscaleSyncTimer() { @@ -681,10 +681,10 @@ function stopTailscaleSyncTimer() { } // TOTP authentication configuration -let totpConfig = { +const totpConfig = { enabled: false, sessionDuration: 'never', // 'never' = disabled, or '15m','30m','1h','2h','4h','8h','12h','24h' - isSetUp: false // true once a secret has been verified + isSetUp: false, // true once a secret has been verified }; async function loadTotpConfig() { @@ -725,20 +725,20 @@ let notificationConfig = { providers: { discord: { enabled: false, webhookUrl: '' }, telegram: { enabled: false, botToken: '', chatId: '' }, - ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' } + ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' }, }, events: { containerDown: true, containerUp: true, deploymentSuccess: true, deploymentFailed: true, - serviceError: true + serviceError: true, }, healthCheck: { enabled: false, intervalMinutes: 5, - lastCheck: null - } + lastCheck: null, + }, }; // Notification history (in-memory, last 100 entries) @@ -801,7 +801,7 @@ async function saveNotificationConfig() { function addNotificationToHistory(notification) { notificationHistory.unshift({ ...notification, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); if (notificationHistory.length > MAX_NOTIFICATION_HISTORY) { notificationHistory = notificationHistory.slice(0, MAX_NOTIFICATION_HISTORY); @@ -817,7 +817,7 @@ async function sendDiscordNotification(title, message, type = 'info') { success: 0x00ff00, // Green error: 0xff0000, // Red warning: 0xffff00, // Yellow - info: 0x0099ff // Blue + info: 0x0099ff, // Blue }; const payload = { @@ -826,15 +826,15 @@ async function sendDiscordNotification(title, message, type = 'info') { description: message, color: colors[type] || colors.info, timestamp: new Date().toISOString(), - footer: { text: 'DashCaddy Notifications' } - }] + footer: { text: 'DashCaddy Notifications' }, + }], }; try { const response = await fetchT(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(payload), }); if (!response.ok) { @@ -857,7 +857,7 @@ async function sendTelegramNotification(title, message, type = 'info') { success: '✅', error: '❌', warning: '⚠️', - info: 'ℹ️' + info: 'ℹ️', }; const text = `${emoji[type] || emoji.info} *DashCaddy: ${title}*\n\n${message}`; @@ -869,8 +869,8 @@ async function sendTelegramNotification(title, message, type = 'info') { body: JSON.stringify({ chat_id: chatId, text: text, - parse_mode: 'Markdown' - }) + parse_mode: 'Markdown', + }), }); const result = await response.json(); @@ -894,14 +894,14 @@ async function sendNtfyNotification(title, message, type = 'info') { success: 3, // default error: 5, // max warning: 4, // high - info: 3 // default + info: 3, // default }; const tags = { success: 'white_check_mark', error: 'x', warning: 'warning', - info: 'information_source' + info: 'information_source', }; try { @@ -910,9 +910,9 @@ async function sendNtfyNotification(title, message, type = 'info') { headers: { 'Title': `DashCaddy: ${title}`, 'Priority': String(priority[type] || 3), - 'Tags': tags[type] || 'information_source' + 'Tags': tags[type] || 'information_source', }, - body: message + body: message, }); if (!response.ok) { @@ -958,14 +958,14 @@ async function sendNotification(event, title, message, type = 'info') { title, message, type, - results + results, }); return { sent: true, results }; } // Container health monitoring state -let containerHealthState = {}; +const containerHealthState = {}; let healthCheckInterval = null; // Check container health and send notifications @@ -1003,7 +1003,7 @@ async function checkContainerHealth() { 'containerUp', 'Container Recovered', `**${serviceName}** is now running again.`, - 'success' + 'success', ); } else { // Container went down @@ -1011,7 +1011,7 @@ async function checkContainerHealth() { 'containerDown', 'Container Down', `**${serviceName}** has stopped running.\nStatus: ${container.Status}`, - 'error' + 'error', ); } } @@ -1082,13 +1082,13 @@ const middlewareResult = configureMiddleware(app, { siteConfig, totpConfig, tailscaleConfig, metrics, auditLogger, authManager, log, cryptoUtils, isValidContainerId, isTailscaleIP, getTailscaleStatus, - RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache + RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache, }); const { strictLimiter, SESSION_DURATIONS, ipSessions, getClientIP, createIPSession, setSessionCookie, - clearIPSession, clearSessionCookie, isSessionValid + clearIPSession, clearSessionCookie, isSessionValid, } = middlewareResult; // ── Populate route context and mount extracted route modules ── @@ -1280,7 +1280,7 @@ app.get('/probe/:id', asyncHandler(async (req, res) => { const fReq = fLib.request({ hostname: fp.hostname, port: 443, path: '/', method: 'GET', timeout: 5000, agent: httpsAgent, - headers: { 'User-Agent': APP.USER_AGENTS.PROBE } + headers: { 'User-Agent': APP.USER_AGENTS.PROBE }, }, (fRes) => { fRes.resume(); resolve(fRes.statusCode); }); fReq.on('error', reject); fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); }); @@ -1305,7 +1305,7 @@ app.get('/api/network/ips', (req, res) => { localhost: '127.0.0.1', lan: envLan || null, tailscale: envTailscale || null, - all: [] + all: [], }; // If env vars not set, try to detect from network interfaces @@ -1364,7 +1364,7 @@ async function refreshDnsToken(username, password, server) { const params = new URLSearchParams({ user: username, pass: password, - includeInfo: 'false' + includeInfo: 'false', }); const response = await fetchT( @@ -1373,10 +1373,10 @@ async function refreshDnsToken(username, password, server) { method: 'POST', headers: { 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', }, - timeout: 10000 - } + timeout: 10000, + }, ); const result = await response.json(); @@ -1436,7 +1436,7 @@ async function ensureValidDnsToken() { return { success: false, - error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials' + error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials', }; } @@ -1466,7 +1466,7 @@ async function getTokenForServer(targetServer, role = 'readonly') { const params = new URLSearchParams({ user: username, pass: password, - includeInfo: 'false' + includeInfo: 'false', }); const response = await fetchT( @@ -1475,9 +1475,9 @@ async function getTokenForServer(targetServer, role = 'readonly') { method: 'POST', headers: { 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded' - } - } + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, ); const result = await response.json(); @@ -1485,7 +1485,7 @@ async function getTokenForServer(targetServer, role = 'readonly') { if (result.status === 'ok' && result.token) { dnsServerTokens.set(cacheKey, { token: result.token, - expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString() + expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString(), }); log.info('dns', 'DNS token obtained for server', { server: targetServer, role }); return { success: true, token: result.token }; @@ -1575,13 +1575,13 @@ function generateCaddyConfig(subdomain, ip, port, options = {}) { } if (tailscaleOnly) { - config += `\t\t@blocked not remote_ip 100.64.0.0/10`; + config += '\t\t@blocked not remote_ip 100.64.0.0/10'; if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; - config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`; + config += '\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n'; } config += `\t\treverse_proxy ${ip}:${port}\n`; - config += `\t}`; + config += '\t}'; return config; } @@ -1589,16 +1589,16 @@ function generateCaddyConfig(subdomain, ip, port, options = {}) { let config = `${buildDomain(subdomain)} {\n`; if (tailscaleOnly) { - config += ` @blocked not remote_ip 100.64.0.0/10`; + config += ' @blocked not remote_ip 100.64.0.0/10'; if (allowedIPs.length > 0) { config += ` ${allowedIPs.join(' ')}`; } - config += `\n respond @blocked "Access denied. Tailscale connection required." 403\n`; + config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n'; } config += ` reverse_proxy ${ip}:${port}\n`; - config += ` tls internal\n`; - config += `}`; + config += ' tls internal\n'; + config += '}'; return config; } @@ -1614,7 +1614,7 @@ async function reloadCaddy(content) { const response = await fetchT(`${CADDY_ADMIN_URL}/load`, { method: 'POST', headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: content + body: content, }); if (response.ok) { @@ -1648,7 +1648,7 @@ async function verifySiteAccessible(domain, maxAttempts = 5) { const response = await fetchT(`https://${domain}/`, { method: 'HEAD', agent: httpsAgent, // Ignore cert errors for internal CA - timeout: 5000 + timeout: 5000, }); // Any response (even 4xx) means Caddy is serving the site @@ -1782,14 +1782,14 @@ app.use((err, req, res, next) => { success: false, error: err.message, code: err.code, - ...(err.details ? { details: err.details } : {}) + ...(err.details ? { details: err.details } : {}), }); } if (err instanceof ValidationError) { return res.status(err.statusCode || 400).json({ success: false, error: err.message, - errors: err.errors || undefined + errors: err.errors || undefined, }); } // Catch-all: never leak stack traces or internal paths @@ -1803,150 +1803,150 @@ module.exports = app; if (require.main === module) { // Validate configuration and wait for async config loads before starting server -(async () => { -await Promise.all([_configsReady, _notificationsReady]); -await licenseManager.load(); -await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }); - -const server = app.listen(PORT, '0.0.0.0', () => { - log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE }); - if (BROWSE_ROOTS.length > 0) { - log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) }); - } - - // Start new feature modules - log.info('server', 'Starting DashCaddy feature modules'); - - // Clean up stale port locks (async () => { - try { - await portLockManager.cleanupStaleLocks(); - log.info('server', 'Port lock cleanup completed'); - } catch (error) { - log.error('server', 'Port lock cleanup failed', { error: error.message }); - } - })(); + await Promise.all([_configsReady, _notificationsReady]); + await licenseManager.load(); + await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }); - try { - resourceMonitor.start(); - log.info('server', 'Resource monitoring started'); - } catch (error) { - log.error('server', 'Resource monitoring failed to start', { error: error.message }); - } + const server = app.listen(PORT, '0.0.0.0', () => { + log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE }); + if (BROWSE_ROOTS.length > 0) { + log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) }); + } - try { - backupManager.start(); - log.info('server', 'Backup manager started'); - } catch (error) { - log.error('server', 'Backup manager failed to start', { error: error.message }); - } - - (async () => { - try { - // Auto-configure health checker from services.json - await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }); - healthChecker.start(); - log.info('server', 'Health checker started'); - } catch (error) { - log.error('server', 'Health checker failed to start', { error: error.message }); - } - })(); - - try { - updateManager.start(); - log.info('server', 'Update manager started'); - } catch (error) { - log.error('server', 'Update manager failed to start', { error: error.message }); - } + // Start new feature modules + log.info('server', 'Starting DashCaddy feature modules'); - try { - selfUpdater.start(); - log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl }); - // Check for post-update result (did a previous update succeed or roll back?) - selfUpdater.checkPostUpdateResult().then(result => { - if (result) { - log.info('server', 'Post-update result', result); - if (typeof ctx.notification?.send === 'function') { - ctx.notification.send('system.update', - result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed', - result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`, - result.success ? 'info' : 'error' - ); + // Clean up stale port locks + (async () => { + try { + await portLockManager.cleanupStaleLocks(); + log.info('server', 'Port lock cleanup completed'); + } catch (error) { + log.error('server', 'Port lock cleanup failed', { error: error.message }); + } + })(); + + try { + resourceMonitor.start(); + log.info('server', 'Resource monitoring started'); + } catch (error) { + log.error('server', 'Resource monitoring failed to start', { error: error.message }); + } + + try { + backupManager.start(); + log.info('server', 'Backup manager started'); + } catch (error) { + log.error('server', 'Backup manager failed to start', { error: error.message }); + } + + (async () => { + try { + // Auto-configure health checker from services.json + await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }); + healthChecker.start(); + log.info('server', 'Health checker started'); + } catch (error) { + log.error('server', 'Health checker failed to start', { error: error.message }); + } + })(); + + try { + updateManager.start(); + log.info('server', 'Update manager started'); + } catch (error) { + log.error('server', 'Update manager failed to start', { error: error.message }); + } + + try { + selfUpdater.start(); + log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl }); + // Check for post-update result (did a previous update succeed or roll back?) + selfUpdater.checkPostUpdateResult().then(result => { + if (result) { + log.info('server', 'Post-update result', result); + if (typeof ctx.notification?.send === 'function') { + ctx.notification.send('system.update', + result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed', + result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`, + result.success ? 'info' : 'error', + ); + } + } + }).catch(() => {}); + } catch (error) { + log.error('server', 'Self-updater failed to start', { error: error.message }); + } + + if (dockerMaintenance) { + try { + dockerMaintenance.start(); + log.info('server', 'Docker maintenance started'); + dockerMaintenance.on('maintenance-complete', (result) => { + const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024); + if (saved > 0 || result.warnings.length > 0) { + log.info('maintenance', 'Docker maintenance completed', { + spaceReclaimedMB: saved, + pruned: result.pruned, + warnings: result.warnings.length, + }); + } + if (result.warnings.length > 0) { + for (const w of result.warnings) log.warn('maintenance', w); + } + }); + } catch (error) { + log.error('server', 'Docker maintenance failed to start', { error: error.message }); } } - }).catch(() => {}); - } catch (error) { - log.error('server', 'Self-updater failed to start', { error: error.message }); - } - - if (dockerMaintenance) { - try { - dockerMaintenance.start(); - log.info('server', 'Docker maintenance started'); - dockerMaintenance.on('maintenance-complete', (result) => { - const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024); - if (saved > 0 || result.warnings.length > 0) { - log.info('maintenance', 'Docker maintenance completed', { - spaceReclaimedMB: saved, - pruned: result.pruned, - warnings: result.warnings.length + + if (logDigest) { + try { + logDigest.start(platformPaths.digestDir); + log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); + logDigest.on('digest-generated', ({ date }) => { + log.info('digest', `Daily digest generated for ${date}`); + if (typeof ctx.notification?.send === 'function') { + ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info'); + } }); + } catch (error) { + log.error('server', 'Log digest failed to start', { error: error.message }); } - if (result.warnings.length > 0) { - for (const w of result.warnings) log.warn('maintenance', w); - } + } + + // Tailscale API sync (if OAuth configured) + if (tailscaleConfig.oauthConfigured) { + startTailscaleSyncTimer(); + // Run initial sync + syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message })); + } + + log.info('server', 'All feature modules initialized'); + }); + + // Graceful shutdown — drain connections before exiting + function shutdown(signal) { + log.info('shutdown', `${signal} received, draining connections...`); + resourceMonitor.stop(); + backupManager.stop(); + if (dockerMaintenance) dockerMaintenance.stop(); + if (logDigest) logDigest.stop(); + healthChecker.stop(); + updateManager.stop(); + selfUpdater.stop(); + stopTailscaleSyncTimer(); + server.close(() => { + log.info('shutdown', 'HTTP server closed'); + process.exit(0); }); - } catch (error) { - log.error('server', 'Docker maintenance failed to start', { error: error.message }); + // Force exit after 5s if connections don't drain + setTimeout(() => process.exit(0), 5000).unref(); } - } - - if (logDigest) { - try { - logDigest.start(platformPaths.digestDir); - log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); - logDigest.on('digest-generated', ({ date }) => { - log.info('digest', `Daily digest generated for ${date}`); - if (typeof ctx.notification?.send === 'function') { - ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info'); - } - }); - } catch (error) { - log.error('server', 'Log digest failed to start', { error: error.message }); - } - } - - // Tailscale API sync (if OAuth configured) - if (tailscaleConfig.oauthConfigured) { - startTailscaleSyncTimer(); - // Run initial sync - syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message })); - } - - log.info('server', 'All feature modules initialized'); -}); - -// Graceful shutdown — drain connections before exiting -function shutdown(signal) { - log.info('shutdown', `${signal} received, draining connections...`); - resourceMonitor.stop(); - backupManager.stop(); - if (dockerMaintenance) dockerMaintenance.stop(); - if (logDigest) logDigest.stop(); - healthChecker.stop(); - updateManager.stop(); - selfUpdater.stop(); - stopTailscaleSyncTimer(); - server.close(() => { - log.info('shutdown', 'HTTP server closed'); - process.exit(0); - }); - // Force exit after 5s if connections don't drain - setTimeout(() => process.exit(0), 5000).unref(); -} -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); -})(); // end async startup + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + })(); // end async startup } // end if (require.main === module) // #2: Catch unhandled errors so the process doesn't crash silently diff --git a/dashcaddy-api/startup-validator.js b/dashcaddy-api/startup-validator.js index 11c3e57..a2e72dd 100644 --- a/dashcaddy-api/startup-validator.js +++ b/dashcaddy-api/startup-validator.js @@ -108,7 +108,7 @@ async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFI port: urlObj.port, path: '/config/', method: 'GET', - timeout: 2000 + timeout: 2000, }, (res) => { resolve(res.statusCode >= 200 && res.statusCode < 500); }); diff --git a/dashcaddy-api/state-manager.js b/dashcaddy-api/state-manager.js index ed963ca..28dc8e6 100644 --- a/dashcaddy-api/state-manager.js +++ b/dashcaddy-api/state-manager.js @@ -27,9 +27,9 @@ class StateManager { retries: { retries: options.lockRetries || 10, minTimeout: options.lockRetryInterval || 100, - maxTimeout: (options.lockRetryInterval || 100) * 3 + maxTimeout: (options.lockRetryInterval || 100) * 3, }, - stale: options.lockTimeout || 30000 // 30 seconds + stale: options.lockTimeout || 30000, // 30 seconds }; // Ensure file exists diff --git a/dashcaddy-api/test-security-fixes.js b/dashcaddy-api/test-security-fixes.js index 3186b5d..ca5cb91 100644 --- a/dashcaddy-api/test-security-fixes.js +++ b/dashcaddy-api/test-security-fixes.js @@ -26,7 +26,7 @@ const colors = { red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m', - cyan: '\x1b[36m' + cyan: '\x1b[36m', }; function log(message, color = 'reset') { @@ -56,7 +56,7 @@ async function makeRequest(path, options = {}) { path: url.pathname + url.search, method: options.method || 'GET', headers: options.headers || {}, - ...options + ...options, }; const req = client.request(requestOptions, (res) => { @@ -67,7 +67,7 @@ async function makeRequest(path, options = {}) { statusCode: res.statusCode, headers: res.headers, body: data, - data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null + data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null, }); }); }); @@ -90,7 +90,7 @@ async function testPathTraversal() { { path: '/api/browse/directories?path=../../../../../../etc/passwd', desc: 'Unix path traversal' }, { path: '/api/browse/directories?path=..\\..\\..\\Windows\\System32', desc: 'Windows path traversal' }, { path: '/api/browse/directories?path=%2e%2e%2f%2e%2e%2fetc%2fpasswd', desc: 'URL-encoded traversal' }, - { path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' } + { path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' }, ]; for (const attack of attacks) { @@ -117,7 +117,7 @@ async function testRequestSizeLimits() { const response = await makeRequest('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(smallPayload) + body: JSON.stringify(smallPayload), }); logResult(true, 'Small payload accepted (100 bytes)'); } catch (error) { @@ -130,7 +130,7 @@ async function testRequestSizeLimits() { const response = await makeRequest('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(largePayload) + body: JSON.stringify(largePayload), }); if (response.statusCode === 413 || response.statusCode === 400) { logResult(true, 'Large payload rejected on general endpoint (2MB)'); @@ -151,7 +151,7 @@ async function testRequestSizeLimits() { const response = await makeRequest('/api/logo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ logo: largeImage }) + body: JSON.stringify({ logo: largeImage }), }); if (response.statusCode !== 413) { logResult(true, 'Large payload accepted on logo endpoint (5MB)'); diff --git a/dashcaddy-api/update-manager.js b/dashcaddy-api/update-manager.js index d8ac7bd..50f4088 100644 --- a/dashcaddy-api/update-manager.js +++ b/dashcaddy-api/update-manager.js @@ -83,7 +83,7 @@ class UpdateManager extends EventEmitter { currentDigest: currentDigest.substring(0, 12), latestDigest: latestDigest.substring(0, 12), currentTag: this.extractTag(imageName), - detectedAt: new Date().toISOString() + detectedAt: new Date().toISOString(), }); this.emit('update-available', this.availableUpdates.get(containerInfo.Id)); @@ -137,8 +137,8 @@ class UpdateManager extends EventEmitter { path: `/v2/${repo}/manifests/${tag}`, method: 'GET', headers: { - 'Accept': 'application/vnd.docker.distribution.manifest.v2+json' - } + 'Accept': 'application/vnd.docker.distribution.manifest.v2+json', + }, }; const req = https.request(options, (res) => { @@ -206,8 +206,8 @@ class UpdateManager extends EventEmitter { ...originalOptions, headers: { ...originalOptions.headers, - 'Authorization': `Bearer ${token}` - } + 'Authorization': `Bearer ${token}`, + }, }; const req = https.request(options, (res) => { @@ -271,7 +271,7 @@ class UpdateManager extends EventEmitter { config: inspect.Config, hostConfig: inspect.HostConfig, networkSettings: inspect.NetworkSettings, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; // Pull latest image @@ -292,7 +292,7 @@ class UpdateManager extends EventEmitter { name: containerName, Image: imageName, ...backup.config, - HostConfig: backup.hostConfig + HostConfig: backup.hostConfig, }); // Start new container @@ -300,7 +300,7 @@ class UpdateManager extends EventEmitter { await newContainer.start(); // Extended verification with health checks and port accessibility - console.log(`[UpdateManager] Performing extended verification...`); + console.log('[UpdateManager] Performing extended verification...'); await this.verifyContainerExtended(newContainer, inspect, options.verifyTimeout || 60000); // Get new image ID @@ -313,7 +313,7 @@ class UpdateManager extends EventEmitter { console.log(`[UpdateManager] Removing old image: ${oldImageId.substring(0, 12)}`); const oldImage = docker.getImage(oldImageId); await oldImage.remove({ force: false }); - console.log(`[UpdateManager] Old image removed successfully`); + console.log('[UpdateManager] Old image removed successfully'); } catch (error) { console.warn(`[UpdateManager] Could not remove old image (may be in use): ${error.message}`); } @@ -330,7 +330,7 @@ class UpdateManager extends EventEmitter { timestamp: new Date().toISOString(), duration, status: 'success', - backup + backup, }; this.addToHistory(historyEntry); @@ -348,7 +348,7 @@ class UpdateManager extends EventEmitter { timestamp: new Date().toISOString(), duration, status: 'failed', - error: error.message + error: error.message, }; this.addToHistory(historyEntry); @@ -360,7 +360,7 @@ class UpdateManager extends EventEmitter { try { await this.rollbackUpdate(containerId); } catch (rollbackError) { - console.error(`[UpdateManager] Rollback failed:`, rollbackError.message); + console.error('[UpdateManager] Rollback failed:', rollbackError.message); } } @@ -448,7 +448,7 @@ class UpdateManager extends EventEmitter { // Step 2: Check Docker health check if available if (inspect.State.Health) { if (inspect.State.Health.Status === 'healthy') { - console.log(`[UpdateManager] Container health check: healthy`); + console.log('[UpdateManager] Container health check: healthy'); return true; } else if (inspect.State.Health.Status === 'unhealthy') { lastError = 'Container health check failed (unhealthy)'; @@ -468,7 +468,7 @@ class UpdateManager extends EventEmitter { try { const response = await fetch(testUrl, { signal: AbortSignal.timeout(3000), - redirect: 'manual' + redirect: 'manual', }); // Accept 2xx, 3xx, 4xx as "accessible" (server is responding) @@ -477,7 +477,7 @@ class UpdateManager extends EventEmitter { // Wait a bit more to ensure stability if (attempt >= 2) { - console.log(`[UpdateManager] Container verified successfully`); + console.log('[UpdateManager] Container verified successfully'); return true; } } @@ -488,7 +488,7 @@ class UpdateManager extends EventEmitter { } else { // No ports exposed - just verify it's running for a few cycles if (attempt >= 5) { - console.log(`[UpdateManager] Container running without exposed ports (verified)`); + console.log('[UpdateManager] Container running without exposed ports (verified)'); return true; } } @@ -529,7 +529,7 @@ class UpdateManager extends EventEmitter { ports.push({ containerPort: containerPort.split('/')[0], hostPort: binding.HostPort, - protocol: containerPort.split('/')[1] || 'tcp' + protocol: containerPort.split('/')[1] || 'tcp', }); } } @@ -572,7 +572,7 @@ class UpdateManager extends EventEmitter { name: backup.containerName, Image: backup.imageName, ...backup.config, - HostConfig: backup.hostConfig + HostConfig: backup.hostConfig, }); await newContainer.start(); @@ -582,7 +582,7 @@ class UpdateManager extends EventEmitter { return true; } catch (error) { - console.error(`[UpdateManager] Rollback failed:`, error.message); + console.error('[UpdateManager] Rollback failed:', error.message); throw error; } } @@ -599,7 +599,7 @@ class UpdateManager extends EventEmitter { setTimeout(() => { this.updateContainer(containerId).catch(error => { - console.error(`[UpdateManager] Scheduled update failed:`, error.message); + console.error('[UpdateManager] Scheduled update failed:', error.message); }); }, delay); @@ -663,20 +663,20 @@ class UpdateManager extends EventEmitter { shortDescription: repoInfo?.description?.substring(0, 200) || '', starCount: repoInfo?.star_count || 0, pullCount: repoInfo?.pull_count || 0, - lastUpdated: repoInfo?.last_updated || null + lastUpdated: repoInfo?.last_updated || null, }, tags: tags.slice(0, 10).map(t => ({ name: t.name, lastPushed: t.last_pushed || t.tag_last_pushed, digest: t.digest?.substring(0, 12) || 'unknown', - size: t.full_size || t.size || 0 + size: t.full_size || t.size || 0, })), urls: { dockerHub: hubUrl, tags: `${hubUrl}/tags`, - dockerfile: repoInfo?.dockerfile_url || null + dockerfile: repoInfo?.dockerfile_url || null, }, - changelog: this.formatChangelog(repoInfo, tags, imageTag) + changelog: this.formatChangelog(repoInfo, tags, imageTag), }; } catch (error) { console.error(`[UpdateManager] Error fetching changelog for ${imageName}:`, error.message); @@ -691,7 +691,7 @@ class UpdateManager extends EventEmitter { urls: { dockerHub: `https://hub.docker.com/r/${repoPath.replace('library/', '_/')}`, }, - changelog: 'Unable to fetch changelog. Visit Docker Hub for details.' + changelog: 'Unable to fetch changelog. Visit Docker Hub for details.', }; } } @@ -711,8 +711,8 @@ class UpdateManager extends EventEmitter { method: 'GET', headers: { 'Accept': 'application/json', - 'User-Agent': 'DashCaddy/1.0' - } + 'User-Agent': 'DashCaddy/1.0', + }, }; const req = https.request(options, (res) => { @@ -755,8 +755,8 @@ class UpdateManager extends EventEmitter { method: 'GET', headers: { 'Accept': 'application/json', - 'User-Agent': 'DashCaddy/1.0' - } + 'User-Agent': 'DashCaddy/1.0', + }, }; const req = https.request(options, (res) => { @@ -836,7 +836,7 @@ class UpdateManager extends EventEmitter { schedule: config.schedule || 'weekly', maintenanceWindow: config.maintenanceWindow, autoRollback: config.autoRollback !== false, - securityOnly: config.securityOnly || false + securityOnly: config.securityOnly || false, }; this.saveConfig(); From 039d3d07e2be2f1e118d1040b609d2b02939ebbc Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:01:40 +0100 Subject: [PATCH 02/11] Phase 1: Add dependency analysis documentation --- dashcaddy-api/DEPENDENCIES.md | 344 ++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 dashcaddy-api/DEPENDENCIES.md diff --git a/dashcaddy-api/DEPENDENCIES.md b/dashcaddy-api/DEPENDENCIES.md new file mode 100644 index 0000000..18c08a3 --- /dev/null +++ b/dashcaddy-api/DEPENDENCIES.md @@ -0,0 +1,344 @@ +# DashCaddy API Dependencies Map + +**Generated:** 2026-03-22 +**Purpose:** Document current `ctx` (context object) usage across routes to guide refactoring + +--- + +## Overview + +The DashCaddy API currently uses a **god object pattern** where a single `ctx` (context) object is passed to all route modules. This object contains 50+ properties mixing: + +- Utilities (asyncHandler, errorResponse, log) +- Domain objects (docker, caddy, dns, session) +- Managers (credentialManager, authManager, healthChecker) +- Configuration (SERVICES_FILE, CONFIG_FILE, siteConfig) +- Template data (APP_TEMPLATES, RECIPE_TEMPLATES) + +**Problem:** Routes don't declare what they actually need — dependencies are hidden, making testing and refactoring difficult. + +**Goal:** Move to explicit dependency injection where each route declares exactly what it needs. + +--- + +## Most-Used Properties (Refactor Priority) + +| Property | Routes Using It | Type | Notes | +|----------|-----------------|------|-------| +| `ctx.asyncHandler` | 35 | Utility | Wrap async route handlers, universal dependency | +| `ctx.errorResponse` | 27 | Utility | Standard error formatting | +| `ctx.log` | 20 | Utility | Logger instance | +| `ctx.docker` | 15 | Domain | Dockerode wrapper + helpers | +| `ctx.caddy` | 12 | Domain | Caddyfile manipulation + reload | +| `ctx.fetchT` | 12 | Utility | Timeout-wrapped fetch | +| `ctx.servicesStateManager` | 12 | Manager | services.json state management | +| `ctx.credentialManager` | 12 | Manager | Encrypted credential storage | +| `ctx.siteConfig` | 10 | Config | Site-wide configuration | +| `ctx.buildDomain` | 9 | Utility | Domain construction helper | +| `ctx.dns` | 9 | Domain | DNS API wrapper | + +--- + +## Route Dependencies (By Route File) + +### High-Complexity Routes (10+ dependencies) + +#### `apps/deploy.js` (15 dependencies) +```javascript +// Current +module.exports = (ctx) => { ... }; + +// After refactor (explicit deps) +module.exports = ({ + APP_TEMPLATES, + addServiceToConfig, + asyncHandler, + buildDomain, + buildServiceUrl, + caddy, + dns, + docker, + errorResponse, + log, + logError, + notification, + portLockManager, + safeErrorMessage, + siteConfig, +}) => { ... }; +``` + +**Refactor Priority:** HIGH (core deployment logic, frequently used) + +--- + +#### `config/backup.js` (16 dependencies) +```javascript +// Dependencies +CONFIG_FILE, NOTIFICATIONS_FILE, SERVICES_FILE, TAILSCALE_CONFIG_FILE, +TOTP_CONFIG_FILE, asyncHandler, caddy, credentialManager, dns, errorResponse, +fetchT, loadDnsCredentials, loadNotificationConfig, loadSiteConfig, log, totpConfig +``` + +**Refactor Priority:** MEDIUM (backup/restore is critical but less frequent) +**Opportunity:** Extract backup logic to service layer + +--- + +#### `services.js` (13 dependencies) +```javascript +// Dependencies +SERVICES_FILE, asyncHandler, buildDomain, buildServiceUrl, caddy, +credentialManager, dns, errorResponse, log, resyncHealthChecker, +safeErrorMessage, servicesStateManager, siteConfig +``` + +**Refactor Priority:** HIGH (core service management, most-used route) + +--- + +### Medium-Complexity Routes (6-9 dependencies) + +- `apps/restore.js` (9 deps) +- `auth/sso-gate.js` (10 deps) +- `dns.js` (8 deps) +- `health.js` (8 deps) +- `recipes/manage.js` (8 deps) +- `sites.js` (12 deps) +- `tailscale.js` (9 deps) + +--- + +### Low-Complexity Routes (1-5 dependencies) + +Good candidates for early refactoring (simpler, less risky): + +- `backups.js` (2 deps: asyncHandler, backupManager) +- `containers.js` (3 deps: asyncHandler, docker, log) +- `credentials.js` (3 deps: asyncHandler, credentialManager, errorResponse) +- `license.js` (3 deps: asyncHandler, errorResponse, licenseManager) +- `monitoring.js` (3 deps: asyncHandler, docker, resourceMonitor) +- `notifications.js` (3 deps: asyncHandler, errorResponse, notification) + +--- + +## Domain Object Breakdown + +### `ctx.docker` (used by 15 routes) +**Properties/Methods:** +- `find()` — find container by name +- `getUsedPorts()` — list ports in use +- `security` — docker-security module + +**Routes:** +- apps/deploy.js, apps/helpers.js, apps/removal.js, apps/restore.js, apps/templates.js +- arr/config.js, arr/detect.js, arr/helpers.js +- browse.js, containers.js, context.js, logs.js, monitoring.js +- recipes/deploy.js, recipes/manage.js + +**Refactor Suggestion:** Extract to standalone service (`services/docker-service.js`) + +--- + +### `ctx.caddy` (used by 12 routes) +**Properties/Methods:** +- `modify()` — modify Caddyfile +- `read()` — read Caddyfile +- `reload()` — reload Caddy +- `generateConfig()` — generate route config +- `verifySite()` — test site accessibility +- `adminUrl`, `filePath` — config properties + +**Routes:** +- apps/deploy.js, apps/helpers.js, apps/removal.js, apps/restore.js, apps/templates.js +- config/backup.js, context.js +- recipes/deploy.js, recipes/manage.js +- services.js, sites.js, tailscale.js + +**Refactor Suggestion:** Extract to `services/caddy-service.js` + +--- + +### `ctx.dns` (used by 9 routes) +**Properties/Methods:** +- `call()` — DNS API call +- `buildUrl()` — construct DNS API URL +- `requireToken()` — token validation +- `ensureToken()` — auto-refresh token if needed +- `createRecord()` — create DNS record +- `getToken()`, `setToken()` — token management +- `refresh()` — force token refresh + +**Routes:** +- apps/deploy.js, apps/removal.js, apps/restore.js, apps/templates.js +- config/backup.js, context.js, dns.js +- services.js, sites.js + +**Refactor Suggestion:** Extract to `services/dns-service.js` + +--- + +## Utility Functions (Universal Dependencies) + +### `ctx.asyncHandler` (35 routes) +**What it does:** Wraps async Express route handlers to catch errors + +**Current pattern:** +```javascript +router.get('/endpoint', ctx.asyncHandler(async (req, res) => { + // ... async logic +})); +``` + +**Refactor suggestion:** Keep as utility, but import directly instead of via ctx +```javascript +const { asyncHandler } = require('../utils/async-handler'); + +router.get('/endpoint', asyncHandler(async (req, res) => { + // ... async logic +})); +``` + +--- + +### `ctx.log` (20 routes) +**What it does:** Structured logger (log.info, log.error, log.warn) + +**Refactor suggestion:** Import logger utility directly +```javascript +const log = require('../utils/logger'); +``` + +--- + +### `ctx.errorResponse` (27 routes) +**What it does:** Standard error response formatting + +**Refactor suggestion:** Utility function, import directly +```javascript +const { errorResponse } = require('../utils/responses'); +``` + +--- + +## Manager Objects + +### `ctx.credentialManager` (12 routes) +**What it does:** AES-256-GCM encrypted credential storage + +**Routes using it:** +- arr/credentials.js, arr/detect.js, arr/helpers.js +- auth/sso-gate.js, auth/totp.js +- config/backup.js, credentials.js, dns.js +- services.js, tailscale.js + +**Refactor suggestion:** Keep as manager, inject explicitly + +--- + +### `ctx.servicesStateManager` (12 routes) +**What it does:** Thread-safe services.json state management + +**Refactor suggestion:** Keep as manager, inject explicitly + +--- + +### `ctx.healthChecker` (1 route: health.js) +**Refactor suggestion:** Already well-isolated, just make explicit + +--- + +## Configuration Objects + +### `ctx.siteConfig` (10 routes) +**What it does:** Site-wide configuration (domain, TLS, etc.) + +**Refactor suggestion:** Load once at app startup, inject where needed + +--- + +### File path constants (used by 6 routes) +- `SERVICES_FILE` (6 routes) +- `CONFIG_FILE` (3 routes) +- `TOTP_CONFIG_FILE` (1 route) +- `NOTIFICATIONS_FILE` (1 route) +- `ERROR_LOG_FILE` (1 route) + +**Refactor suggestion:** Move to `src/config/paths.js`, import directly + +--- + +## Refactoring Strategy + +### Phase 1: Low-Risk Routes (Start Here) +Refactor simple routes with 1-5 dependencies: +1. `backups.js` (2 deps) +2. `containers.js` (3 deps) +3. `credentials.js` (3 deps) +4. `license.js` (3 deps) +5. `monitoring.js` (3 deps) + +**Why:** Low complexity, easier to verify correctness, builds confidence + +--- + +### Phase 2: Extract Common Utilities +Extract universal dependencies to standalone modules: +1. `utils/async-handler.js` (used by 35 routes) +2. `utils/responses.js` (errorResponse, ok, etc.) +3. `utils/logger.js` (structured logging) +4. `utils/http.js` (fetchT wrapper) + +**Why:** Reduces ctx bloat, makes imports explicit + +--- + +### Phase 3: Extract Domain Services +Convert domain objects to services: +1. `services/docker-service.js` (15 routes) +2. `services/caddy-service.js` (12 routes) +3. `services/dns-service.js` (9 routes) + +**Why:** Separate business logic from HTTP handlers, testable in isolation + +--- + +### Phase 4: High-Complexity Routes +Refactor routes with 10+ dependencies: +1. `services.js` (13 deps) — most-used route +2. `apps/deploy.js` (15 deps) — core deployment +3. `config/backup.js` (16 deps) — extract backup service +4. `sites.js` (12 deps) + +**Why:** Highest impact on maintainability + +--- + +## Success Metrics + +### Before Refactor +- God object `ctx` with 50+ properties +- Hidden dependencies (routes don't declare what they use) +- Difficult to mock for testing (need entire ctx object) + +### After Refactor +- ✅ Routes declare explicit dependencies +- ✅ Utilities imported directly (no ctx indirection) +- ✅ Domain logic extracted to services +- ✅ Easy to mock (inject only what's needed) +- ✅ Self-documenting (route signature shows dependencies) + +--- + +## Next Steps + +1. ✅ Document current dependencies (this file) +2. Refactor Phase 1 routes (low-risk, 1-5 deps) +3. Extract utilities (asyncHandler, errorResponse, log) +4. Extract domain services (docker, caddy, dns) +5. Refactor high-complexity routes +6. Remove `ctx` god object entirely + +--- + +*Generated by analyzing route files for `ctx.*` usage patterns.* From d5a67893667c3025cf6b566e863d6e9b3c2671da Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:04:04 +0100 Subject: [PATCH 03/11] Phase 2 (WIP): Extract config and utils modules - Created src/config/ (env.js, site-config.js) - Created src/utils/ (async-handler.js, responses.js, safe-error.js) - server.js not yet modified (backward-compatible extraction) --- DESLOPIFICATION-ROADMAP.md | 388 +++++++++++++++++++++++ dashcaddy-api/src/config/env.js | 50 +++ dashcaddy-api/src/config/index.js | 23 ++ dashcaddy-api/src/config/site-config.js | 103 ++++++ dashcaddy-api/src/utils/async-handler.js | 41 +++ dashcaddy-api/src/utils/index.js | 15 + dashcaddy-api/src/utils/responses.js | 30 ++ dashcaddy-api/src/utils/safe-error.js | 31 ++ 8 files changed, 681 insertions(+) create mode 100644 DESLOPIFICATION-ROADMAP.md create mode 100644 dashcaddy-api/src/config/env.js create mode 100644 dashcaddy-api/src/config/index.js create mode 100644 dashcaddy-api/src/config/site-config.js create mode 100644 dashcaddy-api/src/utils/async-handler.js create mode 100644 dashcaddy-api/src/utils/index.js create mode 100644 dashcaddy-api/src/utils/responses.js create mode 100644 dashcaddy-api/src/utils/safe-error.js 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/src/config/env.js b/dashcaddy-api/src/config/env.js new file mode 100644 index 0000000..4c4eca9 --- /dev/null +++ b/dashcaddy-api/src/config/env.js @@ -0,0 +1,50 @@ +/** + * Environment variable loading and validation + * Central place for all process.env reads + */ + +const path = require('path'); +const platformPaths = require('../../platform-paths'); +const { APP, LIMITS, CADDY } = require('../../constants'); + +// Resolve services directory from SERVICES_FILE env var +const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile; +const SERVICES_DIR = path.dirname(SERVICES_FILE); + +/** + * Application configuration loaded from environment variables + */ +const config = { + // Server + port: APP.PORT, + + // Caddy paths + caddyfilePath: process.env.CADDYFILE_PATH || platformPaths.caddyfile, + caddyAdminUrl: process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl, + + // State files + servicesFile: SERVICES_FILE, + configFile: process.env.CONFIG_FILE || path.join(SERVICES_DIR, 'config.json'), + dnsCredentialsFile: process.env.DNS_CREDENTIALS_FILE || path.join(SERVICES_DIR, 'dns-credentials.json'), + tailscaleConfigFile: process.env.TAILSCALE_CONFIG_FILE || path.join(SERVICES_DIR, 'tailscale-config.json'), + notificationsFile: process.env.NOTIFICATIONS_FILE || path.join(SERVICES_DIR, 'notifications.json'), + totpConfigFile: process.env.TOTP_CONFIG_FILE || path.join(SERVICES_DIR, 'totp-config.json'), + errorLogFile: process.env.ERROR_LOG_FILE || path.join(__dirname, '../../dashcaddy-errors.log'), + licenseSecretFile: process.env.LICENSE_SECRET_FILE || path.join(__dirname, '../../.license-secret'), + + // Limits + maxErrorLogSize: LIMITS.ERROR_LOG_SIZE, + + // Media browse roots (optional feature) + browseRoots: (process.env.MEDIA_BROWSE_ROOTS || '') + .split(',') + .filter((r) => r.includes('=')) + .map((r) => { + const eqIndex = r.indexOf('='); + const containerPath = r.slice(0, eqIndex).trim(); + const hostPath = r.slice(eqIndex + 1).trim(); + return { containerPath, hostPath }; + }), +}; + +module.exports = config; diff --git a/dashcaddy-api/src/config/index.js b/dashcaddy-api/src/config/index.js new file mode 100644 index 0000000..d2a3a32 --- /dev/null +++ b/dashcaddy-api/src/config/index.js @@ -0,0 +1,23 @@ +/** + * Configuration module + * Central exports for all configuration loading + */ + +const envConfig = require('./env'); +const { + siteConfig, + loadSiteConfig, + buildDomain, + buildServiceUrl, +} = require('./site-config'); + +module.exports = { + // Environment config + ...envConfig, + + // Site config + siteConfig, + loadSiteConfig, + buildDomain, + buildServiceUrl, +}; diff --git a/dashcaddy-api/src/config/site-config.js b/dashcaddy-api/src/config/site-config.js new file mode 100644 index 0000000..001e9db --- /dev/null +++ b/dashcaddy-api/src/config/site-config.js @@ -0,0 +1,103 @@ +/** + * Site configuration management + * Loads and validates site-wide settings from config.json + */ + +const fs = require('fs'); +const { validateConfig } = require('../../config-schema'); +const { CADDY } = require('../../constants'); + +/** + * Site configuration state + * Modified by loadSiteConfig() + */ +const siteConfig = { + tld: '.home', + caName: '', + dnsServerIp: '', + dnsServerPort: CADDY.DEFAULT_DNS_PORT, + dashboardHost: '', + timezone: 'UTC', + dnsServers: {}, + configurationType: 'homelab', + domain: '', + routingMode: 'subdomain', +}; + +/** + * Load site configuration from config.json + * @param {string} configFilePath - Path to config.json + * @param {object} log - Logger instance (optional, may not be available at startup) + */ +function loadSiteConfig(configFilePath, log) { + try { + if (fs.existsSync(configFilePath)) { + const raw = JSON.parse(fs.readFileSync(configFilePath, 'utf8')); + + // Validate config and log any issues + const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw); + if (log && log.warn) { + if (!valid) { + log.warn('config', 'Config validation errors', { errors: configErrors }); + } + for (const w of configWarnings) { + log.warn('config', w); + } + } + + // Apply config values + siteConfig.tld = raw.tld || '.home'; + if (!siteConfig.tld.startsWith('.')) { + siteConfig.tld = `.${siteConfig.tld}`; + } + siteConfig.caName = raw.caName || ''; + siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || ''; + siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT; + siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`; + siteConfig.timezone = raw.timezone || 'UTC'; + siteConfig.dnsServers = raw.dnsServers || {}; + siteConfig.configurationType = raw.configurationType || 'homelab'; + siteConfig.domain = raw.domain || ''; + siteConfig.routingMode = raw.routingMode || 'subdomain'; + } + } catch (e) { + if (log && log.error) { + log.error('config', 'Failed to load site config', { error: e.message }); + } else { + console.error('[ERROR] Failed to load site config:', e.message); + } + } +} + +/** + * Build a domain from subdomain + configured TLD or public domain + * @param {string} subdomain - Service subdomain (e.g., 'sonarr') + * @returns {string} Full domain (e.g., 'sonarr.home' or 'sonarr.example.com') + */ +function buildDomain(subdomain) { + if (siteConfig.configurationType === 'public' && siteConfig.domain) { + return `${subdomain}.${siteConfig.domain}`; + } + return `${subdomain}${siteConfig.tld}`; +} + +/** + * Build full service URL (protocol + host + path) for a given subdomain + * Subdirectory mode: https://example.com/sonarr + * Subdomain mode: https://sonarr.example.com + * @param {string} subdomain - Service subdomain + * @returns {string} Full service URL + */ +function buildServiceUrl(subdomain) { + if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { + return `https://${siteConfig.domain}/${subdomain}`; + } + return `https://${buildDomain(subdomain)}`; +} + +module.exports = { + siteConfig, + loadSiteConfig, + buildDomain, + buildServiceUrl, +}; diff --git a/dashcaddy-api/src/utils/async-handler.js b/dashcaddy-api/src/utils/async-handler.js new file mode 100644 index 0000000..dcd2640 --- /dev/null +++ b/dashcaddy-api/src/utils/async-handler.js @@ -0,0 +1,41 @@ +/** + * Async route handler wrapper + * Catches async errors and passes them to Express error middleware + */ + +const { AppError } = require('../../errors'); +const { safeErrorMessage } = require('./safe-error'); + +/** + * Wrap async Express route handlers to catch errors + * @param {Function} fn - async (req, res, next) handler + * @param {string} [context] - label for logError (defaults to req.path) + * @returns {Function} Express middleware + */ +function asyncHandler(fn, context) { + return async (req, res, next) => { + try { + await fn(req, res, next); + } catch (error) { + // Let typed errors (AppError subclasses) propagate to the global error handler + if (error instanceof AppError) { + return next(error); + } + + // Log error (requires logger to be injected) + if (req.app.locals.logError) { + await req.app.locals.logError(context || req.path, error); + } + + // Send error response if headers haven't been sent + if (!res.headersSent && req.app.locals.errorResponse) { + req.app.locals.errorResponse(res, 500, safeErrorMessage(error)); + } else if (!res.headersSent) { + // Fallback if errorResponse not available + res.status(500).json({ success: false, error: safeErrorMessage(error) }); + } + } + }; +} + +module.exports = asyncHandler; diff --git a/dashcaddy-api/src/utils/index.js b/dashcaddy-api/src/utils/index.js new file mode 100644 index 0000000..ba120bd --- /dev/null +++ b/dashcaddy-api/src/utils/index.js @@ -0,0 +1,15 @@ +/** + * Utility functions + * Common helpers used across the API + */ + +const asyncHandler = require('./async-handler'); +const { errorResponse, ok } = require('./responses'); +const { safeErrorMessage } = require('./safe-error'); + +module.exports = { + asyncHandler, + errorResponse, + ok, + safeErrorMessage, +}; diff --git a/dashcaddy-api/src/utils/responses.js b/dashcaddy-api/src/utils/responses.js new file mode 100644 index 0000000..768fb89 --- /dev/null +++ b/dashcaddy-api/src/utils/responses.js @@ -0,0 +1,30 @@ +/** + * Standard HTTP response helpers + */ + +/** + * Standard error response — always returns { success: false, error, ...extras } + * @param {object} res - Express response object + * @param {number} statusCode - HTTP status code + * @param {string} message - Error message + * @param {object} extras - Additional fields to include + * @returns {object} Express response + */ +function errorResponse(res, statusCode, message, extras = {}) { + return res.status(statusCode).json({ success: false, error: message, ...extras }); +} + +/** + * Standard success response — always returns { success: true, ...data } + * @param {object} res - Express response object + * @param {object} data - Data to include in response + * @returns {object} Express response + */ +function ok(res, data = {}) { + return res.json({ success: true, ...data }); +} + +module.exports = { + errorResponse, + ok, +}; diff --git a/dashcaddy-api/src/utils/safe-error.js b/dashcaddy-api/src/utils/safe-error.js new file mode 100644 index 0000000..85001fb --- /dev/null +++ b/dashcaddy-api/src/utils/safe-error.js @@ -0,0 +1,31 @@ +/** + * Safe error message sanitization + * Prevents leaking internal paths, stack traces, etc. to clients + */ + +/** + * Return a safe error message to the client without leaking internals + * @param {Error|string} error - Error object or string + * @returns {string} Sanitized error message safe for client consumption + */ +function safeErrorMessage(error) { + const msg = error.message || String(error); + + // Detect port conflict errors from Docker + const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/); + if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) { + const port = portMatch ? portMatch[1] : 'requested'; + return `[DC-200] Port ${port} is already in use. Try a different port in Advanced Options, or stop the service using that port first.`; + } + + // Only expose messages that are clearly user-facing (short, no paths/stack frames) + if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) { + return msg; + } + + return 'An internal error occurred'; +} + +module.exports = { + safeErrorMessage, +}; From 6771e4775ec3605eb287bde2f222dc47f26a7903 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:05:50 +0100 Subject: [PATCH 04/11] Phase 2 (WIP): Add logger and docker context modules - src/utils/logger.js: Structured JSON logging - src/context/docker.js: Docker API wrapper (pull, findContainer, getUsedPorts) - All modules can now be imported directly instead of via ctx --- dashcaddy-api/src/context/docker.js | 78 +++++++++++++++++++++++++++++ dashcaddy-api/src/utils/index.js | 2 + dashcaddy-api/src/utils/logger.js | 40 +++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 dashcaddy-api/src/context/docker.js create mode 100644 dashcaddy-api/src/utils/logger.js diff --git a/dashcaddy-api/src/context/docker.js b/dashcaddy-api/src/context/docker.js new file mode 100644 index 0000000..ebbf39d --- /dev/null +++ b/dashcaddy-api/src/context/docker.js @@ -0,0 +1,78 @@ +/** + * Docker context + * Docker API wrapper and container utilities + */ + +const Docker = require('dockerode'); +const { DOCKER } = require('../../constants'); + +// Docker client instance +const docker = new Docker(); + +/** + * Pull a Docker image with timeout protection + * @param {string} imageName - Image name (e.g., 'nginx:latest') + * @param {number} timeoutMs - Timeout in milliseconds + * @returns {Promise} Pull progress output + */ +function dockerPull(imageName, timeoutMs = DOCKER.TIMEOUT) { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Docker pull timed out after ${timeoutMs / 1000}s: ${imageName}`)), + timeoutMs, + ); + + docker.pull(imageName, (err, stream) => { + if (err) { + clearTimeout(timer); + return reject(err); + } + + docker.modem.followProgress(stream, (err, output) => { + clearTimeout(timer); + if (err) return reject(err); + resolve(output); + }); + }); + }); +} + +/** + * Find a running Docker container by name substring + * @param {string} name - Container name or substring + * @param {object} opts - Options (e.g., { all: true } to include stopped containers) + * @returns {Promise} Container object or null if not found + */ +async function findContainerByName(name, opts = { all: false }) { + const containers = await docker.listContainers(opts); + const match = containers.find((c) => + c.Names.some((n) => n.toLowerCase().includes(name.toLowerCase())), + ); + return match || null; +} + +/** + * Get all host ports currently in use by Docker containers + * @returns {Promise>} Set of port numbers + */ +async function getUsedPorts() { + const containers = await docker.listContainers({ all: false }); + const ports = new Set(); + + for (const c of containers) { + for (const p of (c.Ports || [])) { + if (p.PublicPort) { + ports.add(p.PublicPort); + } + } + } + + return ports; +} + +module.exports = { + client: docker, + pull: dockerPull, + findContainer: findContainerByName, + getUsedPorts, +}; diff --git a/dashcaddy-api/src/utils/index.js b/dashcaddy-api/src/utils/index.js index ba120bd..86ef7a5 100644 --- a/dashcaddy-api/src/utils/index.js +++ b/dashcaddy-api/src/utils/index.js @@ -6,10 +6,12 @@ const asyncHandler = require('./async-handler'); const { errorResponse, ok } = require('./responses'); const { safeErrorMessage } = require('./safe-error'); +const log = require('./logger'); module.exports = { asyncHandler, errorResponse, ok, safeErrorMessage, + log, }; diff --git a/dashcaddy-api/src/utils/logger.js b/dashcaddy-api/src/utils/logger.js new file mode 100644 index 0000000..bcfcb0d --- /dev/null +++ b/dashcaddy-api/src/utils/logger.js @@ -0,0 +1,40 @@ +/** + * Structured logging + * JSON-formatted logs with levels (debug, info, warn, error) + */ + +const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; +const LOG_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL || 'info'] || 1; + +/** + * Core log function + * @param {string} level - Log level (debug, info, warn, error) + * @param {string} context - Context label (e.g., 'server', 'docker', 'caddy') + * @param {string} message - Log message + * @param {object} data - Additional structured data + */ +function log(level, context, message, data = {}) { + if (LOG_LEVELS[level] < LOG_LEVEL) return; + + const entry = { + t: new Date().toISOString(), + level, + ctx: context, + msg: message, + }; + + if (Object.keys(data).length) { + entry.data = data; + } + + const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + fn(JSON.stringify(entry)); +} + +// Convenience methods +log.info = (ctx, msg, data) => log('info', ctx, msg, data); +log.warn = (ctx, msg, data) => log('warn', ctx, msg, data); +log.error = (ctx, msg, data) => log('error', ctx, msg, data); +log.debug = (ctx, msg, data) => log('debug', ctx, msg, data); + +module.exports = log; From efa9c7ba6b9d3e6638e97906a48ae1b2a4795d98 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:07:03 +0100 Subject: [PATCH 05/11] Phase 2 (WIP): Add caddy context module - src/context/caddy.js: Caddyfile manipulation, reload, config generation - Uses dependency injection (init() pattern) for siteConfig, log, fetchT - Atomic mutex-based modifications with rollback on failure - All Caddy operations now in one module --- dashcaddy-api/src/context/caddy.js | 220 +++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 dashcaddy-api/src/context/caddy.js diff --git a/dashcaddy-api/src/context/caddy.js b/dashcaddy-api/src/context/caddy.js new file mode 100644 index 0000000..ed2c06c --- /dev/null +++ b/dashcaddy-api/src/context/caddy.js @@ -0,0 +1,220 @@ +/** + * Caddy context + * Caddyfile manipulation, reload, and site verification + */ + +const fsp = require('fs').promises; +const { CADDY, RETRIES } = require('../../constants'); +const { safeErrorMessage } = require('../utils/safe-error'); + +// Will be initialized by init() +let config = null; +let log = null; +let fetchT = null; +let buildDomain = null; +let siteConfig = null; +let httpsAgent = null; + +/** + * Initialize caddy context with dependencies + * @param {object} deps - { config, log, fetchT, buildDomain, siteConfig, httpsAgent } + */ +function init(deps) { + config = deps.config; + log = deps.log; + fetchT = deps.fetchT; + buildDomain = deps.buildDomain; + siteConfig = deps.siteConfig; + httpsAgent = deps.httpsAgent; +} + +// Mutex for atomic Caddyfile modifications +let _caddyfileLock = Promise.resolve(); + +/** + * Atomically read-modify-write the Caddyfile and reload Caddy + * Uses a mutex to prevent concurrent modifications from clobbering each other + * Rolls back on reload failure + * @param {function} modifyFn - receives current content, returns modified content (or null to skip) + * @returns {Promise<{success: boolean, error?: string, rolledBack?: boolean}>} + */ +async function modifyCaddyfile(modifyFn) { + let resolve; + const prev = _caddyfileLock; + _caddyfileLock = new Promise((r) => { + resolve = r; + }); + + await prev; // wait for any in-flight modification to finish + + try { + const original = await readCaddyfile(); + const modified = await modifyFn(original); + + if (modified === null || modified === original) { + return { success: false, error: 'No changes to apply' }; + } + + await fsp.writeFile(config.caddyfilePath, modified, 'utf8'); + + try { + await reloadCaddy(modified); + return { success: true }; + } catch (err) { + // Rollback + await fsp.writeFile(config.caddyfilePath, original, 'utf8'); + return { success: false, error: safeErrorMessage(err), rolledBack: true }; + } + } finally { + resolve(); + } +} + +/** + * Read the current Caddyfile content + * @returns {Promise} Caddyfile content + */ +async function readCaddyfile() { + return fsp.readFile(config.caddyfilePath, 'utf8'); +} + +/** + * Reload Caddy configuration via admin API + * @param {string} content - New Caddyfile content + * @returns {Promise} + * @throws {Error} If reload fails after max retries + */ +async function reloadCaddy(content) { + const maxRetries = RETRIES.CADDY_RELOAD; + let lastError = null; + + for (let i = 0; i < maxRetries; i++) { + try { + const response = await fetchT(`${config.caddyAdminUrl}/load`, { + method: 'POST', + headers: { 'Content-Type': CADDY.CONTENT_TYPE }, + body: content, + }); + + if (response.ok) { + log.info('caddy', 'Caddy configuration reloaded successfully'); + // Wait a moment for Caddy to fully apply the config + await new Promise((resolve) => setTimeout(resolve, 1000)); + return; + } + + lastError = await response.text(); + log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError }); + } catch (error) { + lastError = error.message; + log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError }); + } + + if (i < maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); +} + +/** + * Generate Caddy reverse proxy configuration + * @param {string} subdomain - Service subdomain + * @param {string} ip - Backend IP address + * @param {number} port - Backend port + * @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport } + * @returns {string} Caddy configuration snippet + */ +function generateConfig(subdomain, ip, port, options = {}) { + const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; + + // Subdirectory mode: generate handle/handle_path block (injected into main domain block) + if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { + let config = ''; + + // Native-support apps: use handle (preserve path prefix) + // Strip-mode apps: use handle_path (remove path prefix before proxying) + if (subpathSupport === 'native') { + config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; + config += `\thandle /${subdomain}/* {\n`; + } else { + config += `\thandle_path /${subdomain}/* {\n`; + } + + if (tailscaleOnly) { + config += '\t\t@blocked not remote_ip 100.64.0.0/10'; + if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; + config += '\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n'; + } + + config += `\t\treverse_proxy ${ip}:${port}\n`; + config += '\t}'; + return config; + } + + // Subdomain mode (default): standalone domain block + let config = `${buildDomain(subdomain)} {\n`; + + if (tailscaleOnly) { + config += ' @blocked not remote_ip 100.64.0.0/10'; + if (allowedIPs.length > 0) { + config += ` ${allowedIPs.join(' ')}`; + } + config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n'; + } + + config += ` reverse_proxy ${ip}:${port}\n`; + config += ' tls internal\n'; + config += '}'; + + return config; +} + +/** + * Verify a site is accessible via HTTPS + * @param {string} domain - Domain to check + * @param {number} maxAttempts - Max verification attempts + * @returns {Promise} true if site is accessible + */ +async function verifySite(domain, maxAttempts = 5) { + const delay = 2000; + + for (let i = 0; i < maxAttempts; i++) { + try { + // Try HTTPS first (internal CA) + const response = await fetchT(`https://${domain}/`, { + method: 'HEAD', + agent: httpsAgent, // Use CA-aware agent + timeout: 5000, + }); + + // Any response (even 4xx) means Caddy is serving the site + log.info('caddy', 'Site is accessible', { domain, status: response.status }); + return true; + } catch (error) { + log.debug('caddy', 'Site verification attempt', { + domain, + attempt: i + 1, + maxAttempts, + error: error.message, + }); + } + + if (i < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain }); + return false; +} + +module.exports = { + init, + modify: modifyCaddyfile, + read: readCaddyfile, + reload: reloadCaddy, + generateConfig, + verifySite, +}; From 3efa5dc3f44b63dbc8c2321d6c4b07e2f9bb1b24 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:08:05 +0100 Subject: [PATCH 06/11] Phase 2 (WIP): Extract context modules (caddy, dns) - src/context/caddy.js: Caddyfile manipulation, reload, config generation - src/context/dns.js: DNS API wrapper with token management - All context modules use factory pattern with explicit dependencies --- dashcaddy-api/src/context/caddy.js | 340 ++++++++++++++--------------- dashcaddy-api/src/context/dns.js | 211 ++++++++++++++++++ dashcaddy-api/src/context/index.js | 14 ++ 3 files changed, 386 insertions(+), 179 deletions(-) create mode 100644 dashcaddy-api/src/context/dns.js create mode 100644 dashcaddy-api/src/context/index.js diff --git a/dashcaddy-api/src/context/caddy.js b/dashcaddy-api/src/context/caddy.js index ed2c06c..50e98c6 100644 --- a/dashcaddy-api/src/context/caddy.js +++ b/dashcaddy-api/src/context/caddy.js @@ -1,220 +1,202 @@ /** * Caddy context - * Caddyfile manipulation, reload, and site verification + * Caddyfile manipulation, reload, and configuration generation */ const fsp = require('fs').promises; -const { CADDY, RETRIES } = require('../../constants'); +const { RETRIES, CADDY } = require('../../constants'); const { safeErrorMessage } = require('../utils/safe-error'); -// Will be initialized by init() -let config = null; -let log = null; -let fetchT = null; -let buildDomain = null; -let siteConfig = null; -let httpsAgent = null; - -/** - * Initialize caddy context with dependencies - * @param {object} deps - { config, log, fetchT, buildDomain, siteConfig, httpsAgent } - */ -function init(deps) { - config = deps.config; - log = deps.log; - fetchT = deps.fetchT; - buildDomain = deps.buildDomain; - siteConfig = deps.siteConfig; - httpsAgent = deps.httpsAgent; -} - // Mutex for atomic Caddyfile modifications let _caddyfileLock = Promise.resolve(); /** - * Atomically read-modify-write the Caddyfile and reload Caddy - * Uses a mutex to prevent concurrent modifications from clobbering each other - * Rolls back on reload failure - * @param {function} modifyFn - receives current content, returns modified content (or null to skip) - * @returns {Promise<{success: boolean, error?: string, rolledBack?: boolean}>} + * Create Caddy context + * @param {object} deps - Dependencies { caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent } */ -async function modifyCaddyfile(modifyFn) { - let resolve; - const prev = _caddyfileLock; - _caddyfileLock = new Promise((r) => { - resolve = r; - }); - - await prev; // wait for any in-flight modification to finish - - try { - const original = await readCaddyfile(); - const modified = await modifyFn(original); - - if (modified === null || modified === original) { - return { success: false, error: 'No changes to apply' }; - } - - await fsp.writeFile(config.caddyfilePath, modified, 'utf8'); - - try { - await reloadCaddy(modified); - return { success: true }; - } catch (err) { - // Rollback - await fsp.writeFile(config.caddyfilePath, original, 'utf8'); - return { success: false, error: safeErrorMessage(err), rolledBack: true }; - } - } finally { - resolve(); +function createCaddyContext({ caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }) { + /** + * Read the current Caddyfile content + * @returns {Promise} Caddyfile content + */ + async function readCaddyfile() { + return fsp.readFile(caddyfilePath, 'utf8'); } -} -/** - * Read the current Caddyfile content - * @returns {Promise} Caddyfile content - */ -async function readCaddyfile() { - return fsp.readFile(config.caddyfilePath, 'utf8'); -} + /** + * Reload Caddy configuration via admin API + * @param {string} content - New Caddyfile content + */ + async function reloadCaddy(content) { + const maxRetries = RETRIES.CADDY_RELOAD; + let lastError = null; -/** - * Reload Caddy configuration via admin API - * @param {string} content - New Caddyfile content - * @returns {Promise} - * @throws {Error} If reload fails after max retries - */ -async function reloadCaddy(content) { - const maxRetries = RETRIES.CADDY_RELOAD; - let lastError = null; + for (let i = 0; i < maxRetries; i++) { + try { + const response = await fetchT(`${caddyAdminUrl}/load`, { + method: 'POST', + headers: { 'Content-Type': CADDY.CONTENT_TYPE }, + body: content, + }); - for (let i = 0; i < maxRetries; i++) { - try { - const response = await fetchT(`${config.caddyAdminUrl}/load`, { - method: 'POST', - headers: { 'Content-Type': CADDY.CONTENT_TYPE }, - body: content, - }); + if (response.ok) { + log.info('caddy', 'Caddy configuration reloaded successfully'); + // Wait a moment for Caddy to fully apply the config + await new Promise((resolve) => setTimeout(resolve, 1000)); + return; + } - if (response.ok) { - log.info('caddy', 'Caddy configuration reloaded successfully'); - // Wait a moment for Caddy to fully apply the config - await new Promise((resolve) => setTimeout(resolve, 1000)); - return; + lastError = await response.text(); + log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError }); + } catch (error) { + lastError = error.message; + log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError }); } - lastError = await response.text(); - log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError }); - } catch (error) { - lastError = error.message; - log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError }); + if (i < maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } } - if (i < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, 2000)); + throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); + } + + /** + * Atomically read-modify-write the Caddyfile and reload Caddy + * Uses a mutex to prevent concurrent modifications + * Rolls back on reload failure + * @param {function} modifyFn - Receives current content, returns modified content (or null to skip) + * @returns {Promise<{success: boolean, error?: string, rolledBack?: boolean}>} + */ + async function modifyCaddyfile(modifyFn) { + let resolve; + const prev = _caddyfileLock; + _caddyfileLock = new Promise((r) => { + resolve = r; + }); + + await prev; // wait for any in-flight modification to finish + + try { + const original = await readCaddyfile(); + const modified = await modifyFn(original); + + if (modified === null || modified === original) { + return { success: false, error: 'No changes to apply' }; + } + + await fsp.writeFile(caddyfilePath, modified, 'utf8'); + + try { + await reloadCaddy(modified); + return { success: true }; + } catch (err) { + // Rollback + await fsp.writeFile(caddyfilePath, original, 'utf8'); + return { success: false, error: safeErrorMessage(err), rolledBack: true }; + } + } finally { + resolve(); } } - throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); -} + /** + * Generate Caddy configuration block for a service + * @param {string} subdomain - Service subdomain + * @param {string} ip - Target IP address + * @param {number} port - Target port + * @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport } + * @returns {string} Caddy configuration block + */ + function generateConfig(subdomain, ip, port, options = {}) { + const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; -/** - * Generate Caddy reverse proxy configuration - * @param {string} subdomain - Service subdomain - * @param {string} ip - Backend IP address - * @param {number} port - Backend port - * @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport } - * @returns {string} Caddy configuration snippet - */ -function generateConfig(subdomain, ip, port, options = {}) { - const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; + // Subdirectory mode: generate handle/handle_path block + if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { + let config = ''; - // Subdirectory mode: generate handle/handle_path block (injected into main domain block) - if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { - let config = ''; + if (subpathSupport === 'native') { + config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; + config += `\thandle /${subdomain}/* {\n`; + } else { + config += `\thandle_path /${subdomain}/* {\n`; + } - // Native-support apps: use handle (preserve path prefix) - // Strip-mode apps: use handle_path (remove path prefix before proxying) - if (subpathSupport === 'native') { - config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; - config += `\thandle /${subdomain}/* {\n`; - } else { - config += `\thandle_path /${subdomain}/* {\n`; + if (tailscaleOnly) { + config += '\t\t@blocked not remote_ip 100.64.0.0/10'; + if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; + config += '\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n'; + } + + config += `\t\treverse_proxy ${ip}:${port}\n`; + config += '\t}'; + return config; } + // Subdomain mode (default): standalone domain block + let config = `${buildDomain(subdomain)} {\n`; + if (tailscaleOnly) { - config += '\t\t@blocked not remote_ip 100.64.0.0/10'; - if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; - config += '\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n'; + config += ' @blocked not remote_ip 100.64.0.0/10'; + if (allowedIPs.length > 0) { + config += ` ${allowedIPs.join(' ')}`; + } + config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n'; } - config += `\t\treverse_proxy ${ip}:${port}\n`; - config += '\t}'; + config += ` reverse_proxy ${ip}:${port}\n`; + config += ' tls internal\n'; + config += '}'; + return config; } - // Subdomain mode (default): standalone domain block - let config = `${buildDomain(subdomain)} {\n`; + /** + * Verify a site is accessible via HTTPS + * @param {string} domain - Domain to check + * @param {number} maxAttempts - Maximum retry attempts + * @returns {Promise} True if accessible + */ + async function verifySite(domain, maxAttempts = 5) { + const delay = 2000; - if (tailscaleOnly) { - config += ' @blocked not remote_ip 100.64.0.0/10'; - if (allowedIPs.length > 0) { - config += ` ${allowedIPs.join(' ')}`; + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetchT(`https://${domain}/`, { + method: 'HEAD', + agent: httpsAgent, + timeout: 5000, + }); + + log.info('caddy', 'Site is accessible', { domain, status: response.status }); + return true; + } catch (error) { + log.debug('caddy', 'Site verification attempt', { + domain, + attempt: i + 1, + maxAttempts, + error: error.message, + }); + } + + if (i < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } } - config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n'; + + log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain }); + return false; } - config += ` reverse_proxy ${ip}:${port}\n`; - config += ' tls internal\n'; - config += '}'; - - return config; + return { + modify: modifyCaddyfile, + read: readCaddyfile, + reload: reloadCaddy, + generateConfig, + verifySite, + adminUrl: caddyAdminUrl, + filePath: caddyfilePath, + }; } -/** - * Verify a site is accessible via HTTPS - * @param {string} domain - Domain to check - * @param {number} maxAttempts - Max verification attempts - * @returns {Promise} true if site is accessible - */ -async function verifySite(domain, maxAttempts = 5) { - const delay = 2000; - - for (let i = 0; i < maxAttempts; i++) { - try { - // Try HTTPS first (internal CA) - const response = await fetchT(`https://${domain}/`, { - method: 'HEAD', - agent: httpsAgent, // Use CA-aware agent - timeout: 5000, - }); - - // Any response (even 4xx) means Caddy is serving the site - log.info('caddy', 'Site is accessible', { domain, status: response.status }); - return true; - } catch (error) { - log.debug('caddy', 'Site verification attempt', { - domain, - attempt: i + 1, - maxAttempts, - error: error.message, - }); - } - - if (i < maxAttempts - 1) { - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - - log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain }); - return false; -} - -module.exports = { - init, - modify: modifyCaddyfile, - read: readCaddyfile, - reload: reloadCaddy, - generateConfig, - verifySite, -}; +module.exports = createCaddyContext; diff --git a/dashcaddy-api/src/context/dns.js b/dashcaddy-api/src/context/dns.js new file mode 100644 index 0000000..5ea1db8 --- /dev/null +++ b/dashcaddy-api/src/context/dns.js @@ -0,0 +1,211 @@ +/** + * DNS context + * Technitium DNS API wrapper with token management + */ + +const { CADDY } = require('../../constants'); + +/** + * Create DNS context + * @param {object} deps - Dependencies { siteConfig, fetchT, log, credentialManager, buildDomain, createCache, CACHE_CONFIGS } + */ +function createDnsContext({ siteConfig, fetchT, log, credentialManager, buildDomain, createCache, CACHE_CONFIGS }) { + // DNS token state + let dnsToken = process.env.DNS_ADMIN_TOKEN || ''; + let dnsTokenExpiry = null; + + // Per-server token cache + const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens); + + /** + * Build full Technitium DNS API URL + * @param {string} server - Server IP or hostname + * @param {string} apiPath - API path (e.g., '/api/zones/records/add') + * @param {object|URLSearchParams} params - Query parameters + * @returns {string} Full API URL + */ + function buildUrl(server, apiPath, params) { + const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https'; + const port = protocol === 'http' ? `:${CADDY.DEFAULT_DNS_PORT}` : ''; + const qs = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString(); + return `${protocol}://${server}${port}${apiPath}?${qs}`; + } + + /** + * Call DNS API endpoint + * @param {string} server - Server IP or hostname + * @param {string} apiPath - API path + * @param {object} params - Query parameters + * @returns {Promise} Parsed JSON response + */ + async function call(server, apiPath, params) { + const url = buildUrl(server, apiPath, params); + const response = await fetchT(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + return response.json(); + } + + /** + * Refresh DNS token via login + * @param {string} username - DNS username + * @param {string} password - DNS password + * @param {string} server - Server IP + * @returns {Promise<{success: boolean, token?: string, error?: string}>} + */ + async function refreshToken(username, password, server) { + try { + const params = new URLSearchParams({ + user: username, + pass: password, + includeInfo: 'false', + }); + + const response = await fetchT(`http://${server}:5380/api/user/login?${params.toString()}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + timeout: 10000, + }); + + const result = await response.json(); + + if (result.status === 'ok' && result.token) { + dnsToken = result.token; + dnsTokenExpiry = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); // 6 hours + log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry }); + return { success: true, token: dnsToken }; + } + + return { success: false, error: result.errorMessage || 'Login failed' }; + } catch (error) { + log.error('dns', 'DNS token refresh error', { error: error.message }); + return { success: false, error: error.message }; + } + } + + /** + * Map DNS server IP to dnsId (dns1, dns2, dns3) + * @param {string} serverIp - Server IP address + * @returns {string|null} DNS ID or null + */ + function dnsIpToDnsId(serverIp) { + for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) { + if (info.ip === serverIp) return dnsId; + } + return null; + } + + /** + * Ensure a valid DNS token exists (auto-refresh if needed) + * @returns {Promise<{success: boolean, token?: string, error?: string}>} + */ + async function ensureToken() { + // Check if token is valid and not expired + if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) { + return { success: true, token: dnsToken }; + } + + // Try per-server admin credentials for the primary DNS server + const primaryIp = siteConfig.dnsServerIp; + if (primaryIp) { + const dnsId = dnsIpToDnsId(primaryIp); + if (dnsId) { + for (const role of ['admin', 'readonly']) { + try { + const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`); + const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`); + if (username && password) { + return await refreshToken(username, password, primaryIp); + } + } catch (err) { + log.error('dns', `Per-server ${role} credential error`, { dnsId, error: err.message }); + } + } + } + } + + // Fall back to global credentials + try { + const username = await credentialManager.retrieve('dns.username'); + const password = await credentialManager.retrieve('dns.password'); + const server = await credentialManager.retrieve('dns.server'); + if (username && password) { + return await refreshToken(username, password, server || primaryIp); + } + } catch (err) { + log.error('dns', 'Credential manager error', { error: err.message }); + } + + return { + success: false, + error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials', + }; + } + + /** + * Require a valid DNS token (auto-refresh if needed) + * @param {string} providedToken - Optional token provided by caller + * @returns {Promise} Valid token + * @throws {Error} If no valid token can be obtained + */ + async function requireToken(providedToken) { + if (providedToken) return providedToken; + const result = await ensureToken(); + if (result.success) return result.token; + const err = new Error(`No valid DNS token available. ${result.error}`); + err.statusCode = 401; + throw err; + } + + /** + * Create a DNS A record + * @param {string} subdomain - Service subdomain + * @param {string} ip - IP address + * @returns {Promise} + */ + async function createRecord(subdomain, ip) { + const tokenResult = await ensureToken(); + if (!tokenResult.success) { + throw new Error(`DNS token not available: ${tokenResult.error}`); + } + + const domain = buildDomain(subdomain); + const zone = siteConfig.tld.replace(/^\./, ''); + + const params = { + token: dnsToken, + domain, + zone, + type: 'A', + ipAddress: ip, + ttl: '300', + overwrite: 'true', + }; + + log.info('dns', 'Creating DNS record', { domain, ip }); + await call(siteConfig.dnsServerIp, '/api/zones/records/add', params); + } + + return { + call, + buildUrl, + requireToken, + ensureToken, + createRecord, + refresh: refreshToken, + getToken: () => dnsToken, + setToken: (t) => { + dnsToken = t; + }, + getTokenExpiry: () => dnsTokenExpiry, + setTokenExpiry: (e) => { + dnsTokenExpiry = e; + }, + }; +} + +module.exports = createDnsContext; diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js new file mode 100644 index 0000000..5ad4c26 --- /dev/null +++ b/dashcaddy-api/src/context/index.js @@ -0,0 +1,14 @@ +/** + * Context modules + * Domain-specific context factories + */ + +const docker = require('./docker'); +const createCaddyContext = require('./caddy'); +const createDnsContext = require('./dns'); + +module.exports = { + docker, + createCaddyContext, + createDnsContext, +}; From 81f778df72ea49abab933d48dea424398880749a Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:09:16 +0100 Subject: [PATCH 07/11] Phase 3 (WIP): Refactor containers & monitoring routes - routes/containers.js: Explicit deps (docker, asyncHandler, log) - routes/monitoring.js: Explicit deps (docker, resourceMonitor) - Pattern established: factory function with destructured deps --- dashcaddy-api/routes/containers.js | 96 ++++++----- dashcaddy-api/routes/containers.old.js | 217 +++++++++++++++++++++++++ dashcaddy-api/routes/monitoring.js | 77 ++++----- 3 files changed, 303 insertions(+), 87 deletions(-) create mode 100644 dashcaddy-api/routes/containers.old.js diff --git a/dashcaddy-api/routes/containers.js b/dashcaddy-api/routes/containers.js index 9a6f136..9a586c5 100644 --- a/dashcaddy-api/routes/containers.js +++ b/dashcaddy-api/routes/containers.js @@ -1,14 +1,21 @@ +/** + * Container management routes + * Refactored to use explicit dependencies instead of ctx god object + */ + const express = require('express'); const { DOCKER } = require('../constants'); const { paginate, parsePaginationParams } = require('../pagination'); const { NotFoundError } = require('../errors'); +const asyncHandler = require('../src/utils/async-handler'); +const log = require('../src/utils/logger'); -module.exports = function(ctx) { +module.exports = function({ docker }) { const router = express.Router(); // Helper: verify container exists before operating on it async function getVerifiedContainer(id) { - const container = ctx.docker.client.getContainer(id); + const container = docker.client.getContainer(id); try { await container.inspect(); } catch (err) { @@ -21,28 +28,28 @@ module.exports = function(ctx) { } // Start container - router.post('/:id/start', ctx.asyncHandler(async (req, res) => { + router.post('/:id/start', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.start(); res.json({ success: true, message: 'Container started' }); }, 'container-start')); // Stop container - router.post('/:id/stop', ctx.asyncHandler(async (req, res) => { + router.post('/:id/stop', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.stop(); res.json({ success: true, message: 'Container stopped' }); }, 'container-stop')); // Restart container - router.post('/:id/restart', ctx.asyncHandler(async (req, res) => { + router.post('/:id/restart', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.restart(); res.json({ success: true, message: 'Container restarted' }); }, 'container-restart')); // Update container to latest image version - router.post('/:id/update', ctx.asyncHandler(async (req, res) => { + router.post('/:id/update', asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); @@ -51,11 +58,11 @@ module.exports = function(ctx) { const imageName = containerInfo.Config.Image; const containerName = containerInfo.Name.replace(/^\//, ''); - ctx.log.info('docker', 'Updating container', { containerName, imageName }); + log.info('docker', 'Updating container', { containerName, imageName }); // Pull the latest image - ctx.log.info('docker', `Pulling latest image: ${imageName}`); - await ctx.docker.pull(imageName); + log.info('docker', `Pulling latest image: ${imageName}`); + await docker.pull(imageName); // Get current container config for recreation const hostConfig = containerInfo.HostConfig; @@ -75,7 +82,7 @@ module.exports = function(ctx) { CapAdd: hostConfig.CapAdd, CapDrop: hostConfig.CapDrop, Devices: hostConfig.Devices, - LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers + LogConfig: DOCKER.LOG_CONFIG, }, NetworkingConfig: {}, }; @@ -89,40 +96,45 @@ module.exports = function(ctx) { } // Stop and remove old container - ctx.log.info('docker', 'Stopping container', { containerName }); + log.info('docker', 'Stopping container', { containerName }); await container.stop().catch(() => {}); // Ignore if already stopped - ctx.log.info('docker', 'Removing container', { containerName }); + log.info('docker', 'Removing container', { containerName }); await container.remove(); - // Wait for port release (Windows/Docker Desktop can be slow to free ports) - await new Promise(r => setTimeout(r, 3000)); + // Wait for port release + await new Promise((r) => setTimeout(r, 3000)); // Create and start new container - ctx.log.info('docker', 'Creating new container', { containerName }); + log.info('docker', 'Creating new container', { containerName }); let newContainer; try { - newContainer = await ctx.docker.client.createContainer(config); - ctx.log.info('docker', 'Starting container', { containerName }); + newContainer = await docker.client.createContainer(config); + log.info('docker', 'Starting container', { containerName }); await newContainer.start(); } catch (startError) { - // Clean up the failed container so it doesn't block future attempts - ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); + log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); if (newContainer) { - try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } + try { + await newContainer.remove({ force: true }); + } catch (e) { + /* already gone */ + } } throw startError; } const newContainerInfo = await newContainer.inspect(); - // Prune dangling images after update + // Prune dangling images try { - const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); + log.info('docker', 'Pruned dangling images after update', { + spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024)}MB`, + }); } } catch (pruneErr) { - ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); + log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); } res.json({ @@ -132,27 +144,27 @@ module.exports = function(ctx) { }); }, 'container-update')); - // Check for available updates (compares local and remote image digests) - router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => { + // Check for available updates + router.get('/:id/check-update', asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); const containerInfo = await container.inspect(); const imageName = containerInfo.Config.Image; - const localImage = ctx.docker.client.getImage(containerInfo.Image); + const localImage = docker.client.getImage(containerInfo.Image); const localImageInfo = await localImage.inspect(); const localDigest = localImageInfo.RepoDigests?.[0] || null; let updateAvailable = false; try { - const pullStream = await ctx.docker.pull(imageName); + const pullStream = await docker.pull(imageName); - const downloadedLayers = pullStream.filter(e => - e.status === 'Downloading' || e.status === 'Download complete', + const downloadedLayers = pullStream.filter( + (e) => e.status === 'Downloading' || e.status === 'Download complete', ); updateAvailable = downloadedLayers.length > 0; - const newImage = ctx.docker.client.getImage(imageName); + const newImage = docker.client.getImage(imageName); const newImageInfo = await newImage.inspect(); const newDigest = newImageInfo.RepoDigests?.[0] || null; @@ -160,7 +172,7 @@ module.exports = function(ctx) { updateAvailable = true; } } catch (pullError) { - ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message }); + log.debug('docker', 'Could not check for updates', { error: pullError.message }); } res.json({ @@ -172,7 +184,7 @@ module.exports = function(ctx) { }, 'container-check-update')); // Get container logs - router.get('/:id/logs', ctx.asyncHandler(async (req, res) => { + router.get('/:id/logs', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); const logs = await container.logs({ stdout: true, @@ -184,20 +196,20 @@ module.exports = function(ctx) { }, 'container-logs')); // Delete container - router.delete('/:id', ctx.asyncHandler(async (req, res) => { + router.delete('/:id', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.remove({ force: true }); res.json({ success: true, message: 'Container removed' }); }, 'container-delete')); // Discover running containers - router.get('/discover', ctx.asyncHandler(async (req, res) => { - const containers = await ctx.docker.client.listContainers({ all: true }); - const samiContainers = containers.filter(container => - container.Labels && container.Labels['sami.managed'] === 'true', + router.get('/discover', asyncHandler(async (req, res) => { + const containers = await docker.client.listContainers({ all: true }); + const samiContainers = containers.filter( + (container) => container.Labels && container.Labels['sami.managed'] === 'true', ); - const discoveredContainers = samiContainers.map(container => ({ + const discoveredContainers = samiContainers.map((container) => ({ id: container.Id, name: container.Names[0].replace('/', ''), image: container.Image, @@ -210,7 +222,11 @@ module.exports = function(ctx) { const paginationParams = parsePaginationParams(req.query); const result = paginate(discoveredContainers, paginationParams); - res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); + res.json({ + success: true, + containers: result.data, + ...(result.pagination && { pagination: result.pagination }), + }); }, 'containers-discover')); return router; diff --git a/dashcaddy-api/routes/containers.old.js b/dashcaddy-api/routes/containers.old.js new file mode 100644 index 0000000..9a6f136 --- /dev/null +++ b/dashcaddy-api/routes/containers.old.js @@ -0,0 +1,217 @@ +const express = require('express'); +const { DOCKER } = require('../constants'); +const { paginate, parsePaginationParams } = require('../pagination'); +const { NotFoundError } = require('../errors'); + +module.exports = function(ctx) { + const router = express.Router(); + + // Helper: verify container exists before operating on it + async function getVerifiedContainer(id) { + const container = ctx.docker.client.getContainer(id); + try { + await container.inspect(); + } catch (err) { + if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) { + throw new NotFoundError(`Container ${id}`); + } + throw err; + } + return container; + } + + // Start container + router.post('/:id/start', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + await container.start(); + res.json({ success: true, message: 'Container started' }); + }, 'container-start')); + + // Stop container + router.post('/:id/stop', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + await container.stop(); + res.json({ success: true, message: 'Container stopped' }); + }, 'container-stop')); + + // Restart container + router.post('/:id/restart', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + await container.restart(); + res.json({ success: true, message: 'Container restarted' }); + }, 'container-restart')); + + // Update container to latest image version + router.post('/:id/update', ctx.asyncHandler(async (req, res) => { + const containerId = req.params.id; + const container = await getVerifiedContainer(containerId); + + // Get container info + const containerInfo = await container.inspect(); + const imageName = containerInfo.Config.Image; + const containerName = containerInfo.Name.replace(/^\//, ''); + + ctx.log.info('docker', 'Updating container', { containerName, imageName }); + + // Pull the latest image + ctx.log.info('docker', `Pulling latest image: ${imageName}`); + await ctx.docker.pull(imageName); + + // Get current container config for recreation + const hostConfig = containerInfo.HostConfig; + const config = { + Image: imageName, + name: containerName, + Env: containerInfo.Config.Env, + ExposedPorts: containerInfo.Config.ExposedPorts, + Labels: containerInfo.Config.Labels, + HostConfig: { + Binds: hostConfig.Binds, + PortBindings: hostConfig.PortBindings, + RestartPolicy: hostConfig.RestartPolicy, + NetworkMode: hostConfig.NetworkMode, + ExtraHosts: hostConfig.ExtraHosts, + Privileged: hostConfig.Privileged, + CapAdd: hostConfig.CapAdd, + CapDrop: hostConfig.CapDrop, + Devices: hostConfig.Devices, + LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers + }, + NetworkingConfig: {}, + }; + + // Get network settings if using a custom network + if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { + const networkName = hostConfig.NetworkMode; + config.NetworkingConfig.EndpointsConfig = { + [networkName]: containerInfo.NetworkSettings.Networks[networkName], + }; + } + + // Stop and remove old container + ctx.log.info('docker', 'Stopping container', { containerName }); + await container.stop().catch(() => {}); // Ignore if already stopped + ctx.log.info('docker', 'Removing container', { containerName }); + await container.remove(); + + // Wait for port release (Windows/Docker Desktop can be slow to free ports) + await new Promise(r => setTimeout(r, 3000)); + + // Create and start new container + ctx.log.info('docker', 'Creating new container', { containerName }); + let newContainer; + try { + newContainer = await ctx.docker.client.createContainer(config); + ctx.log.info('docker', 'Starting container', { containerName }); + await newContainer.start(); + } catch (startError) { + // Clean up the failed container so it doesn't block future attempts + ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); + if (newContainer) { + try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } + } + throw startError; + } + + const newContainerInfo = await newContainer.inspect(); + + // Prune dangling images after update + try { + const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + if (pruneResult.SpaceReclaimed > 0) { + ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); + } + } catch (pruneErr) { + ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); + } + + res.json({ + success: true, + message: `Container ${containerName} updated successfully`, + newContainerId: newContainerInfo.Id, + }); + }, 'container-update')); + + // Check for available updates (compares local and remote image digests) + router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => { + const containerId = req.params.id; + const container = await getVerifiedContainer(containerId); + const containerInfo = await container.inspect(); + const imageName = containerInfo.Config.Image; + + const localImage = ctx.docker.client.getImage(containerInfo.Image); + const localImageInfo = await localImage.inspect(); + const localDigest = localImageInfo.RepoDigests?.[0] || null; + + let updateAvailable = false; + try { + const pullStream = await ctx.docker.pull(imageName); + + const downloadedLayers = pullStream.filter(e => + e.status === 'Downloading' || e.status === 'Download complete', + ); + updateAvailable = downloadedLayers.length > 0; + + const newImage = ctx.docker.client.getImage(imageName); + const newImageInfo = await newImage.inspect(); + const newDigest = newImageInfo.RepoDigests?.[0] || null; + + if (localDigest && newDigest && localDigest !== newDigest) { + updateAvailable = true; + } + } catch (pullError) { + ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message }); + } + + res.json({ + success: true, + imageName, + updateAvailable, + currentDigest: localDigest, + }); + }, 'container-check-update')); + + // Get container logs + router.get('/:id/logs', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + const logs = await container.logs({ + stdout: true, + stderr: true, + tail: 100, + timestamps: true, + }); + res.json({ success: true, logs: logs.toString() }); + }, 'container-logs')); + + // Delete container + router.delete('/:id', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + await container.remove({ force: true }); + res.json({ success: true, message: 'Container removed' }); + }, 'container-delete')); + + // Discover running containers + router.get('/discover', ctx.asyncHandler(async (req, res) => { + const containers = await ctx.docker.client.listContainers({ all: true }); + const samiContainers = containers.filter(container => + container.Labels && container.Labels['sami.managed'] === 'true', + ); + + const discoveredContainers = samiContainers.map(container => ({ + id: container.Id, + name: container.Names[0].replace('/', ''), + image: container.Image, + state: container.State, + status: container.Status, + appTemplate: container.Labels['sami.app'], + subdomain: container.Labels['sami.subdomain'], + ports: container.Ports, + })); + + const paginationParams = parsePaginationParams(req.query); + const result = paginate(discoveredContainers, paginationParams); + res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); + }, 'containers-discover')); + + return router; +}; diff --git a/dashcaddy-api/routes/monitoring.js b/dashcaddy-api/routes/monitoring.js index 69170e6..d1e8860 100644 --- a/dashcaddy-api/routes/monitoring.js +++ b/dashcaddy-api/routes/monitoring.js @@ -1,19 +1,23 @@ -const express = require('express'); +/** + * Monitoring and stats routes + * Refactored to use explicit dependencies + */ -module.exports = function(ctx) { +const express = require('express'); +const asyncHandler = require('../src/utils/async-handler'); + +module.exports = function({ docker, resourceMonitor }) { const router = express.Router(); // ===== RESOURCE MONITORING ENDPOINTS ===== - // Get all container stats (from resource monitor module) - router.get('/monitoring/stats', ctx.asyncHandler(async (req, res) => { - const stats = ctx.resourceMonitor.getAllStats(); + router.get('/monitoring/stats', asyncHandler(async (req, res) => { + const stats = resourceMonitor.getAllStats(); res.json({ success: true, stats }); }, 'monitoring-stats')); - // Get stats for specific container - router.get('/monitoring/stats/:containerId', ctx.asyncHandler(async (req, res) => { - const stats = ctx.resourceMonitor.getCurrentStats(req.params.containerId); + router.get('/monitoring/stats/:containerId', asyncHandler(async (req, res) => { + const stats = resourceMonitor.getCurrentStats(req.params.containerId); if (!stats) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Container'); @@ -21,17 +25,15 @@ module.exports = function(ctx) { res.json({ success: true, stats }); }, 'monitoring-stats-container')); - // Get historical stats - router.get('/monitoring/history/:containerId', ctx.asyncHandler(async (req, res) => { + router.get('/monitoring/history/:containerId', asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; - const history = ctx.resourceMonitor.getHistoricalStats(req.params.containerId, hours); + const history = resourceMonitor.getHistoricalStats(req.params.containerId, hours); res.json({ success: true, history, hours }); }, 'monitoring-history')); - // Get aggregated stats - router.get('/monitoring/aggregated/:containerId', ctx.asyncHandler(async (req, res) => { + router.get('/monitoring/aggregated/:containerId', asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; - const aggregated = ctx.resourceMonitor.getAggregatedStats(req.params.containerId, hours); + const aggregated = resourceMonitor.getAggregatedStats(req.params.containerId, hours); if (!aggregated) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Monitoring data'); @@ -39,49 +41,42 @@ module.exports = function(ctx) { res.json({ success: true, aggregated, hours }); }, 'monitoring-aggregated')); - // Configure alerts - router.post('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { - ctx.resourceMonitor.setAlertConfig(req.params.containerId, req.body); + router.post('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { + resourceMonitor.setAlertConfig(req.params.containerId, req.body); res.json({ success: true, message: 'Alert configuration saved' }); }, 'monitoring-alerts-set')); - // Get alert configuration - router.get('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { - const config = ctx.resourceMonitor.getAlertConfig(req.params.containerId); + router.get('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { + const config = resourceMonitor.getAlertConfig(req.params.containerId); res.json({ success: true, config: config || {} }); }, 'monitoring-alerts-get')); - // Delete alert configuration - router.delete('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { - ctx.resourceMonitor.removeAlertConfig(req.params.containerId); + router.delete('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { + resourceMonitor.removeAlertConfig(req.params.containerId); res.json({ success: true, message: 'Alert configuration removed' }); }, 'monitoring-alerts-delete')); // ===== CONTAINER STATS ENDPOINTS (legacy /stats/) ===== - // Get all container stats (live Docker stats) - router.get('/stats/containers', ctx.asyncHandler(async (req, res) => { - const containers = await ctx.docker.client.listContainers({ all: false }); + router.get('/stats/containers', asyncHandler(async (req, res) => { + const containers = await docker.client.listContainers({ all: false }); const stats = []; for (const containerInfo of containers) { try { - const container = ctx.docker.client.getContainer(containerInfo.Id); + const container = docker.client.getContainer(containerInfo.Id); const containerStats = await container.stats({ stream: false }); - // Calculate CPU percentage const cpuDelta = containerStats.cpu_stats.cpu_usage.total_usage - (containerStats.precpu_stats.cpu_usage?.total_usage || 0); const systemDelta = containerStats.cpu_stats.system_cpu_usage - (containerStats.precpu_stats.system_cpu_usage || 0); const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * (containerStats.cpu_stats.online_cpus || 1) : 0; - // Calculate memory usage const memUsage = containerStats.memory_stats.usage || 0; const memLimit = containerStats.memory_stats.limit || 1; const memPercent = (memUsage / memLimit) * 100; - // Network stats let netRx = 0, netTx = 0; if (containerStats.networks) { for (const net of Object.values(containerStats.networks)) { @@ -95,21 +90,15 @@ module.exports = function(ctx) { name: containerInfo.Names[0]?.replace(/^\//, '') || 'unknown', image: containerInfo.Image, status: containerInfo.State, - cpu: { - percent: Math.round(cpuPercent * 100) / 100, - }, + cpu: { percent: Math.round(cpuPercent * 100) / 100 }, memory: { used: memUsage, limit: memLimit, percent: Math.round(memPercent * 100) / 100, }, - network: { - rx: netRx, - tx: netTx, - }, + network: { rx: netRx, tx: netTx }, }); } catch (e) { - // Skip containers we can't get stats for console.log(`Could not get stats for ${containerInfo.Names[0]}:`, e.message); } } @@ -117,24 +106,20 @@ module.exports = function(ctx) { res.json({ success: true, stats, timestamp: new Date().toISOString() }); }, 'stats-containers')); - // Get single container stats - router.get('/stats/container/:id', ctx.asyncHandler(async (req, res) => { - const container = ctx.docker.client.getContainer(req.params.id); + router.get('/stats/container/:id', asyncHandler(async (req, res) => { + const container = docker.client.getContainer(req.params.id); const containerStats = await container.stats({ stream: false }); const info = await container.inspect(); - // Calculate CPU percentage const cpuDelta = containerStats.cpu_stats.cpu_usage.total_usage - (containerStats.precpu_stats.cpu_usage?.total_usage || 0); const systemDelta = containerStats.cpu_stats.system_cpu_usage - (containerStats.precpu_stats.system_cpu_usage || 0); const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * (containerStats.cpu_stats.online_cpus || 1) : 0; - // Memory const memUsage = containerStats.memory_stats.usage || 0; const memLimit = containerStats.memory_stats.limit || 1; - // Network let netRx = 0, netTx = 0; if (containerStats.networks) { for (const net of Object.values(containerStats.networks)) { @@ -150,9 +135,7 @@ module.exports = function(ctx) { image: info.Config.Image, status: info.State.Status, started: info.State.StartedAt, - cpu: { - percent: Math.round(cpuPercent * 100) / 100, - }, + cpu: { percent: Math.round(cpuPercent * 100) / 100 }, memory: { used: memUsage, limit: memLimit, From 883cce27df28622720c942f0e3db3876f4a9bd18 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:09:55 +0100 Subject: [PATCH 08/11] Phase 3 (WIP): Refactor license, credentials, backups routes - All use explicit deps instead of ctx - Pattern consistent across all refactored routes --- dashcaddy-api/routes/backups.js | 28 +++++++++------------ dashcaddy-api/routes/credentials.js | 16 ++++++------ dashcaddy-api/routes/license.js | 38 ++++++++++++----------------- 3 files changed, 36 insertions(+), 46 deletions(-) diff --git a/dashcaddy-api/routes/backups.js b/dashcaddy-api/routes/backups.js index e766b1d..fc640d7 100644 --- a/dashcaddy-api/routes/backups.js +++ b/dashcaddy-api/routes/backups.js @@ -1,36 +1,32 @@ const express = require('express'); +const asyncHandler = require('../src/utils/async-handler'); -module.exports = function(ctx) { +module.exports = function({ backupManager }) { const router = express.Router(); - // Get backup configuration - router.get('/backups/config', ctx.asyncHandler(async (req, res) => { - const config = ctx.backupManager.getConfig(); + router.get('/backups/config', asyncHandler(async (req, res) => { + const config = backupManager.getConfig(); res.json({ success: true, config }); }, 'backups-config-get')); - // Update backup configuration - router.post('/backups/config', ctx.asyncHandler(async (req, res) => { - ctx.backupManager.updateConfig(req.body); + router.post('/backups/config', asyncHandler(async (req, res) => { + backupManager.updateConfig(req.body); res.json({ success: true, message: 'Backup configuration updated' }); }, 'backups-config-update')); - // Execute manual backup - router.post('/backups/execute', ctx.asyncHandler(async (req, res) => { - const backup = await ctx.backupManager.executeBackup('manual', req.body); + router.post('/backups/execute', asyncHandler(async (req, res) => { + const backup = await backupManager.executeBackup('manual', req.body); res.json({ success: true, backup }); }, 'backups-execute')); - // Get backup history - router.get('/backups/history', ctx.asyncHandler(async (req, res) => { + router.get('/backups/history', asyncHandler(async (req, res) => { const limit = parseInt(req.query.limit) || 50; - const history = ctx.backupManager.getHistory(limit); + const history = backupManager.getHistory(limit); res.json({ success: true, history }); }, 'backups-history')); - // Restore from backup - router.post('/backups/restore/:backupId', ctx.asyncHandler(async (req, res) => { - const result = await ctx.backupManager.restoreBackup(req.params.backupId, req.body); + router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => { + const result = await backupManager.restoreBackup(req.params.backupId, req.body); res.json({ success: true, result }); }, 'backups-restore')); diff --git a/dashcaddy-api/routes/credentials.js b/dashcaddy-api/routes/credentials.js index 11d95d4..56011ff 100644 --- a/dashcaddy-api/routes/credentials.js +++ b/dashcaddy-api/routes/credentials.js @@ -1,21 +1,21 @@ const express = require('express'); +const asyncHandler = require('../src/utils/async-handler'); +const { errorResponse } = require('../src/utils/responses'); -module.exports = function(ctx) { +module.exports = function({ credentialManager }) { const router = express.Router(); - // List all stored credentials (keys only, no values) - router.get('/credentials/list', ctx.asyncHandler(async (req, res) => { - const keys = await ctx.credentialManager.list(); + router.get('/credentials/list', asyncHandler(async (req, res) => { + const keys = await credentialManager.list(); res.json({ success: true, credentials: keys, count: keys.length }); }, 'credentials-list')); - // Rotate encryption key — re-encrypts all stored credentials - router.post('/credentials/rotate-key', ctx.asyncHandler(async (req, res) => { - const success = await ctx.credentialManager.rotateEncryptionKey(); + router.post('/credentials/rotate-key', asyncHandler(async (req, res) => { + const success = await credentialManager.rotateEncryptionKey(); if (success) { res.json({ success: true, message: 'Encryption key rotated, all credentials re-encrypted' }); } else { - ctx.errorResponse(res, 500, 'Key rotation failed'); + errorResponse(res, 500, 'Key rotation failed'); } }, 'credentials-rotate')); diff --git a/dashcaddy-api/routes/license.js b/dashcaddy-api/routes/license.js index 5039c86..41cce63 100644 --- a/dashcaddy-api/routes/license.js +++ b/dashcaddy-api/routes/license.js @@ -1,50 +1,44 @@ const express = require('express'); +const asyncHandler = require('../src/utils/async-handler'); +const { errorResponse } = require('../src/utils/responses'); -module.exports = function(ctx) { +module.exports = function({ licenseManager }) { const router = express.Router(); - // Activate a license code - router.post('/activate', ctx.asyncHandler(async (req, res) => { + router.post('/activate', asyncHandler(async (req, res) => { const { code } = req.body; if (!code) { - return ctx.errorResponse(res, 400, 'License code is required'); + return errorResponse(res, 400, 'License code is required'); } - const result = await ctx.licenseManager.activate(code); + const result = await licenseManager.activate(code); if (result.success) { - res.json({ - success: true, - message: result.message, - license: result.activation, - }); + res.json({ success: true, message: result.message, license: result.activation }); } else { - ctx.errorResponse(res, 400, result.message); + errorResponse(res, 400, result.message); } }, 'license-activate')); - // Get current license status - router.get('/status', ctx.asyncHandler(async (req, res) => { - const status = ctx.licenseManager.getStatus(); + router.get('/status', asyncHandler(async (req, res) => { + const status = licenseManager.getStatus(); res.json({ success: true, license: status }); }, 'license-status')); - // Deactivate current license - router.post('/deactivate', ctx.asyncHandler(async (req, res) => { - const result = await ctx.licenseManager.deactivate(); + router.post('/deactivate', asyncHandler(async (req, res) => { + const result = await licenseManager.deactivate(); if (result.success) { res.json({ success: true, message: result.message }); } else { - ctx.errorResponse(res, 400, result.message); + errorResponse(res, 400, result.message); } }, 'license-deactivate')); - // Check if a specific feature is available (lightweight check for frontend) - router.get('/feature/:feature', ctx.asyncHandler(async (req, res) => { + router.get('/feature/:feature', asyncHandler(async (req, res) => { const { feature } = req.params; - const available = ctx.licenseManager.hasFeature(feature); - const status = ctx.licenseManager.getStatus(); + const available = licenseManager.hasFeature(feature); + const status = licenseManager.getStatus(); res.json({ success: true, From 024be9c929f3e8e0695fc726339f68c6896104d8 Mon Sep 17 00:00:00 2001 From: Krystie Date: Mon, 23 Mar 2026 10:34:57 +0100 Subject: [PATCH 09/11] Build frontend bundles with CSRF token support --- status/dist/core.js | 727 ++++++++++++++++++++ status/dist/features.js | 1367 +++++++++++++++++++++++++++++++++++++ status/dist/init.js | 220 ++++++ status/dist/onboarding.js | 218 ++++++ 4 files changed, 2532 insertions(+) create mode 100644 status/dist/core.js create mode 100644 status/dist/features.js create mode 100644 status/dist/init.js create mode 100644 status/dist/onboarding.js 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+='