Compare commits

...
6 Commits
Author SHA1 Message Date
Hermes 53680c4c74 v1.13.4: Standardize all route responses to use response helpers
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Convert ~160 raw res.json()/res.status().json() calls across 32+ files
to use centralized helpers from src/utils/responses.js (ok, errorResponse,
successMessage, notFound, validationError, forbidden, unauthorized, conflict).

No behavior changes — response shapes are identical. Future schema changes
(e.g., requestId envelope) only need to update one module.

Fix error vs errorResponse signature mismatch in routes/health.js CA cert
endpoint where error(res, message, statusCode) was being called with
errorResponse(res, statusCode, message, extras) argument order.

Files changed: middleware.js, csrf-protection.js, error-handler.js,
license-manager.js, src/app.js, and 27 route files.

Test suite: 755 pass / 4 pre-existing failures (services credential tests).
2026-06-11 00:48:13 -07:00
Hermes 2d394d882d Standardize response shapes and fix dead fetchT timeout keys
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Three small cleanups for v1.14.0:

1. /caddy/cas now uses standard success envelope
   Was: { status: 'success', data: { cas: caList } }
   Now: { success: true, cas: caList }
   Updated frontend service-infrastructure.js to match.

2. /api/health/ca now uses standard envelope + meaningful HTTP codes
   Was: { status, message, daysUntilExpiration } with 200 on every error
   Now: { success, caStatus, message|error, daysUntilExpiration }
        with 200 / 404 / 500 as appropriate
   caStatus field preserves the original 'healthy'/'warning'/'critical'/'error'
   semantic so any future consumer of the CA-health state still has it.
   Tests updated to match.

3. Dead timeout: keys in fetchT opts are now a warning, not a silent strip
   src/utils/http.js:41 used to do  without telling
   anyone. Callers that wrote fetchT(url, { timeout: 5000 }) got the default
   5s timeout with no indication that their explicit value was ignored.
   Now it logs a warning naming the call site, then strips the key.
   Fixed 4 call sites that had stale timeout: keys:
   - src/context/caddy.js
   - src/context/dns.js
   - src/context/provider-dns.js
   - routes/dns.js (2 places)
2026-06-10 21:52:33 -07:00
Hermes 11cfb8c26a Consolidate response helpers and error logger to single modules
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Two cleanups in one pass for the v1.14.0 'works on any platform' theme:

1. Response helpers — merged src/utils/responses.js and the root-level
   response-helpers.js into a single module at src/utils/responses.js.
   The old module had a richer set (created, noContent, validationError,
   unauthorized, forbidden, notFound, conflict) and is now re-exported
   from the new location. Updated 15 routes to import from
   src/utils/responses and deleted the root response-helpers.js.

2. Error logger — error-handler.js now uses the unified
   src/utils/logging.js#logError (same one src/app.js uses), so all errors
   go to one log file with one rotation policy. Removed the dead
   asyncHandler export (the real one is in src/utils/async-handler.js
   and is used everywhere). Deleted the legacy error-logger.js.

Both are invisible to users — same HTTP response shapes, same log file
path, same error format. Internal-only refactor.
2026-06-10 21:37:55 -07:00
Hermes caa09dcebe Bump to v1.13.1 - fix /health/ready res.status bug
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The readiness probe was using asyncHandler directly, but this codebase's
asyncHandler has signature (logError, fn, context) — first arg is the logger.
Switched to boundAsyncHandler which is what every other route in src/app.js
uses. Verified working on both DNS2 (Docker) and Contabo (systemd).

8 new tests in __tests__/health-endpoints.test.js verify both endpoints.
2026-06-10 21:12:37 -07:00
Hermes 264de9644c Fix /health/ready res.status bug + add comprehensive health endpoint tests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The readiness probe was crashing with 'res.status is not a function' because
asyncHandler(async (req, res) => {...}, 'health-ready') was called directly,
but asyncHandler's signature is (logError, fn, context) — first arg is the
logger, not the handler. The fix uses boundAsyncHandler like all other routes
in the file do.

Added 8 unit tests for both /health/live and /health/ready:
- live always 200 (liveness ≠ readiness)
- ready returns 503 when config/services/docker fail
- no 'res.status is not a function' crash when dependencies fail
- all 4 check keys present in response

Also added MONITORING_PUBLIC env var (defaults true) and the new health
endpoints to PUBLIC_ROUTES so k8s probes can hit them without auth.
2026-06-10 20:35:27 -07:00
Hermes e40cb35011 Add MONITORING_PUBLIC env var to gate monitoring endpoints behind auth
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
By default /api/v1/monitoring/stats and /api/v1/health-checks/status are
public (current behavior, dashboard needs them pre-login). Users deploying
DashCaddy on the open internet can now set:

  MONITORING_PUBLIC=false

...or add 'monitoring: { public: false }' to config.json to require auth.
This prevents anonymous disclosure of CPU/memory/disk data.

The check uses env var first, then config.json, then defaults to true
(preserves current behavior for existing users).
2026-06-10 20:13:53 -07:00
63 changed files with 764 additions and 621 deletions
+20
View File
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.13.4] - 2026-06-12
### Changed
- Standardized all route handler responses to use helpers from `src/utils/responses.js`
(`ok`, `errorResponse`, `successMessage`, `notFound`, `validationError`, `forbidden`,
`unauthorized`, `conflict`). ~160 raw `res.json()` calls converted across 32+ files.
No behavior changes — response shapes are identical. This ensures future schema
changes (e.g., adding a `requestId` envelope) only need to update one module.
- Fixed `error` vs `errorResponse` signature mismatch in `routes/health.js` CA cert
endpoint. The `error` helper takes `(res, message, statusCode)` while `errorResponse`
takes `(res, statusCode, message, extras)` — the wrong alias was being used for
calls that needed the 4-argument form.
- Updated `middleware.js`, `csrf-protection.js`, `error-handler.js`, and
`license-manager.js` to use response helpers for rejection/error responses
instead of inline `res.status().json()`.
### Note
- 4 pre-existing test failures in `services.routes.test.js` (credential storage)
remain from before this release. They are unrelated to the standardization pass.
## [1.5.0] - 2026-05-17 ## [1.5.0] - 2026-05-17
### Changed (BREAKING) ### Changed (BREAKING)
+1 -1
View File
@@ -1 +1 @@
1.13.0 1.13.4
+13 -20
View File
@@ -1,8 +1,18 @@
jest.mock('../error-logger', () => ({ // Mock the unified logging module so we can verify logError is called
logError: jest.fn(), // without writing to the actual error.log file
jest.mock('../src/utils/logging', () => ({
logError: jest.fn().mockResolvedValue(),
safeErrorMessage: jest.fn((err) => {
if (!err) return 'An internal error occurred';
return err.message || String(err);
}),
createLogger: jest.fn(() => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn()
})),
LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 }
})); }));
const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler'); const { errorMiddleware, notFoundHandler } = require('../error-handler');
const { const {
AppError, AppError,
ValidationError, ValidationError,
@@ -30,23 +40,6 @@ describe('Error Handler', () => {
next = jest.fn(); next = jest.fn();
}); });
describe('asyncHandler', () => {
it('calls the wrapped function', async () => {
const fn = jest.fn().mockResolvedValue();
const wrapped = asyncHandler(fn);
await wrapped(req, res, next);
expect(fn).toHaveBeenCalledWith(req, res, next);
});
it('calls next(err) on rejected promise', async () => {
const error = new Error('async fail');
const fn = jest.fn().mockRejectedValue(error);
const wrapped = asyncHandler(fn);
await wrapped(req, res, next);
expect(next).toHaveBeenCalledWith(error);
});
});
describe('errorMiddleware', () => { describe('errorMiddleware', () => {
it('returns 400 for ValidationError', () => { it('returns 400 for ValidationError', () => {
const err = new ValidationError('bad input', 'email'); const err = new ValidationError('bad input', 'email');
@@ -0,0 +1,201 @@
/**
* Health endpoint tests
*
* Verifies:
* - /health/live always returns 200
* - /health/ready returns 200 with valid structure when all deps OK
* - /health/ready returns 503 when a critical dep is down
* - /health/ready does NOT crash with "res.status is not a function"
*/
const express = require('express');
const request = require('supertest');
// Mock dockerode BEFORE anything else
jest.mock('dockerode', () => {
return jest.fn().mockImplementation(() => ({
ping: jest.fn().mockImplementation(() => {
if (process.env.MOCK_DOCKER_DOWN === '1') {
return Promise.reject(new Error('docker unreachable'));
}
return Promise.resolve('OK');
})
}));
});
// Build a minimal Express app with the same health handlers as src/app.js
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
const app = express();
const config = {
CONFIG_FILE: '/tmp/dc-test-config.json',
SERVICES_FILE: '/tmp/dc-test-services.json',
CADDY_ADMIN_URL: 'http://localhost:2019'
};
// Mock fs
const fs = require('fs');
const realExistsSync = fs.existsSync;
const realReadFileSync = fs.readFileSync;
fs.existsSync = (p) => {
if (p === config.CONFIG_FILE) return configOk;
if (p === config.SERVICES_FILE) return servicesOk;
return realExistsSync(p);
};
fs.readFileSync = (p, ...args) => {
if (p === config.CONFIG_FILE) {
if (!configOk) throw new Error('config not found');
return '{}';
}
if (p === config.SERVICES_FILE) {
if (!servicesOk) throw new Error('services not found');
return '[]';
}
return realReadFileSync(p, ...args);
};
// /health/live (matches src/app.js exactly)
app.get('/health/live', (req, res) => {
res.json({ status: 'alive', uptime: process.uptime() });
});
// /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern)
const { asyncHandler } = require('../src/utils/async-handler');
const logError = async () => {}; // noop logger
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
const checks = {};
let allOk = true;
try {
if (fs.existsSync(config.CONFIG_FILE)) {
fs.readFileSync(config.CONFIG_FILE, 'utf8');
checks.configFile = { ok: true };
} else {
checks.configFile = { ok: false, error: 'Config file not found' };
allOk = false;
}
} catch (e) {
checks.configFile = { ok: false, error: e.message };
allOk = false;
}
try {
if (fs.existsSync(config.SERVICES_FILE)) {
fs.readFileSync(config.SERVICES_FILE, 'utf8');
checks.servicesFile = { ok: true };
} else {
checks.servicesFile = { ok: false, error: 'Services file not found' };
allOk = false;
}
} catch (e) {
checks.servicesFile = { ok: false, error: e.message };
allOk = false;
}
try {
const docker = require('dockerode')();
await docker.ping();
checks.docker = { ok: true };
} catch (e) {
checks.docker = { ok: false, error: e.message };
allOk = false;
}
try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
clearTimeout(timeout);
checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false;
} catch (e) {
checks.caddy = { ok: false, error: e.message };
allOk = false;
}
const body = {
status: allOk ? 'ready' : 'not-ready',
timestamp: new Date().toISOString(),
checks
};
res.status(allOk ? 200 : 503).json(body);
}));
return app;
}
describe('Health Endpoints', () => {
beforeEach(() => {
delete process.env.MOCK_DOCKER_DOWN;
});
describe('GET /health/live', () => {
it('always returns 200 with status: alive', async () => {
const app = buildApp();
const res = await request(app).get('/health/live');
expect(res.status).toBe(200);
expect(res.body.status).toBe('alive');
expect(typeof res.body.uptime).toBe('number');
});
it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => {
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false });
const res = await request(app).get('/health/live');
expect(res.status).toBe(200);
});
});
describe('GET /health/ready', () => {
it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => {
const app = buildApp();
const res = await request(app).get('/health/ready');
// config + services + docker should all be OK
expect(res.body.checks.configFile.ok).toBe(true);
expect(res.body.checks.servicesFile.ok).toBe(true);
expect(res.body.checks.docker.ok).toBe(true);
// caddy is tested in sandbox — may be 403 or 200
expect(res.body).toHaveProperty('checks');
expect(res.body).toHaveProperty('status');
});
it('returns 503 when config file is missing', async () => {
const app = buildApp({ configOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.status).toBe('not-ready');
expect(res.body.checks.configFile.ok).toBe(false);
});
it('returns 503 when services file is missing', async () => {
const app = buildApp({ servicesOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.checks.servicesFile.ok).toBe(false);
});
it('returns 503 when Docker is unreachable', async () => {
const app = buildApp({ dockerOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.checks.docker.ok).toBe(false);
});
it('does NOT crash with "res.status is not a function" when dependencies fail', async () => {
const app = buildApp({ dockerOk: false });
const res = await request(app).get('/health/ready');
const bodyStr = JSON.stringify(res.body);
expect(bodyStr).not.toMatch(/res\.status is not a function/);
// Should always be a valid response object
expect(res.body).toHaveProperty('checks');
});
it('responds with all 4 expected check keys', async () => {
const app = buildApp();
const res = await request(app).get('/health/ready');
expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']);
});
});
});
@@ -538,7 +538,7 @@ describe('Health Routes', () => {
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/health/ca'); const res = await request(app).get('/api/health/ca');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.status).toBe('healthy'); expect(res.body.caStatus).toBe('healthy');
expect(res.body.daysUntilExpiration).toBeGreaterThan(90); expect(res.body.daysUntilExpiration).toBeGreaterThan(90);
}); });
@@ -551,7 +551,7 @@ describe('Health Routes', () => {
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/health/ca'); const res = await request(app).get('/api/health/ca');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.status).toBe('warning'); expect(res.body.caStatus).toBe('warning');
expect(res.body.daysUntilExpiration).toBeLessThan(90); expect(res.body.daysUntilExpiration).toBeLessThan(90);
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30); expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30);
}); });
@@ -565,7 +565,7 @@ describe('Health Routes', () => {
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/health/ca'); const res = await request(app).get('/api/health/ca');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.status).toBe('critical'); expect(res.body.caStatus).toBe('critical');
expect(res.body.daysUntilExpiration).toBeLessThan(30); expect(res.body.daysUntilExpiration).toBeLessThan(30);
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0); expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0);
}); });
@@ -579,7 +579,7 @@ describe('Health Routes', () => {
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/health/ca'); const res = await request(app).get('/api/health/ca');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.status).toBe('critical'); expect(res.body.caStatus).toBe('critical');
expect(res.body.daysUntilExpiration).toBeLessThan(7); expect(res.body.daysUntilExpiration).toBeLessThan(7);
}); });
@@ -592,7 +592,7 @@ describe('Health Routes', () => {
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/health/ca'); const res = await request(app).get('/api/health/ca');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.status).toBe('critical'); expect(res.body.caStatus).toBe('critical');
expect(res.body.daysUntilExpiration).toBeLessThan(0); expect(res.body.daysUntilExpiration).toBeLessThan(0);
expect(res.body.message).toMatch(/EXPIRED/); expect(res.body.message).toMatch(/EXPIRED/);
}); });
@@ -601,9 +601,9 @@ describe('Health Routes', () => {
exists.mockResolvedValue(false); exists.mockResolvedValue(false);
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/health/ca'); const res = await request(app).get('/api/health/ca');
expect(res.status).toBe(200); expect(res.status).toBe(404);
expect(res.body.status).toBe('error'); expect(res.body.caStatus).toBe('error');
expect(res.body.message).toMatch(/not found/); expect(res.body.error).toMatch(/not found/);
expect(res.body.daysUntilExpiration).toBeNull(); expect(res.body.daysUntilExpiration).toBeNull();
}); });
@@ -612,9 +612,9 @@ describe('Health Routes', () => {
execSync.mockImplementation(() => { throw new Error('openssl not found'); }); execSync.mockImplementation(() => { throw new Error('openssl not found'); });
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/health/ca'); const res = await request(app).get('/api/health/ca');
expect(res.status).toBe(200); expect(res.status).toBe(500);
expect(res.body.status).toBe('error'); expect(res.body.caStatus).toBe('error');
expect(res.body.message).toBe('openssl not found'); expect(res.body.error).toBe('openssl not found');
expect(res.body.daysUntilExpiration).toBeNull(); expect(res.body.daysUntilExpiration).toBeNull();
}); });
}); });
@@ -34,7 +34,7 @@ jest.mock('../../pagination', () => ({
parsePaginationParams: jest.fn(() => null), parsePaginationParams: jest.fn(() => null),
})); }));
jest.mock('../../response-helpers', () => ({ jest.mock('../../src/utils/responses', () => ({
success: jest.fn((res, data, statusCode = 200) => { success: jest.fn((res, data, statusCode = 200) => {
return res.status(statusCode).json({ success: true, ...data }); return res.status(statusCode).json({ success: true, ...data });
}), }),
@@ -103,12 +103,12 @@ describe('Services Routes', () => {
}); });
describe('GET /api/services', () => { describe('GET /api/services', () => {
it('returns empty array when no services file', async () => { it('returns empty services array (enveloped) when no services file', async () => {
exists.mockResolvedValue(false); exists.mockResolvedValue(false);
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/services'); const res = await request(app).get('/api/services');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual([]); expect(res.body).toEqual({ success: true, services: [] });
}); });
it('returns services list', async () => { it('returns services list', async () => {
+4 -9
View File
@@ -8,6 +8,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const cryptoUtils = require('./crypto-utils'); const cryptoUtils = require('./crypto-utils');
const { errorResponse } = require('./src/utils/responses');
const CSRF_TOKEN_LENGTH = 32; const CSRF_TOKEN_LENGTH = 32;
const CSRF_COOKIE_NAME = 'dashcaddy_csrf'; const CSRF_COOKIE_NAME = 'dashcaddy_csrf';
@@ -169,18 +170,14 @@ function csrfValidationMiddleware(req, res, next) {
// Validate both values exist // Validate both values exist
if (!cookieNonce) { if (!cookieNonce) {
console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`); console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`);
return res.status(403).json({ return errorResponse(res, 403, '[DC-100] CSRF token missing', {
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.'
}); });
} }
if (!headerToken) { if (!headerToken) {
console.warn(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}`); console.warn(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}`);
return res.status(403).json({ return errorResponse(res, 403, '[DC-100] CSRF token missing', {
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.'
}); });
} }
@@ -204,9 +201,7 @@ function csrfValidationMiddleware(req, res, next) {
} catch (err) { } catch (err) {
console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`); console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`);
return res.status(403).json({ return errorResponse(res, 403, '[DC-101] CSRF token invalid', {
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.'
}); });
} }
+39 -39
View File
@@ -1,66 +1,67 @@
/** /**
* DashCaddy Error Handler Middleware * DashCaddy Error Handler Middleware
* Centralizes error handling logic to eliminate duplicate catch blocks * Centralizes error handling logic to eliminate duplicate catch blocks
*
* Logging: this middleware uses the unified logError from src/utils/logging.js
* (same one src/app.js uses), so all errors go to one log file. The legacy
* ./error-logger.js and its ./error.log file have been retired.
*/ */
const path = require('path');
const { AppError } = require('./errors'); const { AppError } = require('./errors');
const { logError } = require('./error-logger'); const { LIMITS } = require('./constants');
const { logError: unifiedLogError, safeErrorMessage } = require('./src/utils/logging');
const { errorResponse } = require('./src/utils/responses');
/** const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
* Async route handler wrapper const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
* Automatically catches errors and passes to error middleware
* Usage: app.get('/route', asyncHandler(async (req, res) => { ... }))
*/
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
/** /**
* Global error handling middleware * Global error handling middleware
* MUST be registered after all routes in server.js * MUST be registered after all routes in server.js
*/ */
function errorMiddleware(err, req, res, next) { function errorMiddleware(err, req, res, next) {
// Log all errors with request context // Log all errors with request context (unified, same file the rest of the app uses)
logError(req.path, err, { unifiedLogError(
method: req.method, ERROR_LOG_FILE,
ip: req.ip, MAX_ERROR_LOG_SIZE,
userId: req.user?.id, req.path,
body: req.body err,
}); {
method: req.method,
ip: req.ip,
userId: req.user?.id,
body: req.body
}
).catch(e => console.error('Failed to write to error log:', e.message));
// Determine if this is an operational error (AppError) or programming error // Determine if this is an operational error (AppError) or programming error
const isOperational = err.isOperational || err instanceof AppError; const isOperational = err.isOperational || err instanceof AppError;
// Status code // Status code
const statusCode = err.statusCode || 500; const statusCode = err.statusCode || 500;
// Error code (DC-XXX format) // Error code (DC-XXX format)
const code = err.code || `DC-${statusCode}`; const code = err.code || `DC-${statusCode}`;
// Build response // Build extras for response
const response = { const extras = { code };
success: false,
error: isOperational ? err.message : 'Internal server error',
code
};
// Add optional fields if present // Add optional fields if present
if (err.requiresTotp) response.requiresTotp = true; if (err.requiresTotp) extras.requiresTotp = true;
if (err.retryAfter) response.retryAfter = err.retryAfter; if (err.retryAfter) extras.retryAfter = err.retryAfter;
if (err.field) response.field = err.field; if (err.field) extras.field = err.field;
if (err.resource) response.resource = err.resource; if (err.resource) extras.resource = err.resource;
if (err.details && Object.keys(err.details).length > 0) response.details = err.details; if (err.details && Object.keys(err.details).length > 0) extras.details = err.details;
// Development mode: include stack trace // Development mode: include stack trace
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
response.stack = err.stack; extras.stack = err.stack;
} }
// Send response // Send response
res.status(statusCode).json(response); errorResponse(res, statusCode, isOperational ? safeErrorMessage(err) : 'Internal server error', extras);
// For non-operational errors, log as fatal // For non-operational errors, log as fatal
if (!isOperational) { if (!isOperational) {
console.error('FATAL: Non-operational error detected', { console.error('FATAL: Non-operational error detected', {
@@ -81,7 +82,6 @@ function notFoundHandler(req, res, next) {
} }
module.exports = { module.exports = {
asyncHandler,
errorMiddleware, errorMiddleware,
notFoundHandler notFoundHandler
}; };
-135
View File
@@ -1,135 +0,0 @@
// Error Logger Utility
// Centralized error logging with rotation and request context tracking
const fsp = require('fs').promises;
const path = require('path');
const { LIMITS } = require('./constants');
const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
/**
* Check if file exists
*/
async function exists(filepath) {
try {
await fsp.access(filepath);
return true;
} catch {
return false;
}
}
/**
* Log error with context and rotation
* @param {string} context - Where the error occurred
* @param {Error|string} error - The error to log
* @param {Object} additionalInfo - Additional context (req, etc.)
*/
async function logError(context, error, additionalInfo = {}) {
const timestamp = new Date().toISOString();
// Extract request context if a request object is provided
const requestContext = extractRequestContext(additionalInfo.req);
if (additionalInfo.req) {
delete additionalInfo.req; // Remove req to avoid circular refs
}
const logEntry = {
timestamp,
context,
...requestContext,
error: {
message: error.message || error,
stack: error.stack,
code: error.code
},
...additionalInfo
};
// Format log line with request context
const contextInfo = Object.keys(requestContext).length > 0
? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}`
: '';
const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`;
try {
// Rotate log if it exceeds max size
await rotateLogIfNeeded();
await fsp.appendFile(ERROR_LOG_FILE, logLine);
} catch (e) {
console.error('Failed to write to error log', e.message);
}
}
/**
* Extract request context from Express request object
*/
function extractRequestContext(req) {
if (!req) return {};
const clientIP = req.ip || req.socket?.remoteAddress || '';
return {
requestId: req.id,
ip: clientIP,
userAgent: req.get('user-agent'),
method: req.method,
path: req.path
};
}
/**
* Rotate log file if it exceeds max size
*/
async function rotateLogIfNeeded() {
try {
const stats = await fsp.stat(ERROR_LOG_FILE);
if (stats.size > MAX_ERROR_LOG_SIZE) {
const rotated = ERROR_LOG_FILE + '.1';
if (await exists(rotated)) {
await fsp.unlink(rotated);
}
await fsp.rename(ERROR_LOG_FILE, rotated);
}
} catch (_) {
// File may not exist yet, that's fine
}
}
/**
* Return a safe error message to the client without leaking internals
*/
function safeErrorMessage(error) {
const msg = error.message || String(error);
// Detect port conflict errors from Docker
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
const port = portMatch ? portMatch[1] : 'requested';
return `Port ${port} is already in use. Please choose a different port or stop the conflicting service.`;
}
// Detect container not found errors
if (msg.includes('No such container')) {
return 'Container not found';
}
// Detect network errors
if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) {
return 'Service unavailable';
}
// Generic safe message for unknown errors
if (process.env.NODE_ENV === 'production') {
return 'An error occurred. Please try again or contact support.';
}
// In development, show the actual error
return msg;
}
module.exports = {
logError,
safeErrorMessage
};
+2 -3
View File
@@ -15,6 +15,7 @@ const os = require('os');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { verifyCode, parseCode, VALID_DURATIONS } = require('./license-keygen'); const { verifyCode, parseCode, VALID_DURATIONS } = require('./license-keygen');
const { errorResponse } = require('./src/utils/responses');
const LICENSE_CRED_KEY = 'license.activation'; const LICENSE_CRED_KEY = 'license.activation';
const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when license server exists const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when license server exists
@@ -344,9 +345,7 @@ class LicenseManager {
} }
const featureInfo = PREMIUM_FEATURES[feature] || { name: feature }; const featureInfo = PREMIUM_FEATURES[feature] || { name: feature };
return res.status(403).json({ return errorResponse(res, 403, `${featureInfo.name} requires a DashCaddy Premium subscription.`, {
success: false,
error: `${featureInfo.name} requires a DashCaddy Premium subscription.`,
premiumRequired: true, premiumRequired: true,
feature, feature,
featureName: featureInfo.name, featureName: featureInfo.name,
+34 -13
View File
@@ -15,6 +15,7 @@ const crypto = require('crypto');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection'); const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection');
const { RATE_LIMITS, LIMITS, APP } = require('./constants'); const { RATE_LIMITS, LIMITS, APP } = require('./constants');
const { errorResponse, unauthorized, forbidden, validationError } = require('./src/utils/responses');
const { CACHE_CONFIGS, createCache } = require('./cache-config'); const { CACHE_CONFIGS, createCache } = require('./cache-config');
/** /**
@@ -33,7 +34,7 @@ module.exports = function configureMiddleware(app, {
// ── Container ID param validation ── // ── Container ID param validation ──
app.param('id', (req, res, next, id) => { app.param('id', (req, res, next, id) => {
if (req.path.includes('/containers/') && !isValidContainerId(id)) { if (req.path.includes('/containers/') && !isValidContainerId(id)) {
return res.status(400).json({ success: false, error: 'Invalid container ID' }); return validationError(res, 'Invalid container ID');
} }
next(); next();
}); });
@@ -127,9 +128,7 @@ module.exports = function configureMiddleware(app, {
const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim())); const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim()));
if (!fromTailscale) { if (!fromTailscale) {
return res.status(403).json({ return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', {
success: false,
error: '[DC-120] Access denied. This dashboard requires Tailscale connection.',
requiresTailscale: true, requiresTailscale: true,
clientIP: clientIP clientIP: clientIP
}); });
@@ -150,9 +149,7 @@ module.exports = function configureMiddleware(app, {
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip); for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
} }
if (!knownIPs.has(clientTailscaleIP)) { if (!knownIPs.has(clientTailscaleIP)) {
return res.status(403).json({ return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
success: false,
error: '[DC-121] Access denied. Device not in allowed tailnet.',
requiresTailscale: true, requiresTailscale: true,
clientIP clientIP
}); });
@@ -277,9 +274,32 @@ module.exports = function configureMiddleware(app, {
} }
// ── Public routes (bypass TOTP and JWT auth) ── // ── Public routes (bypass TOTP and JWT auth) ──
// Routes here are accessible without authentication. By default the
// monitoring/health-check endpoints are public so the dashboard can
// render widgets before the user logs in. Set MONITORING_PUBLIC=false
// (env var) or `monitoring: { public: false }` (config.json) to require
// auth for these — useful for internet-exposed deployments where
// CPU/memory/disk data is sensitive.
const MONITORING_PUBLIC = (() => {
if (process.env.MONITORING_PUBLIC === 'false') return false;
if (process.env.MONITORING_PUBLIC === 'true') return true;
// Default: check config.json if loaded
try {
const cfg = require('./src/config/site').siteConfig;
if (cfg && cfg.monitoring && typeof cfg.monitoring.public === 'boolean') {
return cfg.monitoring.public;
}
} catch { /* config not loaded yet, use default */ }
return true; // default: public (current behavior, dashboard needs it)
})();
const PUBLIC_ROUTES = [ const PUBLIC_ROUTES = [
{ path: '/health', exact: true }, { path: '/health', exact: true },
{ path: '/health/live', exact: true },
{ path: '/health/ready', exact: true },
{ path: '/api/v1/health', exact: true }, { path: '/api/v1/health', exact: true },
{ path: '/api/v1/health/live', exact: true },
{ path: '/api/v1/health/ready', exact: true },
{ path: '/probe/', prefix: true }, { path: '/probe/', prefix: true },
{ path: '/api/v1/tailscale/', prefix: true }, { path: '/api/v1/tailscale/', prefix: true },
{ path: '/api/v1/totp/config', exact: true, method: 'GET' }, { path: '/api/v1/totp/config', exact: true, method: 'GET' },
@@ -305,8 +325,11 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/config', exact: true, method: 'GET' }, { path: '/api/v1/config', exact: true, method: 'GET' },
{ path: '/api/v1/services/status', exact: true, method: 'GET' }, { path: '/api/v1/services/status', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' }, { path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' }, // Monitoring endpoints — only public if MONITORING_PUBLIC is true
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' }, ...(MONITORING_PUBLIC ? [
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
] : []),
{ path: '/api/v1/version', exact: true, method: 'GET' }, { path: '/api/v1/version', exact: true, method: 'GET' },
]; ];
@@ -332,7 +355,7 @@ module.exports = function configureMiddleware(app, {
if (isPublicRoute(req)) return next(); if (isPublicRoute(req)) return next();
if (isSessionValid(req)) return next(); if (isSessionValid(req)) return next();
return res.status(401).json({ success: false, error: '[DC-110] Authentication required', requiresTotp: true }); return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true });
}; };
app.use(totpAuthMiddleware); app.use(totpAuthMiddleware);
@@ -380,9 +403,7 @@ module.exports = function configureMiddleware(app, {
} }
// No valid auth — reject // No valid auth — reject
return res.status(401).json({ return errorResponse(res, 401, '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', {
success: false,
error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key',
requiresTotp: totpConfig.enabled requiresTotp: totpConfig.enabled
}); });
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.13.0", "version": "1.13.4",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
-114
View File
@@ -1,114 +0,0 @@
// Response Helpers
// Standardize API response format across all routes
const { HTTP_STATUS } = require('./constants');
/**
* Success response with data
*/
function success(res, data, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({
success: true,
...data
});
}
/**
* Success response with message
*/
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({
success: true,
message
});
}
/**
* Created response (201)
*/
function created(res, data) {
return res.status(HTTP_STATUS.CREATED).json({
success: true,
...data
});
}
/**
* No content response (204)
*/
function noContent(res) {
return res.status(HTTP_STATUS.NO_CONTENT).send();
}
/**
* Error response
*/
function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
return res.status(statusCode).json({
success: false,
error: message
});
}
/**
* Validation error response (400)
*/
function validationError(res, message) {
return res.status(HTTP_STATUS.BAD_REQUEST).json({
success: false,
error: message
});
}
/**
* Unauthorized response (401)
*/
function unauthorized(res, message = 'Unauthorized') {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({
success: false,
error: message
});
}
/**
* Forbidden response (403)
*/
function forbidden(res, message = 'Forbidden') {
return res.status(HTTP_STATUS.FORBIDDEN).json({
success: false,
error: message
});
}
/**
* Not found response (404)
*/
function notFound(res, message = 'Not found') {
return res.status(HTTP_STATUS.NOT_FOUND).json({
success: false,
error: message
});
}
/**
* Conflict response (409)
*/
function conflict(res, message) {
return res.status(HTTP_STATUS.CONFLICT).json({
success: false,
error: message
});
}
module.exports = {
success,
successMessage,
created,
noContent,
error,
validationError,
unauthorized,
forbidden,
notFound,
conflict
};
+4 -3
View File
@@ -3,6 +3,7 @@ const yaml = require('js-yaml');
const { DOCKER, REGEX } = require('../../constants'); const { DOCKER, REGEX } = require('../../constants');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
const { ok } = require('../../src/utils/responses');
/** /**
* Docker Compose import routes * Docker Compose import routes
@@ -162,7 +163,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
} }
const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack'; const name = (stackName || 'stack').replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 32) || 'stack';
const result = parseCompose(yamlStr, name); const result = parseCompose(yamlStr, name);
res.json({ success: true, ...result }); ok(res, { ...result });
}, 'compose-import')); }, 'compose-import'));
// POST /deploy-compose — deploy parsed services // POST /deploy-compose — deploy parsed services
@@ -300,7 +301,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason }); results.push({ type: 'container', name: svc.name, status: 'skipped', reason: svc.reason });
} }
res.json({ success: true, results, stackName: stackName || prefix }); ok(res, { results, stackName: stackName || prefix });
}, 'compose-deploy')); }, 'compose-deploy'));
// DELETE /compose-stack/:stackName — remove an entire stack // DELETE /compose-stack/:stackName — remove an entire stack
@@ -329,7 +330,7 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
}); });
await servicesStateManager.update(data => { data.services = updated; }); await servicesStateManager.update(data => { data.services = updated; });
res.json({ success: true, removed, count: removed.length }); ok(res, { removed, count: removed.length });
}, 'compose-stack-delete')); }, 'compose-stack-delete'));
return router; return router;
+3 -2
View File
@@ -8,6 +8,7 @@ const { exists } = require('../../fs-helpers');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const { logError } = require('../../src/utils/logging'); const { logError } = require('../../src/utils/logging');
const { ok } = require('../../src/utils/responses');
/** /**
* Apps deployment routes factory * Apps deployment routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -243,9 +244,9 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
if (!template) throw new ValidationError('Invalid app template'); if (!template) throw new ValidationError('Invalid app template');
const existingContainer = await helpers.findExistingContainerByImage(template); const existingContainer = await helpers.findExistingContainerByImage(template);
if (existingContainer) { if (existingContainer) {
res.json({ success: true, exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` }); ok(res, { exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` });
} else { } else {
res.json({ success: true, exists: false, message: `No existing ${template.name} container found` }); ok(res, { exists: false, message: `No existing ${template.name} container found` });
} }
}, 'check-existing')); }, 'check-existing'));
+2 -1
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { logError } = require('../../src/utils/logging'); const { logError } = require('../../src/utils/logging');
const { ok } = require('../../src/utils/responses');
module.exports = function({ module.exports = function({
docker, caddy, servicesStateManager, asyncHandler, log, helpers, docker, caddy, servicesStateManager, asyncHandler, log, helpers,
@@ -135,7 +136,7 @@ module.exports = function({
results.service = error.message; results.service = error.message;
} }
res.json({ success: true, message: `App ${appId} removal completed`, results }); ok(res, { message: `App ${appId} removal completed`, results });
} catch (error) { } catch (error) {
await logError('app-removal', error); await logError('app-removal', error);
errorResponse(res, 500, ctx.safeErrorMessage(error), { results }); errorResponse(res, 500, ctx.safeErrorMessage(error), { results });
+12 -16
View File
@@ -2,6 +2,7 @@ const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const { ok, validationError, notFound, errorResponse } = require('../../src/utils/responses');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
@@ -47,7 +48,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
} }
const result = await restoreService(service); const result = await restoreService(service);
res.json({ success: true, result }); ok(res, { result });
}, 'apps-restore')); }, 'apps-restore'));
/** /**
@@ -59,8 +60,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
const restoreable = services.filter(s => s.deploymentManifest); const restoreable = services.filter(s => s.deploymentManifest);
if (restoreable.length === 0) { if (restoreable.length === 0) {
return res.json({ return ok(res, {
success: true,
message: 'No services have deployment manifests to restore', message: 'No services have deployment manifests to restore',
results: [] results: []
}); });
@@ -85,8 +85,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
const skipped = results.filter(r => r.status === 'skipped').length; const skipped = results.filter(r => r.status === 'skipped').length;
const failed = results.filter(r => r.status === 'failed').length; const failed = results.filter(r => r.status === 'failed').length;
res.json({ ok(res, {
success: true,
message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`, message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`,
results results
}); });
@@ -123,7 +122,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
status.push(entry); status.push(entry);
} }
res.json({ success: true, services: status }); ok(res, { services: status });
}, 'apps-restore-status')); }, 'apps-restore-status'));
// ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ==================== // ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ====================
@@ -174,8 +173,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
// Sort by timestamp descending (newest first) // Sort by timestamp descending (newest first)
files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
res.json({ ok(res, {
success: true,
appId, appId,
isBackupFile: true, isBackupFile: true,
files, files,
@@ -190,12 +188,12 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
// Security: prevent path traversal // Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ success: false, error: 'Invalid filename' }); return validationError(res, 'Invalid filename');
} }
const filepath = path.join(DEFAULT_BACKUP_DIR, filename); const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
if (!fs.existsSync(filepath)) { if (!fs.existsSync(filepath)) {
return res.status(404).json({ success: false, error: `Backup file not found: ${filename}` }); return notFound(res, `Backup file not found: ${filename}`);
} }
try { try {
@@ -207,7 +205,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
try { try {
fileData = await backupManager.decryptBackup(fileData, encryptionKey); fileData = await backupManager.decryptBackup(fileData, encryptionKey);
} catch (err) { } catch (err) {
return res.status(400).json({ success: false, error: 'Failed to decrypt backup: ' + err.message }); return validationError(res, 'Failed to decrypt backup: ' + err.message);
} }
} }
@@ -264,8 +262,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
// Cleanup temp dir // Cleanup temp dir
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
res.json({ ok(res, {
success: true,
isBackupFile: true, isBackupFile: true,
restored: { restored: {
services: !!restoreData.services, services: !!restoreData.services,
@@ -277,8 +274,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
} else { } else {
// Preview mode // Preview mode
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
res.json({ ok(res, {
success: true,
isBackupFile: true, isBackupFile: true,
preview: true, preview: true,
filename, filename,
@@ -296,7 +292,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
throw err; throw err;
} }
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); errorResponse(res, 500, err.message);
} }
}, 'apps-revert')); }, 'apps-revert'));
+5 -6
View File
@@ -20,6 +20,7 @@ const { exists } = require('../../fs-helpers');
* @returns {express.Router} * @returns {express.Router}
*/ */
const { REGEX } = require('../../constants'); const { REGEX } = require('../../constants');
const { ok } = require('../../src/utils/responses');
module.exports = function({ module.exports = function({
servicesStateManager, asyncHandler, helpers, servicesStateManager, asyncHandler, helpers,
@@ -42,8 +43,7 @@ module.exports = function({
// Get available app templates // Get available app templates
router.get('/templates', asyncHandler(async (req, res) => { router.get('/templates', asyncHandler(async (req, res) => {
res.json({ ok(res, {
success: true,
templates: ctx.APP_TEMPLATES, templates: ctx.APP_TEMPLATES,
categories: ctx.TEMPLATE_CATEGORIES, categories: ctx.TEMPLATE_CATEGORIES,
difficultyLevels: ctx.DIFFICULTY_LEVELS difficultyLevels: ctx.DIFFICULTY_LEVELS
@@ -58,7 +58,7 @@ module.exports = function({
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
throw new NotFoundError('App template'); throw new NotFoundError('App template');
} }
res.json({ success: true, template }); ok(res, { template });
}, 'apps-template-detail')); }, 'apps-template-detail'));
// Check port availability // Check port availability
@@ -80,7 +80,7 @@ module.exports = function({
const usedPorts = await docker.getUsedPorts(); const usedPorts = await docker.getUsedPorts();
for (let port = basePort; port < basePort + maxAttempts; port++) { for (let port = basePort; port < basePort + maxAttempts; port++) {
if (!usedPorts.has(port)) { if (!usedPorts.has(port)) {
res.json({ success: true, suggestedPort: port, basePort }); ok(res, { suggestedPort: port, basePort });
return; return;
} }
} }
@@ -170,8 +170,7 @@ module.exports = function({
log.warn('deploy', 'Service update warning', { error: error.message || String(error) }); log.warn('deploy', 'Service update warning', { error: error.message || String(error) });
} }
res.json({ ok(res, {
success: true,
message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`, message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`,
newUrl: `https://${ctx.buildDomain(newSubdomain)}`, newUrl: `https://${ctx.buildDomain(newSubdomain)}`,
results results
+4 -7
View File
@@ -3,6 +3,7 @@ const { APP_PORTS, ARR_SERVICES } = require('../../constants');
const { validateURL, validateToken } = require('../../input-validator'); const { validateURL, validateToken } = require('../../input-validator');
const { ValidationError, AuthenticationError, NotFoundError } = require('../../errors'); const { ValidationError, AuthenticationError, NotFoundError } = require('../../errors');
const { logError } = require('../../src/utils/logging'); const { logError } = require('../../src/utils/logging');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Arr configuration routes factory * Arr configuration routes factory
@@ -258,11 +259,7 @@ module.exports = function(ctx) {
const version = service === 'plex' ? data.MediaContainer?.version : data.version; const version = service === 'plex' ? data.MediaContainer?.version : data.version;
const appName = service === 'plex' ? 'Plex' : data.appName; const appName = service === 'plex' ? 'Plex' : data.appName;
log.info('arr', 'Service connection successful', { service, appName, version }); log.info('arr', 'Service connection successful', { service, appName, version });
return res.json({ return ok(res, { version, appName });
success: true,
version,
appName
});
} else if (response.status === 401) { } else if (response.status === 401) {
throw new AuthenticationError('Invalid API key'); throw new AuthenticationError('Invalid API key');
} else if (response.status === 404) { } else if (response.status === 404) {
@@ -553,7 +550,7 @@ module.exports = function(ctx) {
const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`); const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
const storedProfileId = metadata?.qualityProfileId || null; const storedProfileId = metadata?.qualityProfileId || null;
res.json({ success: true, profiles: mapped, storedProfileId }); ok(res, { profiles: mapped, storedProfileId });
} catch (e) { } catch (e) {
if (e.cause?.code === 'ECONNREFUSED') { if (e.cause?.code === 'ECONNREFUSED') {
return errorResponse(res, 502, 'Connection refused — is the service running?'); return errorResponse(res, 502, 'Connection refused — is the service running?');
@@ -588,7 +585,7 @@ module.exports = function(ctx) {
existing.qualityProfileName = qualityProfileName || null; existing.qualityProfileName = qualityProfileName || null;
await credentialManager.storeMetadata(credKey, existing); await credentialManager.storeMetadata(credKey, existing);
res.json({ success: true, message: `Quality profile updated for ${service}` }); successMessage(res, `Quality profile updated for ${service}`);
}, 'arr-quality-profile-save')); }, 'arr-quality-profile-save'));
return router; return router;
+4 -8
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { validateURL, validateToken } = require('../../input-validator'); const { validateURL, validateToken } = require('../../input-validator');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Arr credentials routes factory * Arr credentials routes factory
@@ -101,12 +102,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false }); log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false });
res.json({ ok(res, { message: `${service} API key stored`, connectionTest, url: resolvedUrl });
success: true,
message: `${service} API key stored`,
connectionTest,
url: resolvedUrl
});
}, 'arr-credentials-store')); }, 'arr-credentials-store'));
// List stored arr credentials (keys only, not values) // List stored arr credentials (keys only, not values)
@@ -131,7 +127,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
// Get seedbox base URL // Get seedbox base URL
const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl'); const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl');
res.json({ success: true, credentials, seedboxBaseUrl: seedboxBaseUrl || null }); ok(res, { credentials, seedboxBaseUrl: seedboxBaseUrl || null });
}, 'arr-credentials-list')); }, 'arr-credentials-list'));
// Delete stored arr credentials // Delete stored arr credentials
@@ -140,7 +136,7 @@ module.exports = function({ credentialManager, servicesStateManager, asyncHandle
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`; const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
await credentialManager.delete(credKey); await credentialManager.delete(credKey);
log.info('arr', 'Deleted credentials', { service }); log.info('arr', 'Deleted credentials', { service });
res.json({ success: true, message: `${service} credentials removed` }); successMessage(res, `${service} credentials removed`);
}, 'arr-credentials-delete')); }, 'arr-credentials-delete'));
return router; return router;
+3 -3
View File
@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const { APP_PORTS, ARR_SERVICES } = require('../../constants'); const { APP_PORTS, ARR_SERVICES } = require('../../constants');
const { ok } = require('../../src/utils/responses');
/** /**
* Arr service detection routes factory * Arr service detection routes factory
@@ -62,8 +63,7 @@ module.exports = function({ docker, servicesStateManager, credentialManager, fet
detected.plex.token = await helpers.getPlexToken(detected.plex.containerName); detected.plex.token = await helpers.getPlexToken(detected.plex.containerName);
} }
res.json({ ok(res, {
success: true,
services: detected, services: detected,
summary: { summary: {
plexReady: !!(detected.plex?.token), plexReady: !!(detected.plex?.token),
@@ -287,7 +287,7 @@ module.exports = function({ docker, servicesStateManager, credentialManager, fet
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 }); ok(res, { services: result, seedboxBaseUrl: detectedSeedboxUrl, summary });
}, 'smart-detect')); }, 'smart-detect'));
return router; return router;
+2 -1
View File
@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const { APP_PORTS } = require('../../constants'); const { APP_PORTS } = require('../../constants');
const { ok } = require('../../src/utils/responses');
/** /**
* Plex routes factory * Plex routes factory
@@ -86,7 +87,7 @@ module.exports = function({ fetchT, asyncHandler, errorResponse, log: _log, help
lastVerified: new Date().toISOString() lastVerified: new Date().toISOString()
}); });
res.json({ success: true, serverName, version, libraries }); ok(res, { serverName, version, libraries });
}, 'plex-libraries')); }, 'plex-libraries'));
return router; return router;
+5 -6
View File
@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors'); const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Auth API keys routes factory * Auth API keys routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -39,7 +40,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
} }
const keys = await authManager.listAPIKeys(); const keys = await authManager.listAPIKeys();
res.json({ success: true, keys }); ok(res, { keys });
}, 'auth-keys-list')); }, 'auth-keys-list'));
// Generate new API key // Generate new API key
@@ -66,8 +67,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
scopes || ['read', 'write'] scopes || ['read', 'write']
); );
res.json({ ok(res, {
success: true,
key: keyData.key, key: keyData.key,
id: keyData.id, id: keyData.id,
name: keyData.name, name: keyData.name,
@@ -93,7 +93,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
const success = await authManager.revokeAPIKey(keyId); const success = await authManager.revokeAPIKey(keyId);
if (success) { if (success) {
res.json({ success: true, message: 'API key revoked successfully' }); successMessage(res, 'API key revoked successfully');
} else { } else {
throw new NotFoundError(`API key ${keyId}`); throw new NotFoundError(`API key ${keyId}`);
} }
@@ -126,8 +126,7 @@ module.exports = function({ authManager, asyncHandler, log }) {
const expiresInMs = parseExpiration(expiresIn || '24h'); const expiresInMs = parseExpiration(expiresIn || '24h');
const expiresAt = new Date(Date.now() + expiresInMs).toISOString(); const expiresAt = new Date(Date.now() + expiresInMs).toISOString();
res.json({ ok(res, {
success: true,
token, token,
expiresAt, expiresAt,
usage: 'Include in Authorization header as: Bearer <token>' usage: 'Include in Authorization header as: Bearer <token>'
+7 -8
View File
@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const { ValidationError, AuthenticationError } = require('../../errors'); const { ValidationError, AuthenticationError } = require('../../errors');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Auth TOTP routes factory * Auth TOTP routes factory
@@ -27,8 +28,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
// Get current TOTP config (public route) // Get current TOTP config (public route)
router.get('/totp/config', asyncHandler(async (req, res) => { router.get('/totp/config', asyncHandler(async (req, res) => {
res.json({ ok(res, {
success: true,
config: { config: {
enabled: ctx.totpConfig.enabled, enabled: ctx.totpConfig.enabled,
sessionDuration: ctx.totpConfig.sessionDuration, sessionDuration: ctx.totpConfig.sessionDuration,
@@ -62,7 +62,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
color: { dark: '#ffffff', light: '#00000000' } color: { dark: '#ffffff', light: '#00000000' }
}); });
res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret }); ok(res, { qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret });
}, 'totp-setup')); }, 'totp-setup'));
// Verify first code to confirm setup, then activate TOTP // Verify first code to confirm setup, then activate TOTP
@@ -99,7 +99,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
ctx.session.create(req, ctx.totpConfig.sessionDuration); ctx.session.create(req, ctx.totpConfig.sessionDuration);
ctx.session.setCookie(res, ctx.totpConfig.sessionDuration); ctx.session.setCookie(res, ctx.totpConfig.sessionDuration);
res.json({ success: true, message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration }); ok(res, { message: 'TOTP enabled successfully', sessionDuration: ctx.totpConfig.sessionDuration });
}, 'totp-verify-setup')); }, 'totp-verify-setup'));
// Login: verify TOTP code and set session cookie // Login: verify TOTP code and set session cookie
@@ -133,7 +133,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https'); const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size }); log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
res.json({ success: true, message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken }); ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
}, 'totp-verify')); }, 'totp-verify'));
// Check session validity (used by Caddy forward_auth) // Check session validity (used by Caddy forward_auth)
@@ -185,7 +185,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
ctx.session.clear(req); ctx.session.clear(req);
ctx.session.clearCookie(res); ctx.session.clearCookie(res);
res.json({ success: true, message: 'TOTP disabled' }); successMessage(res, 'TOTP disabled');
}, 'totp-disable')); }, 'totp-disable'));
// Update TOTP settings (session duration) // Update TOTP settings (session duration)
@@ -204,8 +204,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
} }
await ctx.saveTotpConfig(); await ctx.saveTotpConfig();
res.json({ ok(res, {
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')); }, 'totp-config'));
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+5 -6
View File
@@ -5,6 +5,7 @@ const path = require('path');
const { exists, isAccessible } = require('../fs-helpers'); const { exists, isAccessible } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError, ForbiddenError } = require('../errors'); const { ValidationError, ForbiddenError } = require('../errors');
const { ok } = require('../src/utils/responses');
/** /**
* Browse route factory * Browse route factory
@@ -44,7 +45,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
} }
} }
res.json({ success: true, roots }); return ok(res, { roots });
}, 'browse-roots')); }, 'browse-roots'));
// Browse directory contents // Browse directory contents
@@ -64,7 +65,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
roots.push(r); roots.push(r);
} }
} }
return res.json({ success: true, path: '', items: roots }); return ok(res, { path: '', items: roots });
} }
const matchingRoot = BROWSE_ROOTS.find(r => const matchingRoot = BROWSE_ROOTS.find(r =>
@@ -124,8 +125,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
const result = paginate(folders, paginationParams); const result = paginate(folders, paginationParams);
res.json({ ok(res, {
success: true,
path: requestedPath, path: requestedPath,
parent: path.dirname(requestedPath).replace(/\\/g, '/') || null, parent: path.dirname(requestedPath).replace(/\\/g, '/') || null,
items: result.data, items: result.data,
@@ -190,8 +190,7 @@ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docke
} }
} }
res.json({ ok(res, {
success: true,
mounts: detectedMounts, mounts: detectedMounts,
message: detectedMounts.length > 0 message: detectedMounts.length > 0
? `Found ${detectedMounts.length} media mount(s) from existing containers` ? `Found ${detectedMounts.length} media mount(s) from existing containers`
+4 -4
View File
@@ -5,6 +5,7 @@ const path = require('path');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
const { ok } = require('../src/utils/responses');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
module.exports = function(ctx) { module.exports = function(ctx) {
@@ -26,8 +27,7 @@ module.exports = function(ctx) {
const expirationDate = new Date(certInfo.validUntil); const expirationDate = new Date(certInfo.validUntil);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
res.json({ ok(res, {
success: true,
certificate: { certificate: {
name: certInfo.name, name: certInfo.name,
fingerprint: certInfo.fingerprint, fingerprint: certInfo.fingerprint,
@@ -243,7 +243,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
const certsDir = platformPaths.generatedCertsDir; const certsDir = platformPaths.generatedCertsDir;
if (!await exists(certsDir)) { if (!await exists(certsDir)) {
return res.json({ success: true, certificates: [] }); return ok(res, { certificates: [] });
} }
const dirEntries = await fsp.readdir(certsDir); const dirEntries = await fsp.readdir(certsDir);
@@ -278,7 +278,7 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
} }
}))).filter(Boolean); }))).filter(Boolean);
res.json({ success: true, certificates }); ok(res, { certificates });
}, 'ca-certs')); }, 'ca-certs'));
return router; return router;
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
/** /**
+8 -18
View File
@@ -5,6 +5,7 @@ const { LIMITS } = require('../../constants');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Config assets routes factory * Config assets routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -63,8 +64,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
const filePath = path.join(assetsPath, safeFilename); const filePath = path.join(assetsPath, safeFilename);
await fsp.writeFile(filePath, buffer); await fsp.writeFile(filePath, buffer);
res.json({ ok(res, {
success: true,
path: `/assets/${safeFilename}`, path: `/assets/${safeFilename}`,
message: `Logo saved to ${filePath}` message: `Logo saved to ${filePath}`
}); });
@@ -76,8 +76,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Get current logo path, position, and title // Get current logo path, position, and title
router.get('/logo', asyncHandler(async (req, res) => { router.get('/logo', asyncHandler(async (req, res) => {
const config = await ctx.readConfig(); const config = await ctx.readConfig();
res.json({ ok(res, {
success: true,
// Dark/light variants (new) // Dark/light variants (new)
customLogoDark: config.customLogoDark || null, customLogoDark: config.customLogoDark || null,
customLogoLight: config.customLogoLight || null, customLogoLight: config.customLogoLight || null,
@@ -156,8 +155,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
config.updatedAt = new Date().toISOString(); config.updatedAt = new Date().toISOString();
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
res.json({ ok(res, {
success: true,
pathDark: pathDark, pathDark: pathDark,
pathLight: pathLight, pathLight: pathLight,
// Legacy compat // Legacy compat
@@ -195,10 +193,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
config.updatedAt = new Date().toISOString(); config.updatedAt = new Date().toISOString();
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
res.json({ successMessage(res, 'Branding reset to defaults');
success: true,
message: 'Branding reset to defaults'
});
}, 'logo-delete')); }, 'logo-delete'));
// ===== FAVICON ENDPOINTS ===== // ===== FAVICON ENDPOINTS =====
@@ -207,8 +202,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Get current favicon // Get current favicon
router.get('/favicon', asyncHandler(async (req, res) => { router.get('/favicon', asyncHandler(async (req, res) => {
const config = await ctx.readConfig(); const config = await ctx.readConfig();
res.json({ ok(res, {
success: true,
customFavicon: config.customFavicon || null, customFavicon: config.customFavicon || null,
isDefault: !config.customFavicon isDefault: !config.customFavicon
}); });
@@ -268,8 +262,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Update config // Update config
await ctx.saveConfig({ customFavicon: '/assets/favicon.ico', updatedAt: new Date().toISOString() }); await ctx.saveConfig({ customFavicon: '/assets/favicon.ico', updatedAt: new Date().toISOString() });
res.json({ ok(res, {
success: true,
path: '/assets/favicon.ico', path: '/assets/favicon.ico',
message: 'Favicon created successfully' message: 'Favicon created successfully'
}); });
@@ -293,10 +286,7 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
config.updatedAt = new Date().toISOString(); config.updatedAt = new Date().toISOString();
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
res.json({ successMessage(res, 'Favicon reset to default');
success: true,
message: 'Favicon reset to default'
});
}, 'favicon-delete')); }, 'favicon-delete'));
return router; return router;
+13 -8
View File
@@ -5,6 +5,7 @@ const { CADDY } = require('../../constants');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { ValidationError, AuthenticationError } = require('../../errors'); const { ValidationError, AuthenticationError } = require('../../errors');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
const { ok } = require('../../src/utils/responses');
/** /**
* Config backup routes factory * Config backup routes factory
@@ -210,7 +211,7 @@ module.exports = function(deps) {
preview.browserStateCount = Object.keys(backup.browserState).length; preview.browserStateCount = Object.keys(backup.browserState).length;
} }
res.json({ success: true, preview }); ok(res, { preview });
}, 'backup-preview')); }, 'backup-preview'));
// Restore configuration from backup // Restore configuration from backup
@@ -391,13 +392,17 @@ module.exports = function(deps) {
const success = results.restored.length > 0 && results.errors.length === 0; const success = results.restored.length > 0 && results.errors.length === 0;
res.json({ if (success) {
success, ok(res, {
message: success message: `Restored ${results.restored.length} file(s) successfully`,
? `Restored ${results.restored.length} file(s) successfully` results
: `Restore completed with ${results.errors.length} error(s)`, });
results } else {
}); ok(res, {
message: `Restore completed with ${results.errors.length} error(s)`,
results
}, 200);
}
log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length }); log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });
}, 'backup-restore')); }, 'backup-restore'));
+3 -2
View File
@@ -2,6 +2,7 @@ const fsp = require('fs').promises;
const { validateConfig } = require('../../config-schema'); const { validateConfig } = require('../../config-schema');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const { ok, successMessage } = require('../../src/utils/responses');
/** /**
* Config settings routes factory * Config settings routes factory
@@ -71,14 +72,14 @@ module.exports = function({ configStateManager: _configStateManager, asyncHandle
} }
log.info('config', 'Config saved', { path: ctx.CONFIG_FILE }); log.info('config', 'Config saved', { path: ctx.CONFIG_FILE });
res.json({ success: true, message: 'Configuration saved', config, warnings }); ok(res, { message: 'Configuration saved', config, warnings });
}, 'config-save')); }, 'config-save'));
router.delete('/config', asyncHandler(async (req, res) => { router.delete('/config', asyncHandler(async (req, res) => {
if (await exists(ctx.CONFIG_FILE)) { if (await exists(ctx.CONFIG_FILE)) {
await fsp.unlink(ctx.CONFIG_FILE); await fsp.unlink(ctx.CONFIG_FILE);
} }
res.json({ success: true, message: 'Configuration reset' }); successMessage(res, 'Configuration reset');
}, 'config-delete')); }, 'config-delete'));
return router; return router;
+1 -1
View File
@@ -2,7 +2,7 @@ const express = require('express');
const { DOCKER } = require('../constants'); const { DOCKER } = require('../constants');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError } = require('../errors'); const { NotFoundError } = require('../errors');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Containers route factory * Containers route factory
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
/** /**
* Credentials routes factory * Credentials routes factory
+1 -1
View File
@@ -15,7 +15,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { NotFoundError, ValidationError } = require('../errors'); const { NotFoundError, ValidationError } = require('../errors');
/** /**
+5 -6
View File
@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
const validatorLib = require('validator'); const validatorLib = require('validator');
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants'); const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
/** /**
@@ -383,9 +383,8 @@ module.exports = function({
const response = await fetchT(technitiumUrl, { const response = await fetchT(technitiumUrl, {
method: 'GET', method: 'GET',
headers: { 'Accept': 'text/plain' }, headers: { 'Accept': 'text/plain' }
timeout: 10000 }, 10000);
});
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
@@ -553,7 +552,7 @@ module.exports = function({
} }
} }
return res.json({ return ok(res, {
success: anySuccess, success: anySuccess,
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed', message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
results results
@@ -640,7 +639,7 @@ module.exports = function({
const dnsPort = siteConfig.dnsServerPort || '5380'; const dnsPort = siteConfig.dnsServerPort || '5380';
try { try {
const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`; const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`;
const response = await fetchT(url, { method: 'POST', timeout: 5000 }); const response = await fetchT(url, { method: 'POST' }, 5000);
const result = await response.json(); const result = await response.json();
if (result.status === 'ok') { if (result.status === 'ok') {
success(res, { message: 'Restart initiated' }); success(res, { message: 'Restart initiated' });
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -3,7 +3,7 @@ const fs = require('fs');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Error logs routes factory * Error logs routes factory
+2 -1
View File
@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const { ok } = require('../src/utils/responses');
/** /**
* Server-Sent Events route factory * Server-Sent Events route factory
@@ -147,7 +148,7 @@ module.exports = function({ resourceMonitor, healthChecker, updateManager, logEr
// Client count (useful for debugging) // Client count (useful for debugging)
router.get('/clients', (req, res) => { router.get('/clients', (req, res) => {
res.json({ success: true, count: clients.size }); ok(res, { count: clients.size });
}); });
return router; return router;
+12 -20
View File
@@ -7,7 +7,7 @@ const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse, errorResponse: sendError, ok, notFound } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
@@ -273,11 +273,7 @@ module.exports = function({
try { try {
// Check if certificate exists // Check if certificate exists
if (!await exists(rootCertPath)) { if (!await exists(rootCertPath)) {
return res.json({ return sendError(res, 404, 'Root CA certificate not found', { caStatus: 'error', daysUntilExpiration: null });
status: 'error',
message: 'Root CA certificate not found',
daysUntilExpiration: null
});
} }
const dates = execSync(`openssl x509 -in "${rootCertPath}" -noout -dates`).toString(); const dates = execSync(`openssl x509 -in "${rootCertPath}" -noout -dates`).toString();
@@ -286,36 +282,32 @@ module.exports = function({
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
// Alert thresholds // Alert thresholds
let status = 'healthy'; let caStatus = 'healthy';
let message = `CA certificate valid for ${daysUntilExpiration} days`; let message = `CA certificate valid for ${daysUntilExpiration} days`;
if (daysUntilExpiration < 0) { if (daysUntilExpiration < 0) {
status = 'critical'; caStatus = 'critical';
message = `CA certificate EXPIRED ${Math.abs(daysUntilExpiration)} days ago!`; message = `CA certificate EXPIRED ${Math.abs(daysUntilExpiration)} days ago!`;
} else if (daysUntilExpiration < 7) { } else if (daysUntilExpiration < 7) {
status = 'critical'; caStatus = 'critical';
message = `CA certificate expires in ${daysUntilExpiration} days!`; message = `CA certificate expires in ${daysUntilExpiration} days!`;
} else if (daysUntilExpiration < 30) { } else if (daysUntilExpiration < 30) {
status = 'critical'; caStatus = 'critical';
message = `CA certificate expires in ${daysUntilExpiration} days!`; message = `CA certificate expires in ${daysUntilExpiration} days!`;
} else if (daysUntilExpiration < 90) { } else if (daysUntilExpiration < 90) {
status = 'warning'; caStatus = 'warning';
message = `CA certificate expires in ${daysUntilExpiration} days`; message = `CA certificate expires in ${daysUntilExpiration} days`;
} }
res.json({ ok(res, {
status: status, caStatus,
message: message, message,
daysUntilExpiration: daysUntilExpiration, daysUntilExpiration,
expiresAt: notAfter expiresAt: notAfter
}); });
} catch (error) { } catch (error) {
await logError('GET /api/health/ca', error); await logError('GET /api/health/ca', error);
res.json({ sendError(res, 500, error.message, { caStatus: 'error', daysUntilExpiration: null });
status: 'error',
message: error.message,
daysUntilExpiration: null
});
} }
}, 'health-ca')); }, 'health-ca'));
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+12 -13
View File
@@ -5,6 +5,7 @@ const path = require('path');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError, ValidationError, ForbiddenError } = require('../errors'); const { NotFoundError, ValidationError, ForbiddenError } = require('../errors');
const { ok } = require('../src/utils/responses');
/** /**
* Logs route factory * Logs route factory
@@ -31,7 +32,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
const result = paginate(containerList, paginationParams); const result = paginate(containerList, paginationParams);
res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); ok(res, { containers: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'logs-containers')); }, 'logs-containers'));
// Get logs for a specific container // Get logs for a specific container
@@ -81,8 +82,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
offset += 8 + size; offset += 8 + size;
} }
res.json({ ok(res, {
success: true,
containerId, containerName, containerId, containerName,
logs: lines, logs: lines,
count: lines.length count: lines.length
@@ -153,23 +153,23 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
if (!logDigest) throw new Error('Log digest not available'); if (!logDigest) throw new Error('Log digest not available');
const digest = await logDigest.getLatestDigest(); const digest = await logDigest.getLatestDigest();
if (!digest) { if (!digest) {
return res.json({ success: true, digest: null, message: 'No digest available yet. First digest is generated at midnight.' }); return ok(res, { digest: null, message: 'No digest available yet. First digest is generated at midnight.' });
} }
res.json({ success: true, digest }); ok(res, { digest });
}, 'logs-digest-latest')); }, 'logs-digest-latest'));
// Get live digest data (today's accumulated stats) // Get live digest data (today's accumulated stats)
router.get('/logs/digest/live', asyncHandler(async (req, res) => { router.get('/logs/digest/live', asyncHandler(async (req, res) => {
if (!logDigest) throw new Error('Log digest not available'); if (!logDigest) throw new Error('Log digest not available');
const live = logDigest.getLiveData(); const live = logDigest.getLiveData();
res.json({ success: true, ...live }); ok(res, { ...live });
}, 'logs-digest-live')); }, 'logs-digest-live'));
// List available digest dates // List available digest dates
router.get('/logs/digest/history', asyncHandler(async (req, res) => { router.get('/logs/digest/history', asyncHandler(async (req, res) => {
if (!logDigest) throw new Error('Log digest not available'); if (!logDigest) throw new Error('Log digest not available');
const dates = await logDigest.listDigests(); const dates = await logDigest.listDigests();
res.json({ success: true, dates }); ok(res, { dates });
}, 'logs-digest-history')); }, 'logs-digest-history'));
// Generate digest on demand (for today or a specific date) // Generate digest on demand (for today or a specific date)
@@ -177,7 +177,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
if (!logDigest) throw new Error('Log digest not available'); if (!logDigest) throw new Error('Log digest not available');
const date = req.body.date || new Date().toISOString().slice(0, 10); const date = req.body.date || new Date().toISOString().slice(0, 10);
const digest = await logDigest.generateDailyDigest(date); const digest = await logDigest.generateDailyDigest(date);
res.json({ success: true, digest }); ok(res, { digest });
}, 'logs-digest-generate')); }, 'logs-digest-generate'));
// Get digest for a specific date (JSON) // Get digest for a specific date (JSON)
@@ -196,7 +196,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
} }
const digest = await logDigest.getDigestByDate(date); const digest = await logDigest.getDigestByDate(date);
if (!digest) throw new NotFoundError(`Digest for ${date}`); if (!digest) throw new NotFoundError(`Digest for ${date}`);
res.json({ success: true, digest }); ok(res, { digest });
}, 'logs-digest-date')); }, 'logs-digest-date'));
// Get Docker disk usage snapshot // Get Docker disk usage snapshot
@@ -204,14 +204,14 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
if (!dockerMaintenance) throw new Error('Docker maintenance not available'); if (!dockerMaintenance) throw new Error('Docker maintenance not available');
const diskUsage = await dockerMaintenance.getDiskUsage(); const diskUsage = await dockerMaintenance.getDiskUsage();
const status = dockerMaintenance.getStatus(); const status = dockerMaintenance.getStatus();
res.json({ success: true, diskUsage, maintenance: status }); ok(res, { diskUsage, maintenance: status });
}, 'logs-docker-disk')); }, 'logs-docker-disk'));
// Trigger Docker maintenance manually // Trigger Docker maintenance manually
router.post('/logs/docker-maintenance', asyncHandler(async (req, res) => { router.post('/logs/docker-maintenance', asyncHandler(async (req, res) => {
if (!dockerMaintenance) throw new Error('Docker maintenance not available'); if (!dockerMaintenance) throw new Error('Docker maintenance not available');
const result = await dockerMaintenance.runMaintenance(); const result = await dockerMaintenance.runMaintenance();
res.json({ success: true, result }); ok(res, { result });
}, 'logs-docker-maintenance')); }, 'logs-docker-maintenance'));
// Get logs from a file path (for native applications) // Get logs from a file path (for native applications)
@@ -261,8 +261,7 @@ module.exports = function({ asyncHandler, docker, logDigest, dockerMaintenance }
timestamp: extractTimestamp(line) timestamp: extractTimestamp(line)
})); }));
res.json({ ok(res, {
success: true,
logPath: normalizedPath, logPath: normalizedPath,
logs, logs,
count: logs.length, count: logs.length,
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Monitoring routes factory * Monitoring routes factory
+11 -13
View File
@@ -3,6 +3,7 @@ const { validateURL, validateToken } = require('../input-validator');
const validatorLib = require('validator'); const validatorLib = require('validator');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
const { ok, successMessage } = require('../src/utils/responses');
/** /**
* Notifications route factory * Notifications route factory
@@ -44,7 +45,7 @@ module.exports = function({ notification, asyncHandler }) {
events: notificationConfig.events, events: notificationConfig.events,
healthCheck: notificationConfig.healthCheck healthCheck: notificationConfig.healthCheck
}; };
res.json({ success: true, config: safeConfig }); ok(res, { config: safeConfig });
}, 'notifications-config-get')); }, 'notifications-config-get'));
// POST /config — Update notification configuration // POST /config — Update notification configuration
@@ -150,7 +151,7 @@ module.exports = function({ notification, asyncHandler }) {
} }
await notification.saveConfig(); await notification.saveConfig();
res.json({ success: true, message: 'Notification config updated' }); successMessage(res, 'Notification config updated');
}, 'notifications-config-update')); }, 'notifications-config-update'));
// POST /test — Test notification delivery // POST /test — Test notification delivery
@@ -176,11 +177,11 @@ module.exports = function({ notification, asyncHandler }) {
default: default:
throw new ValidationError('Unknown provider'); throw new ValidationError('Unknown provider');
} }
res.json({ success: result.success, provider, error: result.error }); ok(res, { success: result.success, provider, error: result.error });
} else { } else {
// Test all enabled providers // Test all enabled providers
const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info'); const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info');
res.json({ success: true, ...result }); ok(res, { success: true, ...result });
} }
}, 'notifications-test')); }, 'notifications-test'));
@@ -190,11 +191,10 @@ module.exports = function({ notification, asyncHandler }) {
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
if (paginationParams) { if (paginationParams) {
const result = paginate(notificationHistory, paginationParams); const result = paginate(notificationHistory, paginationParams);
res.json({ success: true, history: result.data, total: notificationHistory.length, pagination: result.pagination }); ok(res, { history: result.data, total: notificationHistory.length, pagination: result.pagination });
} else { } else {
const limit = parseInt(req.query.limit) || 50; const limit = parseInt(req.query.limit) || 50;
res.json({ ok(res, {
success: true,
history: notificationHistory.slice(0, limit), history: notificationHistory.slice(0, limit),
total: notificationHistory.length total: notificationHistory.length
}); });
@@ -204,15 +204,14 @@ module.exports = function({ notification, asyncHandler }) {
// DELETE /history — Clear notification history // DELETE /history — Clear notification history
router.delete('/history', asyncHandler(async (req, res) => { router.delete('/history', asyncHandler(async (req, res) => {
notification.clearHistory(); notification.clearHistory();
res.json({ success: true, message: 'Notification history cleared' }); successMessage(res, 'Notification history cleared');
}, 'notifications-history-clear')); }, 'notifications-history-clear'));
// POST /health-check — Manually trigger health check // POST /health-check — Manually trigger health check
router.post('/health-check', asyncHandler(async (req, res) => { router.post('/health-check', asyncHandler(async (req, res) => {
await notification.checkHealth(); await notification.checkHealth();
const notificationConfig = notification.getConfig(); const notificationConfig = notification.getConfig();
res.json({ ok(res, {
success: true,
lastCheck: notificationConfig.healthCheck.lastCheck, lastCheck: notificationConfig.healthCheck.lastCheck,
containersMonitored: Object.keys(notification.getHealthState()).length containersMonitored: Object.keys(notification.getHealthState()).length
}); });
@@ -223,8 +222,7 @@ module.exports = function({ notification, asyncHandler }) {
const notificationConfig = notification.getConfig(); const notificationConfig = notification.getConfig();
const providers = notificationConfig.providers || {}; const providers = notificationConfig.providers || {};
res.json({ ok(res, {
success: true,
enabled: notificationConfig.enabled, enabled: notificationConfig.enabled,
providers: { providers: {
discord: providers.discord?.enabled && !!providers.discord?.webhookUrl, discord: providers.discord?.enabled && !!providers.discord?.webhookUrl,
@@ -252,7 +250,7 @@ module.exports = function({ notification, asyncHandler }) {
// Use 'test' as the event for manual sends // Use 'test' as the event for manual sends
const result = await notification.send(event, data || {}, type || 'info'); const result = await notification.send(event, data || {}, type || 'info');
res.json({ ok(res, {
success: result.success, success: result.success,
event, event,
results: result.results results: result.results
+16 -17
View File
@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const http = require('http'); const http = require('http');
const { ok, errorResponse, notFound, conflict } = require('../src/utils/responses');
/** /**
* OpenClaw management routes * OpenClaw management routes
@@ -93,8 +94,8 @@ module.exports = function openClawRoutes(ctx) {
proxyRes.on('data', function(d) { res.write(d); }); proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); }); proxyRes.on('end', function() { res.end(); });
}); });
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); }); proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); }); proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); });
proxyReq.write(body); proxyReq.write(body);
proxyReq.end(); proxyReq.end();
} else { } else {
@@ -104,8 +105,8 @@ module.exports = function openClawRoutes(ctx) {
proxyRes.on('data', function(d) { res.write(d); }); proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); }); proxyRes.on('end', function() { res.end(); });
}); });
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); }); proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); }); proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); });
} }
} }
@@ -115,7 +116,7 @@ module.exports = function openClawRoutes(ctx) {
const container = await findOpenClawContainer(); const container = await findOpenClawContainer();
if (!container) { if (!container) {
return res.json({ success: true, deployed: false }); return ok(res, { deployed: false });
} }
const token = await getGatewayToken(container.Id); const token = await getGatewayToken(container.Id);
@@ -123,8 +124,7 @@ module.exports = function openClawRoutes(ctx) {
const baseUrl = 'http://localhost:' + port; const baseUrl = 'http://localhost:' + port;
const health = await gatewayHealth(baseUrl, token); const health = await gatewayHealth(baseUrl, token);
res.json({ ok(res, {
success: true,
deployed: true, deployed: true,
container: { container: {
id: container.Id.slice(0, 12), id: container.Id.slice(0, 12),
@@ -149,7 +149,7 @@ module.exports = function openClawRoutes(ctx) {
router.post('/deploy', asyncHandler(async function(req, res) { router.post('/deploy', asyncHandler(async function(req, res) {
const existing = await findOpenClawContainer(); const existing = await findOpenClawContainer();
if (existing) { if (existing) {
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' }); return conflict(res, 'OpenClaw is already deployed');
} }
const image = 'ghcr.io/nousresearch/openclaw:latest'; const image = 'ghcr.io/nousresearch/openclaw:latest';
@@ -170,7 +170,7 @@ module.exports = function openClawRoutes(ctx) {
}); });
} catch(e) { } catch(e) {
log.error('OpenClaw pull failed: ' + e.message); log.error('OpenClaw pull failed: ' + e.message);
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message }); return errorResponse(res, 500, 'Failed to pull image: ' + e.message);
} }
// Create + start container // Create + start container
@@ -196,8 +196,7 @@ module.exports = function openClawRoutes(ctx) {
await container.start(); await container.start();
log.info('OpenClaw deployed: ' + container.id.slice(0, 12)); log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
res.json({ ok(res, {
success: true,
deployed: true, deployed: true,
container: { id: container.id.slice(0, 12), name: name }, container: { id: container.id.slice(0, 12), name: name },
gateway: { gateway: {
@@ -207,7 +206,7 @@ module.exports = function openClawRoutes(ctx) {
}); });
} catch(e) { } catch(e) {
log.error('OpenClaw deploy failed: ' + e.message); log.error('OpenClaw deploy failed: ' + e.message);
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message }); errorResponse(res, 500, 'Deploy failed: ' + e.message);
} }
})); }));
@@ -215,7 +214,7 @@ module.exports = function openClawRoutes(ctx) {
router.get('/proxy/*', asyncHandler(async function(req, res) { router.get('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer(); const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' }); if (!container) return notFound(res, 'OpenClaw not deployed');
const token = await getGatewayToken(container.Id); const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id); const port = await getContainerPort(container.Id);
@@ -229,7 +228,7 @@ module.exports = function openClawRoutes(ctx) {
router.post('/proxy/*', asyncHandler(async function(req, res) { router.post('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer(); const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' }); if (!container) return notFound(res, 'OpenClaw not deployed');
const token = await getGatewayToken(container.Id); const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id); const port = await getContainerPort(container.Id);
@@ -243,17 +242,17 @@ module.exports = function openClawRoutes(ctx) {
router.delete('/', asyncHandler(async function(req, res) { router.delete('/', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer(); const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' }); if (!container) return notFound(res, 'OpenClaw not deployed');
try { try {
const c = docker.client.container(container.Id); const c = docker.client.container(container.Id);
await c.stop().catch(function() {}); await c.stop().catch(function() {});
await c.remove({ force: true }); await c.remove({ force: true });
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed'); log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
res.json({ success: true, message: 'OpenClaw removed' }); ok(res, { message: 'OpenClaw removed' });
} catch(e) { } catch(e) {
log.error('Failed to remove OpenClaw: ' + e.message); log.error('Failed to remove OpenClaw: ' + e.message);
res.status(500).json({ success: false, error: e.message }); errorResponse(res, 500, e.message);
} }
})); }));
+2 -1
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { ValidationError } = require('../../errors'); const { ValidationError } = require('../../errors');
const crypto = require('crypto'); const crypto = require('crypto');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const { ok } = require('../../src/utils/responses');
/** /**
* Recipes deployment routes factory * Recipes deployment routes factory
@@ -146,7 +147,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
'success' 'success'
); );
res.json(response); ok(res, response);
} catch (error) { } catch (error) {
log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message }); log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message });
+3 -2
View File
@@ -2,6 +2,7 @@ const express = require('express');
const deployRoutes = require('./deploy'); const deployRoutes = require('./deploy');
const manageRoutes = require('./manage'); const manageRoutes = require('./manage');
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
const { ok } = require('../../src/utils/responses');
/** /**
* Recipes routes aggregator * Recipes routes aggregator
@@ -55,7 +56,7 @@ module.exports = function(ctx) {
setupInstructions: recipe.setupInstructions setupInstructions: recipe.setupInstructions
})); }));
res.json({ success: true, templates, categories: RECIPE_CATEGORIES }); ok(res, { templates, categories: RECIPE_CATEGORIES });
}, 'recipe-templates')); }, 'recipe-templates'));
// GET /api/recipes/templates/:recipeId — get single recipe template detail // GET /api/recipes/templates/:recipeId — get single recipe template detail
@@ -64,7 +65,7 @@ module.exports = function(ctx) {
const recipe = RECIPE_TEMPLATES[req.params.recipeId]; const recipe = RECIPE_TEMPLATES[req.params.recipeId];
if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`); if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`);
res.json({ success: true, recipe: { id: req.params.recipeId, ...recipe } }); ok(res, { recipe: { id: req.params.recipeId, ...recipe } });
}, 'recipe-template-detail')); }, 'recipe-template-detail'));
// Mount deploy and manage sub-routes — pass full ctx for sub-routes that reference ctx.* // Mount deploy and manage sub-routes — pass full ctx for sub-routes that reference ctx.*
+6 -5
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
const { ok } = require('../../src/utils/responses');
module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) { module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) {
const router = express.Router(); const router = express.Router();
@@ -98,7 +99,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
} }
} }
res.json({ success: true, recipes: Object.values(recipeGroups) }); ok(res, { recipes: Object.values(recipeGroups) });
}, 'recipe-deployed')); }, 'recipe-deployed'));
/** /**
@@ -129,7 +130,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
} }
log.info('recipe', 'Recipe started', { recipeId, results }); log.info('recipe', 'Recipe started', { recipeId, results });
res.json({ success: true, recipeId, results }); ok(res, { recipeId, results });
}, 'recipe-start')); }, 'recipe-start'));
/** /**
@@ -161,7 +162,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
} }
log.info('recipe', 'Recipe stopped', { recipeId, results }); log.info('recipe', 'Recipe stopped', { recipeId, results });
res.json({ success: true, recipeId, results }); ok(res, { recipeId, results });
}, 'recipe-stop')); }, 'recipe-stop'));
/** /**
@@ -187,7 +188,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
} }
log.info('recipe', 'Recipe restarted', { recipeId, results }); log.info('recipe', 'Recipe restarted', { recipeId, results });
res.json({ success: true, recipeId, results }); ok(res, { recipeId, results });
}, 'recipe-restart')); }, 'recipe-restart'));
/** /**
@@ -259,7 +260,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
); );
log.info('recipe', 'Recipe removed', { recipeId, results }); log.info('recipe', 'Recipe removed', { recipeId, results });
res.json({ success: true, recipeId, results }); ok(res, { recipeId, results });
}, 'recipe-remove')); }, 'recipe-remove'));
// === Helper functions === // === Helper functions ===
+5 -3
View File
@@ -10,7 +10,7 @@ const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError, NotFoundError, ConflictError } = require('../errors'); const { ValidationError, NotFoundError, ConflictError } = require('../errors');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
/** /**
@@ -356,9 +356,11 @@ module.exports = function({
}, 'services-status')); }, 'services-status'));
// List all services // List all services
// Always returns the standard envelope. The `services` field is the array
// (paginated if ?page=N&limit=M is in the query, otherwise the full list).
router.get('/services', asyncHandler(async (req, res) => { router.get('/services', asyncHandler(async (req, res) => {
if (!await exists(SERVICES_FILE)) { if (!await exists(SERVICES_FILE)) {
return res.json([]); return success(res, { services: [] });
} }
const services = await servicesStateManager.read(); const services = await servicesStateManager.read();
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
@@ -366,7 +368,7 @@ module.exports = function({
if (paginationParams) { if (paginationParams) {
success(res, { services: result.data, pagination: result.pagination }); success(res, { services: result.data, pagination: result.pagination });
} else { } else {
res.json(result.data); success(res, { services: result.data });
} }
}, 'services-list')); }, 'services-list'));
+10 -10
View File
@@ -3,6 +3,7 @@ const fs = require('fs');
const { CADDY, REGEX, LIMITS } = require('../constants'); const { CADDY, REGEX, LIMITS } = require('../constants');
const { ValidationError, ConflictError, NotFoundError } = require('../errors'); const { ValidationError, ConflictError, NotFoundError } = require('../errors');
const { validateURL } = require('../input-validator'); const { validateURL } = require('../input-validator');
const { ok, successMessage } = require('../src/utils/responses');
/** /**
* Sites route factory * Sites route factory
@@ -23,14 +24,14 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
// Get Caddyfile contents // Get Caddyfile contents
router.get('/caddyfile', asyncHandler(async (req, res) => { router.get('/caddyfile', asyncHandler(async (req, res) => {
const content = await caddy.read(); const content = await caddy.read();
res.json({ success: true, content }); ok(res, { content });
}, 'caddyfile-get')); }, 'caddyfile-get'));
// Get current Caddy config (from admin API) // Get current Caddy config (from admin API)
router.get('/caddy/config', asyncHandler(async (req, res) => { router.get('/caddy/config', asyncHandler(async (req, res) => {
const response = await fetchT(`${caddy.adminUrl}/config/`); const response = await fetchT(`${caddy.adminUrl}/config/`);
const config = await response.json(); const config = await response.json();
res.json({ success: true, config }); ok(res, { config });
}, 'caddy-config')); }, 'caddy-config'));
// Reload Caddy configuration via admin API // Reload Caddy configuration via admin API
@@ -49,7 +50,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
throw new Error('Caddy reload failed. Check server logs for details.'); throw new Error('Caddy reload failed. Check server logs for details.');
} }
res.json({ success: true, message: 'Caddy configuration reloaded successfully' }); successMessage(res, 'Caddy configuration reloaded successfully');
}, 'caddy-reload')); }, 'caddy-reload'));
// Get Certificate Authorities from Caddyfile // Get Certificate Authorities from Caddyfile
@@ -127,7 +128,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
name: ca.name, name: ca.name,
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.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 } }); ok(res, { cas: caList });
}, 'caddy-get-cas')); }, 'caddy-get-cas'));
// Remove a site from Caddyfile // Remove a site from Caddyfile
@@ -152,7 +153,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
throw new NotFoundError(`Site block for "" in Caddyfile`); throw new NotFoundError(`Site block for "" in Caddyfile`);
} }
res.json({ success: true, message: `Site "${domain}" removed from Caddyfile and Caddy reloaded` }); successMessage(res, `Site "${domain}" removed from Caddyfile and Caddy reloaded`);
}, 'site-delete')); }, 'site-delete'));
// Add a new site to Caddyfile and reload // Add a new site to Caddyfile and reload
@@ -180,7 +181,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
result.rolledBack ? { note: 'Caddyfile was rolled back to previous state' } : {}); result.rolledBack ? { note: 'Caddyfile was rolled back to previous state' } : {});
} }
res.json({ success: true, message: `Site "${domain}" added to Caddyfile and Caddy reloaded successfully` }); successMessage(res, `Site "${domain}" added to Caddyfile and Caddy reloaded successfully`);
}, 'site-add')); }, 'site-add'));
// Add external service reverse proxy to Caddyfile // Add external service reverse proxy to Caddyfile
@@ -260,12 +261,11 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
} }
} }
const response = { const responseData = {
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; if (dnsWarning) responseData.warning = dnsWarning;
res.json(response); ok(res, responseData);
}, 'site-external')); }, 'site-external'));
return router; return router;
+1 -1
View File
@@ -6,7 +6,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success, error: errorResponse, notFound } = require('../response-helpers'); const { success, error: errorResponse, notFound } = require('../src/utils/responses');
/** /**
* SSL Monitor route factory * SSL Monitor route factory
+13 -19
View File
@@ -3,6 +3,7 @@ const fs = require('fs');
const { TAILSCALE } = require('../constants'); const { TAILSCALE } = require('../constants');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
const { ok, successMessage, unauthorized } = require('../src/utils/responses');
/** /**
* Tailscale route factory * Tailscale route factory
@@ -35,8 +36,7 @@ module.exports = function({
const localIP = await tailscale.getLocalIP(); const localIP = await tailscale.getLocalIP();
if (!status) { if (!status) {
return res.json({ return ok(res, {
success: true,
installed: false, installed: false,
connected: false, connected: false,
message: 'Tailscale not available or not running' message: 'Tailscale not available or not running'
@@ -58,8 +58,7 @@ module.exports = function({
} }
} }
res.json({ ok(res, {
success: true,
installed: true, installed: true,
connected: status.BackendState === 'Running', connected: status.BackendState === 'Running',
backendState: status.BackendState, backendState: status.BackendState,
@@ -85,8 +84,7 @@ module.exports = function({
await tailscale.save(); await tailscale.save();
res.json({ ok(res, {
success: true,
message: 'Tailscale configuration updated', message: 'Tailscale configuration updated',
config: tailscale.config config: tailscale.config
}); });
@@ -101,8 +99,7 @@ module.exports = function({
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean); const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
const isTailscale = ipsToCheck.some(ip => tailscale.isTailscaleIP(ip.toString().split(',')[0].trim())); const isTailscale = ipsToCheck.some(ip => tailscale.isTailscaleIP(ip.toString().split(',')[0].trim()));
res.json({ ok(res, {
success: true,
isTailscale, isTailscale,
clientIP, clientIP,
forwardedFor: forwardedFor || null, forwardedFor: forwardedFor || null,
@@ -114,7 +111,7 @@ module.exports = function({
router.get('/devices', asyncHandler(async (req, res) => { router.get('/devices', asyncHandler(async (req, res) => {
const status = await tailscale.getStatus(); const status = await tailscale.getStatus();
if (!status || !status.Peer) { if (!status || !status.Peer) {
return res.json({ success: true, devices: [] }); return ok(res, { devices: [] });
} }
const devices = []; const devices = [];
@@ -141,7 +138,7 @@ module.exports = function({
}); });
} }
res.json({ success: true, devices }); ok(res, { devices });
}, 'tailscale-devices')); }, 'tailscale-devices'));
// Toggle Tailscale-only mode for an existing service // Toggle Tailscale-only mode for an existing service
@@ -190,8 +187,7 @@ module.exports = function({
}); });
} }
res.json({ ok(res, {
success: true,
message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`, message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`,
tailscaleOnly: tailscaleOnly !== false tailscaleOnly: tailscaleOnly !== false
}); });
@@ -254,7 +250,7 @@ module.exports = function({
log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message }); log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message });
} }
res.json({ success: true, config: tailscale.config }); ok(res, { config: tailscale.config });
}, 'tailscale-oauth-config')); }, 'tailscale-oauth-config'));
// Remove OAuth credentials and disable API sync // Remove OAuth credentials and disable API sync
@@ -269,7 +265,7 @@ module.exports = function({
tailscale.stopSync(); tailscale.stopSync();
res.json({ success: true, message: 'Tailscale OAuth credentials removed' }); successMessage(res, 'Tailscale OAuth credentials removed');
}, 'tailscale-oauth-delete')); }, 'tailscale-oauth-delete'));
// Get enriched device list from Tailscale API // Get enriched device list from Tailscale API
@@ -279,8 +275,7 @@ module.exports = function({
} }
// Return cached devices from last sync // Return cached devices from last sync
res.json({ ok(res, {
success: true,
devices: tailscale.config.devices || [], devices: tailscale.config.devices || [],
lastSync: tailscale.config.lastSync lastSync: tailscale.config.lastSync
}); });
@@ -294,8 +289,7 @@ module.exports = function({
const devices = await tailscale.syncAPI(); const devices = await tailscale.syncAPI();
res.json({ ok(res, {
success: true,
devices: devices || [], devices: devices || [],
lastSync: tailscale.config.lastSync lastSync: tailscale.config.lastSync
}); });
@@ -325,7 +319,7 @@ module.exports = function({
sshRuleCount: (acl.ssh || []).length sshRuleCount: (acl.ssh || []).length
}; };
res.json({ success: true, acl, summary }); ok(res, { acl, summary });
}, 'tailscale-acl')); }, 'tailscale-acl'));
return router; return router;
+1 -1
View File
@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
+20 -21
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
const { ok, successMessage } = require('../src/utils/responses');
/** /**
* Updates route factory * Updates route factory
@@ -20,7 +21,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
router.post('/updates/check', asyncHandler(async (req, res) => { router.post('/updates/check', asyncHandler(async (req, res) => {
await updateManager.checkForUpdates(); await updateManager.checkForUpdates();
const updates = updateManager.getAvailableUpdates(); const updates = updateManager.getAvailableUpdates();
res.json({ success: true, updates, count: updates.length }); ok(res, { updates, count: updates.length });
}, 'updates-check')); }, 'updates-check'));
// Get available updates // Get available updates
@@ -28,19 +29,19 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
const updates = updateManager.getAvailableUpdates(); const updates = updateManager.getAvailableUpdates();
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
const result = paginate(updates, paginationParams); const result = paginate(updates, paginationParams);
res.json({ success: true, updates: result.data, count: updates.length, ...(result.pagination && { pagination: result.pagination }) }); ok(res, { updates: result.data, count: updates.length, ...(result.pagination && { pagination: result.pagination }) });
}, 'updates-available')); }, 'updates-available'));
// Update a container // Update a container
router.post('/updates/update/:containerId', asyncHandler(async (req, res) => { router.post('/updates/update/:containerId', asyncHandler(async (req, res) => {
const result = await updateManager.updateContainer(req.params.containerId, req.body); const result = await updateManager.updateContainer(req.params.containerId, req.body);
res.json({ success: true, result }); ok(res, { result });
}, 'updates-update')); }, 'updates-update'));
// Rollback update // Rollback update
router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => { router.post('/updates/rollback/:containerId', asyncHandler(async (req, res) => {
await updateManager.rollbackUpdate(req.params.containerId); await updateManager.rollbackUpdate(req.params.containerId);
res.json({ success: true, message: 'Rollback completed' }); successMessage(res, 'Rollback completed');
}, 'updates-rollback')); }, 'updates-rollback'));
// Get update history // Get update history
@@ -50,19 +51,19 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50); const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50);
const history = updateManager.getHistory(fetchLimit); const history = updateManager.getHistory(fetchLimit);
const result = paginate(history, paginationParams); const result = paginate(history, paginationParams);
res.json({ success: true, history: result.data, ...(result.pagination && { pagination: result.pagination }) }); ok(res, { history: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'updates-history')); }, 'updates-history'));
// Configure auto-update // Configure auto-update
router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => { router.post('/updates/auto-update/:containerId', asyncHandler(async (req, res) => {
updateManager.configureAutoUpdate(req.params.containerId, req.body); updateManager.configureAutoUpdate(req.params.containerId, req.body);
res.json({ success: true, message: 'Auto-update configured' }); successMessage(res, 'Auto-update configured');
}, 'updates-auto-update')); }, 'updates-auto-update'));
// Get auto-update configuration // Get auto-update configuration
router.get('/updates/auto-update', asyncHandler(async (req, res) => { router.get('/updates/auto-update', asyncHandler(async (req, res) => {
const config = updateManager.getAutoUpdateConfig(); const config = updateManager.getAutoUpdateConfig();
res.json({ success: true, config }); ok(res, { config });
}, 'updates-auto-update-config')); }, 'updates-auto-update-config'));
// Schedule update // Schedule update
@@ -72,7 +73,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
throw new ValidationError('scheduledTime is required'); throw new ValidationError('scheduledTime is required');
} }
updateManager.scheduleUpdate(req.params.containerId, scheduledTime); updateManager.scheduleUpdate(req.params.containerId, scheduledTime);
res.json({ success: true, message: 'Update scheduled', scheduledTime }); ok(res, { message: 'Update scheduled', scheduledTime });
}, 'updates-schedule')); }, 'updates-schedule'));
// ===== DASHCADDY SELF-UPDATE ENDPOINTS ===== // ===== DASHCADDY SELF-UPDATE ENDPOINTS =====
@@ -80,20 +81,20 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
// Get current version // Get current version
router.get('/system/version', asyncHandler(async (req, res) => { router.get('/system/version', asyncHandler(async (req, res) => {
const local = selfUpdater.getLocalVersion(); const local = selfUpdater.getLocalVersion();
res.json({ success: true, name: 'DashCaddy', version: local.version, commit: local.commit }); ok(res, { name: 'DashCaddy', version: local.version, commit: local.commit });
}, 'system-version')); }, 'system-version'));
// Check for DashCaddy update // Check for DashCaddy update
router.get('/system/update-check', asyncHandler(async (req, res) => { router.get('/system/update-check', asyncHandler(async (req, res) => {
const result = await selfUpdater.checkForUpdate(); const result = await selfUpdater.checkForUpdate();
res.json({ success: true, ...result }); ok(res, result);
}, 'system-update-check')); }, 'system-update-check'));
// Apply available update // Apply available update
router.post('/system/update-apply', asyncHandler(async (req, res) => { router.post('/system/update-apply', asyncHandler(async (req, res) => {
const check = await selfUpdater.checkForUpdate(); const check = await selfUpdater.checkForUpdate();
if (!check.available) { if (!check.available) {
return res.json({ success: true, message: 'Already up to date' }); return successMessage(res, 'Already up to date');
} }
// Refuse same-version applies. The check.available flag can theoretically be // Refuse same-version applies. The check.available flag can theoretically be
// true with equal versions (commit-mismatch path); applying anyway just // true with equal versions (commit-mismatch path); applying anyway just
@@ -102,14 +103,13 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
const localV = check.local && check.local.version; const localV = check.local && check.local.version;
const remoteV = check.remote && check.remote.version; const remoteV = check.remote && check.remote.version;
if (localV && remoteV && localV === remoteV) { if (localV && remoteV && localV === remoteV) {
return res.json({ success: true, message: 'Already up to date', version: localV }); return ok(res, { message: 'Already up to date', version: localV });
} }
// Start async — container may restart // Start async — container may restart
selfUpdater.applyUpdate(check.remote).catch(err => { selfUpdater.applyUpdate(check.remote).catch(err => {
logError('self-update', err); logError('self-update', err);
}); });
res.json({ ok(res, {
success: true,
message: 'Update initiated', message: 'Update initiated',
fromVersion: localV, fromVersion: localV,
toVersion: remoteV, toVersion: remoteV,
@@ -132,16 +132,15 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
presentedBuf.length > 0 && presentedBuf.length > 0 &&
require('crypto').timingSafeEqual(presentedBuf, expectedBuf); require('crypto').timingSafeEqual(presentedBuf, expectedBuf);
if (!ok) { if (!ok) {
return res.status(401).json({ success: false, error: 'Invalid notify secret' }); return unauthorized(res, 'Invalid notify secret');
} }
const result = selfUpdater.notifyAndApply('http-notify'); const result = selfUpdater.notifyAndApply('http-notify');
res.json({ success: true, ...result }); ok(res, result);
}, 'system-update-notify')); }, 'system-update-notify'));
// Get update status // Get update status
router.get('/system/update-status', asyncHandler(async (req, res) => { router.get('/system/update-status', asyncHandler(async (req, res) => {
res.json({ ok(res, {
success: true,
status: selfUpdater.getStatus(), status: selfUpdater.getStatus(),
lastCheck: selfUpdater.lastCheckTime, lastCheck: selfUpdater.lastCheckTime,
lastResult: selfUpdater.lastCheckResult, lastResult: selfUpdater.lastCheckResult,
@@ -151,13 +150,13 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
// Get self-update history // Get self-update history
router.get('/system/update-history', asyncHandler(async (req, res) => { router.get('/system/update-history', asyncHandler(async (req, res) => {
const history = selfUpdater.getUpdateHistory(); const history = selfUpdater.getUpdateHistory();
res.json({ success: true, history }); ok(res, { history });
}, 'system-update-history')); }, 'system-update-history'));
// List rollback versions // List rollback versions
router.get('/system/rollback-versions', asyncHandler(async (req, res) => { router.get('/system/rollback-versions', asyncHandler(async (req, res) => {
const versions = selfUpdater.getAvailableRollbacks(); const versions = selfUpdater.getAvailableRollbacks();
res.json({ success: true, versions }); ok(res, { versions });
}, 'system-rollback-versions')); }, 'system-rollback-versions'));
// Rollback to a previous version // Rollback to a previous version
@@ -167,7 +166,7 @@ module.exports = function({ updateManager, selfUpdater, asyncHandler, logError }
selfUpdater.rollbackToVersion(version).catch(err => { selfUpdater.rollbackToVersion(version).catch(err => {
logError('self-rollback', err); logError('self-rollback', err);
}); });
res.json({ success: true, message: `Rollback to ${version} initiated` }); ok(res, { message: `Rollback to ${version} initiated` });
}, 'system-rollback')); }, 'system-rollback'));
return router; return router;
+7 -6
View File
@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const { ok } = require('../src/utils/responses');
/** /**
* Workflows routes factory * Workflows routes factory
@@ -19,21 +20,21 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
// List all bundled workflows // List all bundled workflows
router.get('/workflows', asyncHandler(async (req, res) => { router.get('/workflows', asyncHandler(async (req, res) => {
const workflows = workflowEngine.listWorkflows(); const workflows = workflowEngine.listWorkflows();
res.json({ success: true, workflows }); ok(res, { workflows });
}, 'workflows-list')); }, 'workflows-list'));
// Enable a workflow // Enable a workflow
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => { router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
const { workflowId } = req.params; const { workflowId } = req.params;
const result = workflowEngine.setWorkflowEnabled(workflowId, true); const result = workflowEngine.setWorkflowEnabled(workflowId, true);
res.json({ success: true, ...result }); ok(res, result);
}, 'workflows-enable')); }, 'workflows-enable'));
// Disable a workflow // Disable a workflow
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => { router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
const { workflowId } = req.params; const { workflowId } = req.params;
const result = workflowEngine.setWorkflowEnabled(workflowId, false); const result = workflowEngine.setWorkflowEnabled(workflowId, false);
res.json({ success: true, ...result }); ok(res, result);
}, 'workflows-disable')); }, 'workflows-disable'));
// Manually trigger a workflow // Manually trigger a workflow
@@ -43,7 +44,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
triggerData.trigger = 'manual'; triggerData.trigger = 'manual';
const result = await workflowEngine.executeWorkflow(workflowId, triggerData); const result = await workflowEngine.executeWorkflow(workflowId, triggerData);
res.json({ success: true, result }); ok(res, { result });
}, 'workflows-run')); }, 'workflows-run'));
// Get execution history for a workflow // Get execution history for a workflow
@@ -51,14 +52,14 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler }) {
const { workflowId } = req.params; const { workflowId } = req.params;
const limit = parseInt(req.query.limit) || 50; const limit = parseInt(req.query.limit) || 50;
const history = workflowEngine.getHistory(workflowId, limit); const history = workflowEngine.getHistory(workflowId, limit);
res.json({ success: true, history }); ok(res, { history });
}, 'workflows-history')); }, 'workflows-history'));
// Get all workflow execution history // Get all workflow execution history
router.get('/workflows/history', asyncHandler(async (req, res) => { router.get('/workflows/history', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100; const limit = parseInt(req.query.limit) || 100;
const history = workflowEngine.getHistory(null, limit); const history = workflowEngine.getHistory(null, limit);
res.json({ success: true, history }); ok(res, { history });
}, 'workflows-all-history')); }, 'workflows-all-history'));
return router; return router;
+86 -7
View File
@@ -404,8 +404,7 @@ async function createApp() {
appName = pkg.name || appName; appName = pkg.name || appName;
} catch { /* package.json unreadable — keep fallback */ } } catch { /* package.json unreadable — keep fallback */ }
apiRouter.get('/version', (req, res) => { apiRouter.get('/version', (req, res) => {
res.json({ ok(res, {
success: true,
name: appName, name: appName,
version: appVersion, version: appVersion,
node: process.version, node: process.version,
@@ -608,15 +607,15 @@ async function createApp() {
// Inline API routes // Inline API routes
apiRouter.get('/health', (req, res) => { apiRouter.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); ok(res, { status: 'ok', timestamp: new Date().toISOString() });
}); });
apiRouter.get('/csrf-token', (req, res) => { apiRouter.get('/csrf-token', (req, res) => {
res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME }); ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME });
}); });
apiRouter.get('/metrics', (req, res) => { apiRouter.get('/metrics', (req, res) => {
res.json({ success: true, metrics: metrics.getSummary() }); ok(res, { metrics: metrics.getSummary() });
}); });
// Mount at /api/v1 (canonical, single version) // Mount at /api/v1 (canonical, single version)
@@ -624,9 +623,89 @@ async function createApp() {
// Root-level health check // Root-level health check
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); ok(res, { status: 'ok', timestamp: new Date().toISOString() });
}); });
// Liveness probe — "is the process alive?"
// Always returns 200 unless the Node.js event loop is completely blocked.
// Used by k8s/Docker to decide whether to RESTART the container.
// DO NOT add dependency checks here — those belong in /health/ready.
app.get('/health/live', (req, res) => {
ok(res, { status: 'alive', uptime: process.uptime() });
});
// Readiness probe — "is the app ready to serve traffic?"
// Checks critical dependencies: Docker daemon, Caddy admin API, config file.
// Returns 200 with details if all OK, 503 with failed components otherwise.
// Used by k8s/Docker to decide whether to ROUTE TRAFFIC to this instance.
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
const checks = {};
let allOk = true;
// Check 1: Config file readable
try {
const fs = require('fs');
if (fs.existsSync(config.CONFIG_FILE)) {
fs.readFileSync(config.CONFIG_FILE, 'utf8');
checks.configFile = { ok: true };
} else {
checks.configFile = { ok: false, error: 'Config file not found' };
allOk = false;
}
} catch (e) {
checks.configFile = { ok: false, error: e.message };
allOk = false;
}
// Check 2: Services file readable
try {
const fs = require('fs');
if (fs.existsSync(config.SERVICES_FILE)) {
fs.readFileSync(config.SERVICES_FILE, 'utf8');
checks.servicesFile = { ok: true };
} else {
checks.servicesFile = { ok: false, error: 'Services file not found' };
allOk = false;
}
} catch (e) {
checks.servicesFile = { ok: false, error: e.message };
allOk = false;
}
// Check 3: Docker daemon reachable
try {
const docker = require('dockerode')();
await docker.ping();
checks.docker = { ok: true };
} catch (e) {
checks.docker = { ok: false, error: e.message };
allOk = false;
}
// Check 4: Caddy admin API reachable
try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${caddyUrl}/config/`, {
signal: controller.signal
});
clearTimeout(timeout);
checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false;
} catch (e) {
checks.caddy = { ok: false, error: e.message };
allOk = false;
}
const body = {
status: allOk ? 'ready' : 'not-ready',
timestamp: new Date().toISOString(),
checks
};
ok(res, body, allOk ? 200 : 503);
}));
// Lightweight probe endpoint // Lightweight probe endpoint
app.get('/probe/:id', boundAsyncHandler(async (req, res) => { app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
const id = req.params.id; const id = req.params.id;
@@ -750,7 +829,7 @@ async function createApp() {
} }
} }
res.json(result); ok(res, result);
} catch (error) { } catch (error) {
errorResponse(res, 500, safeErrorMessage(error)); errorResponse(res, 500, safeErrorMessage(error));
} }
+2 -3
View File
@@ -93,9 +93,8 @@ async function verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts
try { try {
const response = await fetchT(`https://${domain}/`, { const response = await fetchT(`https://${domain}/`, {
method: 'HEAD', method: 'HEAD',
agent: httpsAgent, agent: httpsAgent
timeout: 5000 }, 5000);
});
log.info('caddy', 'Site is accessible', { domain, status: response.status }); log.info('caddy', 'Site is accessible', { domain, status: response.status });
return true; return true;
+3 -3
View File
@@ -58,9 +58,9 @@ async function refreshDnsToken(username, password, server, fetchT, log) {
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded'
}, }
timeout: 10000 },
} 10000
); );
const result = await response.json(); const result = await response.json();
+2 -1
View File
@@ -96,7 +96,8 @@ function createProviderDnsContext(siteConfig, buildDomain, credentialManager, fe
const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' }); const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' });
const response = await fetchT( const response = await fetchT(
`http://${server}:5380/api/user/login?${params.toString()}`, `http://${server}:5380/api/user/login?${params.toString()}`,
{ method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 10000 } { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' } },
10000
); );
const result = await response.json(); const result = await response.json();
if (result.status === 'ok' && result.token) { if (result.status === 'ok' && result.token) {
+9 -1
View File
@@ -38,7 +38,15 @@ function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
if (!opts.signal) { if (!opts.signal) {
opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) }; opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) };
} }
delete opts.timeout; // The `timeout` key in fetch() opts is silently ignored by undici. Callers
// should use the third arg of fetchT() (timeoutMs) instead. If a caller
// passes `timeout: N` here, it's almost certainly a bug — we used to silently
// strip it, which masked the issue. Now we surface it in logs and strip it.
if ('timeout' in opts) {
console.warn(`[fetchT] opts.timeout=${opts.timeout} is ignored — pass timeoutMs as the 3rd arg of fetchT() instead. Called from: ${new Error().stack.split('\n').slice(2, 4).join(' <- ')}`);
const { timeout, ...rest } = opts;
opts = rest;
}
return fetch(url, opts); return fetch(url, opts);
} }
+107 -5
View File
@@ -1,22 +1,124 @@
/** /**
* Response helpers - Standard API response formats * Response helpers - Standard API response formats
*
* Single source of truth for HTTP response shapes across DashCaddy.
* Standard envelope: { success: true, ...data } or { success: false, error: "..." }.
*
* All routes should import from this module do not call res.json/res.status
* directly with the response shape, use these helpers instead.
*/ */
const { HTTP_STATUS } = require('../../constants');
// ── Success helpers ────────────────────────────────────────────
/** /**
* Standard error response * Standard success response. Use this in route handlers.
* Wraps the data object with a `success: true` envelope.
* @param {object} res Express response
* @param {object} [data={}] fields to include in the response body
* @param {number} [statusCode=200] HTTP status code
*/
function ok(res, data = {}, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({ success: true, ...data });
}
/**
* Alias for `ok` prefer `ok` in new code, but kept for code that imports as `success`.
*/
function success(res, data, statusCode) {
return ok(res, data, statusCode);
}
/**
* Success response with a human-readable message field.
* Use when there's no data to return, just confirmation.
*/
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({ success: true, message });
}
/**
* 201 Created response.
*/
function created(res, data = {}) {
return res.status(HTTP_STATUS.CREATED).json({ success: true, ...data });
}
/**
* 204 No Content response.
*/
function noContent(res) {
return res.status(HTTP_STATUS.NO_CONTENT).send();
}
// ── Error helpers ──────────────────────────────────────────────
/**
* Standard error response. Use this in route handlers.
* @param {object} res Express response
* @param {number} statusCode HTTP status code
* @param {string} message Human-readable error message
* @param {object} [extras={}] additional fields to merge into the response
*/ */
function errorResponse(res, statusCode, message, extras = {}) { function errorResponse(res, statusCode, message, extras = {}) {
return res.status(statusCode).json({ success: false, error: message, ...extras }); return res.status(statusCode).json({ success: false, error: message, ...extras });
} }
/** /**
* Standard success response * Alias for `errorResponse` kept for code that imports as `error`.
*/ */
function ok(res, data = {}) { function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
return res.json({ success: true, ...data }); return res.status(statusCode).json({ success: false, error: message });
}
/**
* 400 Bad Request invalid input from the user.
*/
function validationError(res, message) {
return res.status(HTTP_STATUS.BAD_REQUEST).json({ success: false, error: message });
}
/**
* 401 Unauthorized no valid credentials.
*/
function unauthorized(res, message = 'Unauthorized') {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ success: false, error: message });
}
/**
* 403 Forbidden credentials valid but permission denied.
*/
function forbidden(res, message = 'Forbidden') {
return res.status(HTTP_STATUS.FORBIDDEN).json({ success: false, error: message });
}
/**
* 404 Not Found resource doesn't exist.
*/
function notFound(res, message = 'Not found') {
return res.status(HTTP_STATUS.NOT_FOUND).json({ success: false, error: message });
}
/**
* 409 Conflict request conflicts with current state (e.g. duplicate).
*/
function conflict(res, message) {
return res.status(HTTP_STATUS.CONFLICT).json({ success: false, error: message });
} }
module.exports = { module.exports = {
errorResponse, // Success helpers
ok, ok,
success,
successMessage,
created,
noContent,
// Error helpers
errorResponse,
error,
validationError,
unauthorized,
forbidden,
notFound,
conflict,
}; };
+3 -1
View File
@@ -65,7 +65,9 @@
if (window.SkeletonLoader) window.SkeletonLoader.show(6); if (window.SkeletonLoader) window.SkeletonLoader.show(6);
const response = await fetch('/api/v1/services', { cache: 'no-store' }); const response = await fetch('/api/v1/services', { cache: 'no-store' });
if (response.ok) { if (response.ok) {
window.APPS = await response.json(); const result = await response.json();
// Standard envelope: { success: true, services: [...], pagination?: {...} }
window.APPS = result.services || [];
if (window.SkeletonLoader) window.SkeletonLoader.hide(); if (window.SkeletonLoader) window.SkeletonLoader.hide();
} else { } else {
console.error('Failed to load services:', response.status); console.error('Failed to load services:', response.status);
+3 -3
View File
@@ -14,15 +14,15 @@
const result = await response.json(); const result = await response.json();
if (result.status === 'success') { if (result.success) {
const select = document.getElementById('existing-ca-select'); const select = document.getElementById('existing-ca-select');
select.innerHTML = ''; select.innerHTML = '';
if (result.data.cas.length === 0) { if (result.cas.length === 0) {
select.innerHTML = '<option value="">No CAs found in Caddyfile</option>'; select.innerHTML = '<option value="">No CAs found in Caddyfile</option>';
} else { } else {
select.innerHTML = '<option value="">Select existing CA...</option>'; select.innerHTML = '<option value="">Select existing CA...</option>';
result.data.cas.forEach(ca => { result.cas.forEach(ca => {
const option = document.createElement('option'); const option = document.createElement('option');
if (typeof ca === 'object') { if (typeof ca === 'object') {
option.value = ca.id; option.value = ca.id;