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();