From e2c67a8fe8653a5acb79b02f5542e9a0e8bbf7d1 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:00:25 +0100 Subject: [PATCH] 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();