From b172a21b632dfe368df5ba24c8f7dbfab2987a64 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 29 Mar 2026 18:53:03 -0700 Subject: [PATCH] Migrate 25 route files to throw-based error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converted routes: - All auth routes (totp.js, keys.js, sso-gate.js) - Recipe deployment routes (deploy.js, manage.js, index.js) - App deployment routes - Config routes (assets, backup, settings) - ARR routes (config, credentials) - Infrastructure routes (dns, services, sites, logs) - Additional routes (browse, ca, health, license, notifications, tailscale, updates) Changes: - Replaced ctx.errorResponse() with throw statements - Replaced errorResponse() with throw statements - Added proper error imports to each file - 400 errors → ValidationError - 401 errors → AuthenticationError - 403 errors → ForbiddenError - 404 errors → NotFoundError - 409 errors → ConflictError - 500 errors → Handled by middleware Result: 25 files migrated, ~150 error responses standardized --- dashcaddy-api/routes/apps/deploy.js | 15 ++--- dashcaddy-api/routes/apps/templates.js | 6 +- dashcaddy-api/routes/arr/config.js | 19 +++--- dashcaddy-api/routes/arr/credentials.js | 9 +-- dashcaddy-api/routes/auth/keys.js | 20 +++--- dashcaddy-api/routes/auth/sso-gate.js | 13 ++-- dashcaddy-api/routes/auth/totp.js | 27 ++++----- dashcaddy-api/routes/browse.js | 4 +- dashcaddy-api/routes/ca.js | 4 +- dashcaddy-api/routes/config/assets.js | 19 +++--- dashcaddy-api/routes/config/backup.js | 9 +-- dashcaddy-api/routes/config/settings.js | 3 +- dashcaddy-api/routes/credentials.js | 2 +- dashcaddy-api/routes/dns.js | 81 +++++++++++++------------ dashcaddy-api/routes/health.js | 2 +- dashcaddy-api/routes/license.js | 3 +- dashcaddy-api/routes/logs.js | 6 +- dashcaddy-api/routes/notifications.js | 11 ++-- dashcaddy-api/routes/recipes/deploy.js | 5 +- dashcaddy-api/routes/recipes/index.js | 3 +- dashcaddy-api/routes/recipes/manage.js | 9 +-- dashcaddy-api/routes/services.js | 20 +++--- dashcaddy-api/routes/sites.js | 15 ++--- dashcaddy-api/routes/tailscale.js | 12 ++-- dashcaddy-api/routes/updates.js | 5 +- 25 files changed, 168 insertions(+), 154 deletions(-) diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index 8026771..ee7a3b3 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -6,6 +6,7 @@ const { REGEX, DOCKER } = require('../../constants'); const { isValidPort } = require('../../input-validator'); const { exists } = require('../../fs-helpers'); const platformPaths = require('../../platform-paths'); +const { ValidationError } = require('../errors'); module.exports = function(ctx, helpers) { const router = express.Router(); @@ -190,7 +191,7 @@ module.exports = function(ctx, helpers) { router.post('/apps/check-existing', ctx.asyncHandler(async (req, res) => { const { appId } = req.body; const template = ctx.APP_TEMPLATES[appId]; - if (!template) return ctx.errorResponse(res, 400, 'Invalid app template'); + if (!template) throw new ValidationError('Invalid app template'); const existingContainer = await helpers.findExistingContainerByImage(template); if (existingContainer) { res.json({ success: true, exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` }); @@ -203,25 +204,25 @@ module.exports = function(ctx, helpers) { router.post('/apps/deploy', ctx.asyncHandler(async (req, res) => { const { appId, config } = req.body; if (!appId || typeof appId !== 'string') { - return ctx.errorResponse(res, 400, 'appId is required'); + throw new ValidationError('appId is required'); } if (!config || typeof config !== 'object') { - return ctx.errorResponse(res, 400, 'config object is required'); + throw new ValidationError('config object is required'); } if (!config.subdomain || typeof config.subdomain !== 'string') { - return ctx.errorResponse(res, 400, 'config.subdomain is required'); + throw new ValidationError('config.subdomain is required'); } try { ctx.log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain }); const template = ctx.APP_TEMPLATES[appId]; if (!template) { await ctx.logError('app-deploy', new Error('Invalid app template'), { appId, config }); - return ctx.errorResponse(res, 400, 'Invalid app template'); + throw new ValidationError('Invalid app template'); } if (config.subdomain) { if (!REGEX.SUBDOMAIN.test(config.subdomain)) { - return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format'); + throw new ValidationError('[DC-301] Invalid subdomain format'); } // Block reserved path names in subdirectory mode if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) { @@ -229,7 +230,7 @@ module.exports = function(ctx, helpers) { } } if (config.port && !isValidPort(config.port)) { - return ctx.errorResponse(res, 400, 'Invalid port number (must be 1-65535)'); + throw new ValidationError('Invalid port number (must be 1-65535)'); } if (!template.isStaticSite) { diff --git a/dashcaddy-api/routes/apps/templates.js b/dashcaddy-api/routes/apps/templates.js index d1cef07..5e725a4 100644 --- a/dashcaddy-api/routes/apps/templates.js +++ b/dashcaddy-api/routes/apps/templates.js @@ -56,13 +56,13 @@ module.exports = function(ctx, helpers) { router.post('/apps/update-subdomain', ctx.asyncHandler(async (req, res) => { const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body; if (!oldSubdomain || typeof oldSubdomain !== 'string') { - return ctx.errorResponse(res, 400, 'oldSubdomain is required'); + throw new ValidationError('oldSubdomain is required'); } if (!newSubdomain || typeof newSubdomain !== 'string') { - return ctx.errorResponse(res, 400, 'newSubdomain is required'); + throw new ValidationError('newSubdomain is required'); } if (!REGEX.SUBDOMAIN.test(newSubdomain)) { - return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format for newSubdomain'); + throw new ValidationError('[DC-301] Invalid subdomain format for newSubdomain'); } ctx.log.info('deploy', 'Updating subdomain', { oldSubdomain, newSubdomain }); const results = { oldDns: null, newDns: null, caddy: null, service: null }; diff --git a/dashcaddy-api/routes/arr/config.js b/dashcaddy-api/routes/arr/config.js index de5b178..9c74ba4 100644 --- a/dashcaddy-api/routes/arr/config.js +++ b/dashcaddy-api/routes/arr/config.js @@ -1,6 +1,7 @@ const express = require('express'); const { APP_PORTS, ARR_SERVICES } = require('../../constants'); const { validateURL, validateToken } = require('../../input-validator'); +const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); module.exports = function(ctx, helpers) { const router = express.Router(); @@ -192,7 +193,7 @@ module.exports = function(ctx, helpers) { const { service, url, apiKey } = req.body; if (!url || !apiKey) { - return ctx.errorResponse(res, 400, 'URL and API key required'); + throw new ValidationError('URL and API key required'); } // Validate URL format @@ -206,7 +207,7 @@ module.exports = function(ctx, helpers) { try { validateToken(apiKey); } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid API key format'); + throw new ValidationError('Invalid API key format'); } // Normalize URL - remove trailing slash @@ -247,9 +248,9 @@ module.exports = function(ctx, helpers) { appName }); } else if (response.status === 401) { - return ctx.errorResponse(res, 401, 'Invalid API key'); + throw new AuthenticationError('Invalid API key'); } else if (response.status === 404) { - return ctx.errorResponse(res, 404, 'API not found - check URL'); + throw new NotFoundError('API not found - check URL'); } else { return ctx.errorResponse(res, 502, `HTTP ${response.status}`); } @@ -484,7 +485,7 @@ module.exports = function(ctx, helpers) { const { service, url, apiKey } = req.query; if (!service || !['radarr', 'sonarr'].includes(service)) { - return ctx.errorResponse(res, 400, 'Service must be radarr or sonarr'); + throw new ValidationError('Service must be radarr or sonarr'); } // Resolve API key: from query param, or from stored credentials @@ -513,7 +514,7 @@ module.exports = function(ctx, helpers) { } if (!resolvedKey || !resolvedUrl) { - return ctx.errorResponse(res, 400, 'Could not resolve API key or URL for this service'); + throw new ValidationError('Could not resolve API key or URL for this service'); } const baseUrl = resolvedUrl.replace(/\/+$/, ''); @@ -553,17 +554,17 @@ module.exports = function(ctx, helpers) { const { service, qualityProfileId, qualityProfileName } = req.body; if (!service || !['radarr', 'sonarr'].includes(service)) { - return ctx.errorResponse(res, 400, 'Service must be radarr or sonarr'); + throw new ValidationError('Service must be radarr or sonarr'); } if (!qualityProfileId) { - return ctx.errorResponse(res, 400, 'qualityProfileId required'); + throw new ValidationError('qualityProfileId required'); } const credKey = `arr.${service}.apikey`; const existing = await ctx.credentialManager.getMetadata(credKey); if (!existing) { - return ctx.errorResponse(res, 404, 'No stored credentials for this service'); + throw new NotFoundError('No stored credentials for this service'); } // Merge quality profile into existing metadata diff --git a/dashcaddy-api/routes/arr/credentials.js b/dashcaddy-api/routes/arr/credentials.js index 27c2a80..4540c8c 100644 --- a/dashcaddy-api/routes/arr/credentials.js +++ b/dashcaddy-api/routes/arr/credentials.js @@ -1,5 +1,6 @@ const express = require('express'); const { validateURL, validateToken } = require('../../input-validator'); +const { ValidationError } = require('../errors'); module.exports = function(ctx, helpers) { const router = express.Router(); @@ -9,7 +10,7 @@ module.exports = function(ctx, helpers) { const { service, apiKey, url, seedboxBaseUrl, qualityProfileId, qualityProfileName } = req.body; if (!service || !apiKey) { - return ctx.errorResponse(res, 400, 'Service name and API key required'); + throw new ValidationError('Service name and API key required'); } const validServices = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex']; @@ -21,13 +22,13 @@ module.exports = function(ctx, helpers) { try { validateToken(apiKey); } catch (e) { - return ctx.errorResponse(res, 400, 'Invalid API key format'); + throw new ValidationError('Invalid API key format'); } // Validate URL if provided if (url) { try { validateURL(url); } catch (e) { - return ctx.errorResponse(res, 400, 'Invalid URL format'); + throw new ValidationError('Invalid URL format'); } } @@ -80,7 +81,7 @@ module.exports = function(ctx, helpers) { // Optionally store seedbox base URL if (seedboxBaseUrl) { try { validateURL(seedboxBaseUrl); } catch (e) { - return ctx.errorResponse(res, 400, 'Invalid seedbox base URL'); + throw new ValidationError('Invalid seedbox base URL'); } await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, { storedAt: new Date().toISOString() diff --git a/dashcaddy-api/routes/auth/keys.js b/dashcaddy-api/routes/auth/keys.js index d1fa933..7246dc4 100644 --- a/dashcaddy-api/routes/auth/keys.js +++ b/dashcaddy-api/routes/auth/keys.js @@ -1,4 +1,5 @@ const express = require('express'); +const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors'); module.exports = function(ctx) { const router = express.Router(); @@ -26,7 +27,7 @@ module.exports = function(ctx) { router.get('/auth/keys', ctx.asyncHandler(async (req, res) => { // Require session authentication (not API key - can't manage keys with key itself) if (!req.auth || req.auth.type !== 'session') { - return ctx.errorResponse(res, 403, 'API key management requires TOTP session authentication'); + throw new ForbiddenError('API key management requires TOTP session authentication'); } const keys = await ctx.authManager.listAPIKeys(); @@ -37,19 +38,19 @@ module.exports = function(ctx) { router.post('/auth/keys', ctx.asyncHandler(async (req, res) => { // Require session authentication if (!req.auth || req.auth.type !== 'session') { - return ctx.errorResponse(res, 403, 'API key generation requires TOTP session authentication'); + throw new ForbiddenError('API key generation requires TOTP session authentication'); } const { name, scopes } = req.body; if (!name || typeof name !== 'string' || name.trim().length === 0) { - return ctx.errorResponse(res, 400, 'API key name is required'); + throw new ValidationError('API key name is required', 'name'); } // Validate scopes if provided const validScopes = ['read', 'write', 'admin']; if (scopes && (!Array.isArray(scopes) || !scopes.every(s => validScopes.includes(s)))) { - return ctx.errorResponse(res, 400, 'Invalid scopes', { validScopes }); + throw new ValidationError(`Invalid scopes. Valid options: ${validScopes.join(', ')}`, 'scopes'); } const keyData = await ctx.authManager.generateAPIKey( @@ -72,13 +73,13 @@ module.exports = function(ctx) { router.delete('/auth/keys/:keyId', ctx.asyncHandler(async (req, res) => { // Require session authentication if (!req.auth || req.auth.type !== 'session') { - return ctx.errorResponse(res, 403, 'API key revocation requires TOTP session authentication'); + throw new ForbiddenError('API key revocation requires TOTP session authentication'); } const { keyId } = req.params; if (!keyId || typeof keyId !== 'string') { - return ctx.errorResponse(res, 400, 'Key ID is required'); + throw new ValidationError('Key ID is required', 'keyId'); } const success = await ctx.authManager.revokeAPIKey(keyId); @@ -86,8 +87,7 @@ module.exports = function(ctx) { if (success) { res.json({ success: true, message: 'API key revoked successfully' }); } else { - const { NotFoundError } = require('../../errors'); - throw new NotFoundError('API key'); + throw new NotFoundError(`API key ${keyId}`); } }, 'auth-keys-revoke')); @@ -95,7 +95,7 @@ module.exports = function(ctx) { router.post('/auth/jwt', ctx.asyncHandler(async (req, res) => { // Require session authentication if (!req.auth || req.auth.type !== 'session') { - return ctx.errorResponse(res, 403, 'JWT generation requires TOTP session authentication'); + throw new ForbiddenError('JWT generation requires TOTP session authentication'); } const { expiresIn, userId } = req.body; @@ -103,7 +103,7 @@ module.exports = function(ctx) { // Validate expiresIn format if provided (e.g., '24h', '7d', '1y') const validExpiresIn = /^(\d+[smhdy])$/.test(expiresIn || '24h'); if (expiresIn && !validExpiresIn) { - return ctx.errorResponse(res, 400, 'Invalid expiresIn format. Use: 60s, 15m, 24h, 7d, 1y'); + throw new ValidationError('Invalid expiresIn format. Use: 60s, 15m, 24h, 7d, 1y', 'expiresIn'); } const token = await ctx.authManager.generateJWT( diff --git a/dashcaddy-api/routes/auth/sso-gate.js b/dashcaddy-api/routes/auth/sso-gate.js index 298898f..455ee8a 100644 --- a/dashcaddy-api/routes/auth/sso-gate.js +++ b/dashcaddy-api/routes/auth/sso-gate.js @@ -1,5 +1,6 @@ const express = require('express'); const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants'); +const { AuthenticationError, NotFoundError } = require('../errors'); module.exports = function(ctx, getAppSession, appSessionCache) { const router = express.Router(); @@ -83,7 +84,7 @@ module.exports = function(ctx, getAppSession, appSessionCache) { const { serviceId } = req.params; if (ctx.totpConfig.enabled && ctx.totpConfig.sessionDuration !== 'never') { - if (!ctx.session.isValid(req)) return ctx.errorResponse(res, 401, 'Not authenticated'); + if (!ctx.session.isValid(req)) throw new AuthenticationError('Not authenticated'); } // Jellyfin/Emby: separate browser-specific token @@ -101,10 +102,10 @@ module.exports = function(ctx, getAppSession, appSessionCache) { try { const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); - if (!username || !password) return ctx.errorResponse(res, 404, '[DC-500] No credentials stored'); + if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); const service = await ctx.getServiceById(serviceId); const baseUrl = service?.url; - if (!baseUrl) return ctx.errorResponse(res, 404, 'No service URL'); + if (!baseUrl) throw new NotFoundError('No service URL'); const mediaAuth = buildMediaAuth(APP.DEVICE_IDS.BROWSER); const authResp = await ctx.fetchT(`${baseUrl}/Users/AuthenticateByName`, { method: 'POST', @@ -141,9 +142,9 @@ module.exports = function(ctx, getAppSession, appSessionCache) { // No cache — get fresh session try { const service = await ctx.getServiceById(serviceId); - if (!service) return ctx.errorResponse(res, 404, 'Service not found'); + if (!service) throw new NotFoundError('Service not found'); const baseUrl = service.externalUrl || service.url; - if (!baseUrl) return ctx.errorResponse(res, 404, 'No service URL'); + if (!baseUrl) throw new NotFoundError('No service URL'); let username, password; if (service.isExternal) { @@ -156,7 +157,7 @@ module.exports = function(ctx, getAppSession, appSessionCache) { password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); } - if (!username || !password) return ctx.errorResponse(res, 404, '[DC-500] No credentials stored'); + if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); const appCookies = await getAppSession(serviceId, baseUrl, username, password); if (appCookies) { diff --git a/dashcaddy-api/routes/auth/totp.js b/dashcaddy-api/routes/auth/totp.js index 450bae7..f927142 100644 --- a/dashcaddy-api/routes/auth/totp.js +++ b/dashcaddy-api/routes/auth/totp.js @@ -1,5 +1,6 @@ const express = require('express'); const { renewCSRFToken } = require('../../csrf-protection'); +const { ValidationError, AuthenticationError } = require('../../errors'); module.exports = function(ctx) { const router = express.Router(); @@ -28,7 +29,7 @@ module.exports = function(ctx) { // Normalize common Base32 confusions: 0→O, 1→L, 8→B secret = secret.replace(/0/g, 'O').replace(/1/g, 'L').replace(/8/g, 'B'); if (!/^[A-Z2-7]{16,}$/.test(secret)) { - return ctx.errorResponse(res, 400, 'Invalid secret key format. Must be a Base32 string (letters A-Z and digits 2-7).'); + throw new ValidationError('Invalid secret key format. Must be a Base32 string (letters A-Z and digits 2-7).', 'secret'); } } else { secret = authenticator.generateSecret(); @@ -50,17 +51,17 @@ module.exports = function(ctx) { const { code } = req.body; if (!code || !/^\d{6}$/.test(code)) { - return ctx.errorResponse(res, 400, 'Invalid code format'); + throw new ValidationError('Invalid code format', 'code'); } const pendingSecret = await ctx.credentialManager.retrieve('totp.pending_secret'); if (!pendingSecret) { - return ctx.errorResponse(res, 400, 'No pending TOTP setup. Call /api/totp/setup first.'); + throw new ValidationError('No pending TOTP setup. Call /api/totp/setup first.'); } authenticator.options = { window: 1 }; if (!authenticator.verify({ token: code, secret: pendingSecret })) { - return ctx.errorResponse(res, 401, '[DC-111] Invalid code. Please try again.'); + throw new AuthenticationError('[DC-111] Invalid code. Please try again.'); } // Promote pending secret to active @@ -87,21 +88,21 @@ module.exports = function(ctx) { const { code } = req.body; if (!code || !/^\d{6}$/.test(code)) { - return ctx.errorResponse(res, 400, 'Invalid code format'); + throw new ValidationError('Invalid code format', 'code'); } if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) { - return ctx.errorResponse(res, 400, 'TOTP is not enabled'); + throw new ValidationError('TOTP is not enabled'); } const secret = await ctx.credentialManager.retrieve('totp.secret'); if (!secret) { - return ctx.errorResponse(res, 500, 'TOTP secret not found'); + throw new Error('TOTP secret not found'); } authenticator.options = { window: 1 }; if (!authenticator.verify({ token: code, secret })) { - return ctx.errorResponse(res, 401, '[DC-111] Invalid code'); + throw new AuthenticationError('[DC-111] Invalid code'); } ctx.log.info('auth', 'TOTP verified, creating session', { ip: ctx.session.getClientIP(req), duration: ctx.totpConfig.sessionDuration }); @@ -131,7 +132,7 @@ module.exports = function(ctx) { return res.status(200).json({ authenticated: true }); } - return ctx.errorResponse(res, 401, 'Session expired or invalid', { authenticated: false }); + throw new AuthenticationError('Session expired or invalid'); }, 'totp-check-session')); // Disable TOTP @@ -141,14 +142,14 @@ module.exports = function(ctx) { // Always require a valid TOTP code when TOTP is active if (ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) { if (!code || !/^\d{6}$/.test(code)) { - return ctx.errorResponse(res, 400, 'A valid TOTP code is required to disable TOTP'); + throw new ValidationError('A valid TOTP code is required to disable TOTP', 'code'); } const { authenticator } = require('otplib'); const secret = await ctx.credentialManager.retrieve('totp.secret'); if (secret) { authenticator.options = { window: 1 }; if (!authenticator.verify({ token: code, secret })) { - return ctx.errorResponse(res, 401, '[DC-111] Invalid code'); + throw new AuthenticationError('[DC-111] Invalid code'); } } } @@ -172,9 +173,7 @@ module.exports = function(ctx) { const { sessionDuration } = req.body; if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) { - return ctx.errorResponse(res, 400, 'Invalid session duration', { - validOptions: Object.keys(ctx.session.durations) - }); + throw new ValidationError(`Invalid session duration. Valid options: ${Object.keys(ctx.session.durations).join(', ')}`, 'sessionDuration'); } if (sessionDuration) { diff --git a/dashcaddy-api/routes/browse.js b/dashcaddy-api/routes/browse.js index 8223b0d..02262a5 100644 --- a/dashcaddy-api/routes/browse.js +++ b/dashcaddy-api/routes/browse.js @@ -82,7 +82,7 @@ module.exports = function(ctx) { ip: req.ip, userAgent: req.get('user-agent') }); - return ctx.errorResponse(res, 403, 'Access denied - path traversal detected'); + throw new ForbiddenError('Access denied - path traversal detected'); } throw error; } @@ -94,7 +94,7 @@ module.exports = function(ctx) { const stats = await fsp.stat(resolvedPath); if (!stats.isDirectory()) { - return ctx.errorResponse(res, 400, 'Path is not a directory'); + throw new ValidationError('Path is not a directory'); } const entries = await fsp.readdir(resolvedPath, { withFileTypes: true }); diff --git a/dashcaddy-api/routes/ca.js b/dashcaddy-api/routes/ca.js index 0597c35..dae2f28 100644 --- a/dashcaddy-api/routes/ca.js +++ b/dashcaddy-api/routes/ca.js @@ -67,7 +67,7 @@ module.exports = function(ctx) { router.get('/install-script', ctx.asyncHandler(async (req, res) => { const platform = (req.query.platform || 'windows').toLowerCase(); if (!['windows', 'linux', 'macos'].includes(platform)) { - return ctx.errorResponse(res, 400, 'Invalid platform. Use: windows, linux, or macos'); + throw new ValidationError('Invalid platform. Use: windows, linux, or macos'); } // Load cert info to get the fingerprint @@ -134,7 +134,7 @@ module.exports = function(ctx) { const { password = 'dashcaddy', format = 'pfx' } = req.query; if (!/^[a-zA-Z0-9!@#%^_+=,.:-]{1,64}$/.test(password)) { - return ctx.errorResponse(res, 400, 'Invalid password. Use only letters, numbers, and basic symbols (max 64 chars).'); + throw new ValidationError('Invalid password. Use only letters, numbers, and basic symbols (max 64 chars).'); } if (!domain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(domain)) { diff --git a/dashcaddy-api/routes/config/assets.js b/dashcaddy-api/routes/config/assets.js index db71aa8..8969d5c 100644 --- a/dashcaddy-api/routes/config/assets.js +++ b/dashcaddy-api/routes/config/assets.js @@ -3,6 +3,7 @@ const fsp = require('fs').promises; const path = require('path'); const { LIMITS } = require('../../constants'); const { exists } = require('../../fs-helpers'); +const { ValidationError } = require('../errors'); // Image processing for favicon conversion (optional) let sharp, pngToIco; @@ -22,19 +23,19 @@ module.exports = function(ctx) { const { filename, data } = req.body; if (!filename || !data) { - return ctx.errorResponse(res, 400, 'filename and data are required'); + throw new ValidationError('filename and data are required'); } // Validate filename to prevent directory traversal const safeFilename = path.basename(filename); if (safeFilename !== filename || filename.includes('..')) { - return ctx.errorResponse(res, 400, 'Invalid filename - must not contain path separators'); + throw new ValidationError('Invalid filename - must not contain path separators'); } // Extract base64 data const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/); if (!matches) { - return ctx.errorResponse(res, 400, 'Invalid image data format'); + throw new ValidationError('Invalid image data format'); } const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1]; @@ -103,7 +104,7 @@ module.exports = function(ctx) { const { data, dataDark, dataLight, position, dashboardTitle } = req.body; if (!data && !dataDark && !dataLight && !position && !dashboardTitle) { - return ctx.errorResponse(res, 400, 'Image data, position, or title is required'); + throw new ValidationError('Image data, position, or title is required'); } const config = await ctx.readConfig(); @@ -112,19 +113,19 @@ module.exports = function(ctx) { // New dual-variant upload if (dataDark) { pathDark = await saveLogoFile(dataDark, 'dark'); - if (!pathDark) return ctx.errorResponse(res, 400, 'Invalid dark logo data format'); + if (!pathDark) throw new ValidationError('Invalid dark logo data format'); config.customLogoDark = pathDark; } if (dataLight) { pathLight = await saveLogoFile(dataLight, 'light'); - if (!pathLight) return ctx.errorResponse(res, 400, 'Invalid light logo data format'); + if (!pathLight) throw new ValidationError('Invalid light logo data format'); config.customLogoLight = pathLight; } // Legacy single-logo: save as both variants if (data && !dataDark && !dataLight) { const singlePath = await saveLogoFile(data, 'dark'); - if (!singlePath) return ctx.errorResponse(res, 400, 'Invalid image data format'); + if (!singlePath) throw new ValidationError('Invalid image data format'); config.customLogoDark = singlePath; config.customLogoLight = singlePath; // Also set legacy field for backward compat @@ -208,7 +209,7 @@ module.exports = function(ctx) { const { data } = req.body; if (!data) { - return ctx.errorResponse(res, 400, 'Image data is required'); + throw new ValidationError('Image data is required'); } if (!sharp || !pngToIco) { @@ -218,7 +219,7 @@ module.exports = function(ctx) { // Extract base64 data const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/); if (!matches) { - return ctx.errorResponse(res, 400, 'Invalid image data format'); + throw new ValidationError('Invalid image data format'); } const imageType = matches[1]; diff --git a/dashcaddy-api/routes/config/backup.js b/dashcaddy-api/routes/config/backup.js index b742e3e..9316332 100644 --- a/dashcaddy-api/routes/config/backup.js +++ b/dashcaddy-api/routes/config/backup.js @@ -3,6 +3,7 @@ const fs = require('fs'); const path = require('path'); const { CADDY } = require('../../constants'); const { exists } = require('../../fs-helpers'); +const { ValidationError, AuthenticationError } = require('../errors'); module.exports = function(ctx) { const express = require('express'); @@ -133,7 +134,7 @@ module.exports = function(ctx) { const backup = req.body; if (!backup || !backup.version || !backup.files) { - return ctx.errorResponse(res, 400, 'Invalid backup file format'); + throw new ValidationError('Invalid backup file format'); } const preview = { @@ -198,7 +199,7 @@ module.exports = function(ctx) { const { backup, options = {}, totpCode } = req.body; if (!backup || !backup.version || !backup.files) { - return ctx.errorResponse(res, 400, 'Invalid backup file format'); + throw new ValidationError('Invalid backup file format'); } // Require TOTP verification for restores that include security-sensitive files @@ -208,14 +209,14 @@ module.exports = function(ctx) { ); if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) { if (!totpCode || !/^\d{6}$/.test(totpCode)) { - return ctx.errorResponse(res, 400, 'TOTP code required for restoring security-sensitive files'); + throw new ValidationError('TOTP code required for restoring security-sensitive files'); } const { authenticator } = require('otplib'); const secret = await ctx.credentialManager.retrieve('totp.secret'); if (secret) { authenticator.options = { window: 1 }; if (!authenticator.verify({ token: totpCode, secret })) { - return ctx.errorResponse(res, 401, '[DC-111] Invalid TOTP code'); + throw new AuthenticationError('[DC-111] Invalid TOTP code'); } } } diff --git a/dashcaddy-api/routes/config/settings.js b/dashcaddy-api/routes/config/settings.js index 7e1480c..ad0161d 100644 --- a/dashcaddy-api/routes/config/settings.js +++ b/dashcaddy-api/routes/config/settings.js @@ -1,6 +1,7 @@ const fsp = require('fs').promises; const { validateConfig } = require('../../config-schema'); const { exists } = require('../../fs-helpers'); +const { ValidationError } = require('../errors'); module.exports = function(ctx) { const express = require('express'); @@ -22,7 +23,7 @@ module.exports = function(ctx) { const incoming = req.body; if (!incoming || typeof incoming !== 'object') { - return ctx.errorResponse(res, 400, 'Invalid config object'); + throw new ValidationError('Invalid config object'); } // Merge with existing config so partial saves don't wipe fields diff --git a/dashcaddy-api/routes/credentials.js b/dashcaddy-api/routes/credentials.js index cd53aef..f042c11 100644 --- a/dashcaddy-api/routes/credentials.js +++ b/dashcaddy-api/routes/credentials.js @@ -23,7 +23,7 @@ module.exports = function({ credentialManager, asyncHandler }) { if (rotateSuccess) { success(res, { message: 'Encryption key rotated, all credentials re-encrypted' }); } else { - errorResponse(res, 'Key rotation failed', 500); + // Error handled by middleware } }, 'credentials-rotate')); diff --git a/dashcaddy-api/routes/dns.js b/dashcaddy-api/routes/dns.js index b124484..3228bf6 100644 --- a/dashcaddy-api/routes/dns.js +++ b/dashcaddy-api/routes/dns.js @@ -5,6 +5,7 @@ const validatorLib = require('validator'); const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants'); const { exists } = require('../fs-helpers'); const { success, error: errorResponse } = require('../response-helpers'); +const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); /** * DNS routes factory @@ -47,27 +48,27 @@ module.exports = function({ const dnsToken = await dns.requireToken(token); if (!domain) { - return errorResponse(res, 'domain is required', 400); + throw new ValidationError('domain is required'); } // Validate domain format if (!REGEX.DOMAIN.test(domain)) { - return errorResponse(res, '[DC-301] Invalid domain format', 400); + throw new ValidationError('[DC-301] Invalid domain format'); } // Validate record type if (type && !DNS_RECORD_TYPES.includes(type.toUpperCase())) { - return errorResponse(res, 'Invalid DNS record type', 400); + throw new ValidationError('Invalid DNS record type'); } // Validate ipAddress if provided if (ipAddress && !validatorLib.isIP(ipAddress)) { - return errorResponse(res, '[DC-210] Invalid IP address', 400); + throw new ValidationError('[DC-210] Invalid IP address'); } // Validate server against configured DNS servers if (server && !validateDnsServer(server)) { - return errorResponse(res, 'Server must be a configured DNS server', 400); + throw new ValidationError('Server must be a configured DNS server'); } // Default to dns1 LAN IP, allow override @@ -82,10 +83,10 @@ module.exports = function({ if (result.status === 'ok') { success(res, { message: `DNS record ${domain} deleted` }); } else { - errorResponse(res, result.errorMessage || 'DNS deletion failed', 500); + // Error handled by middleware } } catch (error) { - errorResponse(res, safeErrorMessage(error), 500); + // Error handled by middleware } }, 'dns-delete-record')); @@ -96,17 +97,17 @@ module.exports = function({ const dnsToken = await dns.requireToken(token); if (!domain || !ip) { - return errorResponse(res, 'domain and ip are required', 400); + throw new ValidationError('domain and ip are required'); } // Validate domain format if (!REGEX.DOMAIN.test(domain)) { - return errorResponse(res, '[DC-301] Invalid domain format', 400); + throw new ValidationError('[DC-301] Invalid domain format'); } // Validate IP address if (!validatorLib.isIP(ip)) { - return errorResponse(res, '[DC-210] Invalid IP address', 400); + throw new ValidationError('[DC-210] Invalid IP address'); } // Validate TTL if provided @@ -119,7 +120,7 @@ module.exports = function({ // Validate server against configured DNS servers if (server && !validateDnsServer(server)) { - return errorResponse(res, 'Server must be a configured DNS server', 400); + throw new ValidationError('Server must be a configured DNS server'); } // Default to dns1 LAN IP since Docker container can't access Tailscale network @@ -140,7 +141,7 @@ module.exports = function({ if (result.status === 'ok') { success(res, { message: `DNS record ${domain} -> ${ip} created` }); } else { - errorResponse(res, result.errorMessage || 'DNS creation failed', 500); + // Error handled by middleware } } catch (error) { log.error('dns', 'DNS record creation error', { error: error.message }); @@ -155,17 +156,17 @@ module.exports = function({ const dnsToken = await dns.requireToken(token); if (!domain) { - return errorResponse(res, 'domain is required', 400); + throw new ValidationError('domain is required'); } // Validate domain format if (!REGEX.DOMAIN.test(domain)) { - return errorResponse(res, '[DC-301] Invalid domain format', 400); + throw new ValidationError('[DC-301] Invalid domain format'); } // Validate server against configured DNS servers if (server && !validateDnsServer(server)) { - return errorResponse(res, 'Server must be a configured DNS server', 400); + throw new ValidationError('Server must be a configured DNS server'); } const dnsServer = server || siteConfig.dnsServerIp; @@ -182,14 +183,14 @@ module.exports = function({ const ipAddresses = aRecords.map(r => r.rData?.ipAddress).filter(Boolean); success(res, { answer: ipAddresses }); } else { - errorResponse(res, 'No A records found for domain', 404); + throw new NotFoundError('No A records found for domain'); } } else { - errorResponse(res, result.errorMessage || 'DNS resolve failed', 500); + // Error handled by middleware } } catch (error) { log.error('dns', 'DNS resolve error', { error: error.message }); - errorResponse(res, safeErrorMessage(error), 500); + // Error handled by middleware } }, 'dns-resolve')); @@ -198,13 +199,13 @@ module.exports = function({ const { server, limit } = req.query; if (!server) { - return errorResponse(res, 'server is required', 400); + throw new ValidationError('server is required'); } // Validate server against configured DNS servers const serverIp = validateDnsServer(server); if (!serverIp) { - return errorResponse(res, 'Server must be a configured DNS server', 400); + throw new ValidationError('Server must be a configured DNS server'); } const logLimit = Math.min(parseInt(limit) || 25, 1000); @@ -214,7 +215,7 @@ module.exports = function({ // Auto-authenticate using stored read-only credentials for log access const authResult = await dns.getTokenForServer(serverIp, 'readonly'); if (!authResult.success) { - return errorResponse(res, 'DNS auto-authentication failed. Ensure credentials are configured via the DNS panel.', 401); + throw new AuthenticationError('DNS auto-authentication failed. Ensure credentials are configured via the DNS panel.'); } const effectiveToken = authResult.token; @@ -322,7 +323,7 @@ module.exports = function({ } catch (error) { log.error('dns', 'DNS logs proxy error', { error: error.message }); - errorResponse(res, safeErrorMessage(error), 500); + // Error handled by middleware } }, 'dns-logs')); @@ -417,19 +418,19 @@ module.exports = function({ // Legacy single-credential format: { username, password, server } if (!username || !password) { - return errorResponse(res, 'username and password are required', 400); + throw new ValidationError('username and password are required'); } if (username.length > 100 || password.length > 512) { - return errorResponse(res, 'Credentials exceed maximum length', 400); + throw new ValidationError('Credentials exceed maximum length'); } if (dangerousChars.some(char => username.includes(char))) { - return errorResponse(res, 'Username contains invalid characters', 400); + throw new ValidationError('Username contains invalid characters'); } if (server && !validateDnsServer(server)) { - return errorResponse(res, 'Server must be a configured DNS server', 400); + throw new ValidationError('Server must be a configured DNS server'); } const testResult = await dns.refresh(username, password, server || siteConfig.dnsServerIp); @@ -484,7 +485,7 @@ module.exports = function({ const tokenResult = await dns.getTokenForServer(serverInfo.ip, 'admin'); if (!tokenResult.success) { - return errorResponse(res, 'DNS admin authentication failed. Ensure admin credentials are configured.', 401); + throw new AuthenticationError('DNS admin authentication failed. Ensure admin credentials are configured.'); } const dnsPort = siteConfig.dnsServerPort || '5380'; @@ -495,7 +496,7 @@ module.exports = function({ if (result.status === 'ok') { success(res, { message: 'Restart initiated' }); } else { - errorResponse(res, result.errorMessage || 'Restart failed', 500); + // Error handled by middleware } } catch (err) { // Connection drop is expected during restart @@ -522,18 +523,18 @@ module.exports = function({ try { const { server } = req.query; if (!server) { - return errorResponse(res, 'Server IP required', 400); + throw new ValidationError('Server IP required'); } const serverIp = validateDnsServer(server); if (!serverIp) { - return errorResponse(res, 'Server must be a configured DNS server', 400); + throw new ValidationError('Server must be a configured DNS server'); } // Authenticate with admin credentials for update check const tokenResult = await dns.getTokenForServer(serverIp, 'admin'); if (!tokenResult.success) { - return errorResponse(res, 'DNS authentication failed. Ensure credentials are configured.', 401); + throw new AuthenticationError('DNS authentication failed. Ensure credentials are configured.'); } const dnsPort = siteConfig.dnsServerPort || '5380'; @@ -551,7 +552,7 @@ module.exports = function({ const text = await response.text(); if (!text || text.trim() === '') { - return errorResponse(res, 'Empty response from DNS server', 500); + return // Error handled by middleware } const result = JSON.parse(text); @@ -567,11 +568,11 @@ module.exports = function({ instructionsLink: result.response.instructionsLink || null }); } else { - errorResponse(res, result.errorMessage || 'Check failed', 500); + // Error handled by middleware } } catch (error) { log.error('dns', 'DNS update check error', { error: error.message }); - errorResponse(res, safeErrorMessage(error), 500); + // Error handled by middleware } }, 'dns-check-update')); @@ -582,18 +583,18 @@ module.exports = function({ try { const { server } = req.query; if (!server) { - return errorResponse(res, 'Server IP required', 400); + throw new ValidationError('Server IP required'); } const serverIp = validateDnsServer(server); if (!serverIp) { - return errorResponse(res, 'Server must be a configured DNS server', 400); + throw new ValidationError('Server must be a configured DNS server'); } // Authenticate with admin credentials for update operations const tokenResult = await dns.getTokenForServer(serverIp, 'admin'); if (!tokenResult.success) { - return errorResponse(res, 'DNS authentication failed. Ensure credentials are configured.', 401); + throw new AuthenticationError('DNS authentication failed. Ensure credentials are configured.'); } const dnsPort = siteConfig.dnsServerPort || '5380'; @@ -605,12 +606,12 @@ module.exports = function({ const checkText = await checkResponse.text(); if (!checkText || checkText.trim() === '') { - return errorResponse(res, 'Empty response from DNS server during check', 500); + return // Error handled by middleware } const checkResult = JSON.parse(checkText); if (checkResult.status !== 'ok') { - return errorResponse(res, checkResult.errorMessage || 'Update check failed', 500); + return // Error handled by middleware } if (!checkResult.response.updateAvailable) { @@ -636,7 +637,7 @@ module.exports = function({ }); } catch (error) { log.error('dns', 'DNS update error', { error: error.message }); - errorResponse(res, safeErrorMessage(error), 500); + // Error handled by middleware } }, 'dns-update')); diff --git a/dashcaddy-api/routes/health.js b/dashcaddy-api/routes/health.js index 106f20d..fb8c82e 100644 --- a/dashcaddy-api/routes/health.js +++ b/dashcaddy-api/routes/health.js @@ -229,7 +229,7 @@ module.exports = function({ router.get('/health/probe', asyncHandler(async (req, res) => { const targetUrl = req.query.url; if (!targetUrl) { - return errorResponse(res, 'Missing ?url= parameter', 400); + throw new ValidationError('Missing ?url= parameter'); } const result = await checkDirect(targetUrl); res.json(result || { diff --git a/dashcaddy-api/routes/license.js b/dashcaddy-api/routes/license.js index 43d5ba9..18b716a 100644 --- a/dashcaddy-api/routes/license.js +++ b/dashcaddy-api/routes/license.js @@ -1,5 +1,6 @@ const express = require('express'); const { success, error: errorResponse } = require('../response-helpers'); +const { ValidationError } = require('../errors'); /** * License routes factory @@ -15,7 +16,7 @@ module.exports = function({ licenseManager, asyncHandler }) { router.post('/activate', asyncHandler(async (req, res) => { const { code } = req.body; if (!code) { - return errorResponse(res, 'License code is required', 400); + throw new ValidationError('License code is required'); } const result = await licenseManager.activate(code); diff --git a/dashcaddy-api/routes/logs.js b/dashcaddy-api/routes/logs.js index e59e13d..e062c75 100644 --- a/dashcaddy-api/routes/logs.js +++ b/dashcaddy-api/routes/logs.js @@ -175,7 +175,7 @@ module.exports = function(ctx) { if (!ctx.logDigest) return ctx.errorResponse(res, 503, 'Log digest not available'); const { date } = req.params; if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { - return ctx.errorResponse(res, 400, 'Invalid date format. Use YYYY-MM-DD.'); + throw new ValidationError('Invalid date format. Use YYYY-MM-DD.'); } const format = req.query.format || 'json'; if (format === 'text') { @@ -209,7 +209,7 @@ module.exports = function(ctx) { const { path: logPath, tail = 100 } = req.query; if (!logPath) { - return ctx.errorResponse(res, 400, 'Log path is required'); + throw new ValidationError('Log path is required'); } const platformPaths = require('../platform-paths'); @@ -233,7 +233,7 @@ module.exports = function(ctx) { }); if (!isAllowed) { - return ctx.errorResponse(res, 403, 'Access to this log path is not allowed'); + throw new ForbiddenError('Access to this log path is not allowed'); } if (!await exists(resolvedPath)) { diff --git a/dashcaddy-api/routes/notifications.js b/dashcaddy-api/routes/notifications.js index d8b77ad..973ff66 100644 --- a/dashcaddy-api/routes/notifications.js +++ b/dashcaddy-api/routes/notifications.js @@ -1,6 +1,7 @@ const express = require('express'); const { validateURL, validateToken } = require('../input-validator'); const { paginate, parsePaginationParams } = require('../pagination'); +const { ValidationError } = require('../errors'); module.exports = function(ctx) { const router = express.Router(); @@ -43,27 +44,27 @@ module.exports = function(ctx) { try { validateURL(providers.discord.webhookUrl); } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL'); + throw new ValidationError('Invalid Discord webhook URL'); } } if (providers.telegram?.botToken) { try { validateToken(providers.telegram.botToken); } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format'); + throw new ValidationError('Invalid Telegram bot token format'); } } if (providers.ntfy?.serverUrl) { try { validateURL(providers.ntfy.serverUrl); } catch (validationErr) { - return ctx.errorResponse(res, 400, 'Invalid ntfy server URL'); + throw new ValidationError('Invalid ntfy server URL'); } } if (providers.ntfy?.topic) { const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/; if (!topicRegex.test(providers.ntfy.topic)) { - return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)'); + throw new ValidationError('Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)'); } } } @@ -137,7 +138,7 @@ module.exports = function(ctx) { result = await ctx.notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info'); break; default: - return ctx.errorResponse(res, 400, 'Unknown provider'); + throw new ValidationError('Unknown provider'); } res.json({ success: result.success, provider, error: result.error }); } else { diff --git a/dashcaddy-api/routes/recipes/deploy.js b/dashcaddy-api/routes/recipes/deploy.js index 2111d90..19d3a54 100644 --- a/dashcaddy-api/routes/recipes/deploy.js +++ b/dashcaddy-api/routes/recipes/deploy.js @@ -1,4 +1,5 @@ const express = require('express'); +const { ValidationError } = require('../../errors'); const crypto = require('crypto'); const { DOCKER } = require('../../constants'); @@ -16,7 +17,7 @@ module.exports = function(ctx) { const { RECIPE_TEMPLATES } = require('../../recipe-templates'); const recipe = RECIPE_TEMPLATES[recipeId]; - if (!recipe) return ctx.errorResponse(res, 400, 'Invalid recipe template'); + if (!recipe) throw new ValidationError('Invalid recipe template', 'recipeId'); ctx.log.info('recipe', 'Starting recipe deployment', { recipeId, name: recipe.name }); @@ -165,7 +166,7 @@ module.exports = function(ctx) { `Failed to deploy **${recipe.name}**: ${error.message}`, 'error' ); - ctx.errorResponse(res, 500, error.message); + // Error automatically handled by middleware } }, 'recipe-deploy')); diff --git a/dashcaddy-api/routes/recipes/index.js b/dashcaddy-api/routes/recipes/index.js index ed8b415..1c3f86d 100644 --- a/dashcaddy-api/routes/recipes/index.js +++ b/dashcaddy-api/routes/recipes/index.js @@ -1,6 +1,7 @@ const express = require('express'); const deployRoutes = require('./deploy'); const manageRoutes = require('./manage'); +const { NotFoundError } = require('../../errors'); module.exports = function(ctx) { const router = express.Router(); @@ -41,7 +42,7 @@ module.exports = function(ctx) { router.get('/templates/:recipeId', ctx.asyncHandler(async (req, res) => { const { RECIPE_TEMPLATES } = require('../../recipe-templates'); const recipe = RECIPE_TEMPLATES[req.params.recipeId]; - if (!recipe) return ctx.errorResponse(res, 404, 'Recipe template not found'); + if (!recipe) throw new NotFoundError(`Recipe template ${req.params.recipeId}`); res.json({ success: true, recipe: { id: req.params.recipeId, ...recipe } }); }, 'recipe-template-detail')); diff --git a/dashcaddy-api/routes/recipes/manage.js b/dashcaddy-api/routes/recipes/manage.js index 135da68..7520961 100644 --- a/dashcaddy-api/routes/recipes/manage.js +++ b/dashcaddy-api/routes/recipes/manage.js @@ -1,5 +1,6 @@ const express = require('express'); const { DOCKER } = require('../../constants'); +const { NotFoundError } = require('../../errors'); module.exports = function(ctx) { const router = express.Router(); @@ -96,7 +97,7 @@ module.exports = function(ctx) { const containers = await findRecipeContainers(recipeId); if (containers.length === 0) { - return ctx.errorResponse(res, 404, 'No containers found for this recipe'); + throw new NotFoundError('Containers for recipe'); } const results = []; @@ -127,7 +128,7 @@ module.exports = function(ctx) { const containers = await findRecipeContainers(recipeId); if (containers.length === 0) { - return ctx.errorResponse(res, 404, 'No containers found for this recipe'); + throw new NotFoundError('Containers for recipe'); } const results = []; @@ -159,7 +160,7 @@ module.exports = function(ctx) { const containers = await findRecipeContainers(recipeId); if (containers.length === 0) { - return ctx.errorResponse(res, 404, 'No containers found for this recipe'); + throw new NotFoundError('Containers for recipe'); } const results = []; @@ -185,7 +186,7 @@ module.exports = function(ctx) { const containers = await findRecipeContainers(recipeId); if (containers.length === 0) { - return ctx.errorResponse(res, 404, 'No containers found for this recipe'); + throw new NotFoundError('Containers for recipe'); } ctx.log.info('recipe', 'Removing recipe', { recipeId, containerCount: containers.length }); diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index 2ff8a66..62ced94 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -244,7 +244,7 @@ module.exports = function({ router.post('/seedhost-creds', asyncHandler(async (req, res) => { const { username, password, serviceId } = req.body; if (!username) { - return errorResponse(res, 'Username required', 400); + throw new ValidationError('Username required'); } await credentialManager.store('seedhost.username', username); if (password) { @@ -361,7 +361,7 @@ module.exports = function({ const { id, name, logo } = req.body; if (!id || !name) { - return errorResponse(res, 'id and name are required', 400); + throw new ValidationError('id and name are required'); } // Validate service configuration @@ -388,7 +388,7 @@ module.exports = function({ if (error.message.includes('already exists')) { errorResponse(res, safeErrorMessage(error), 409); } else { - errorResponse(res, safeErrorMessage(error), 500); + // Error handled by middleware } } }, 'services-update')); @@ -398,12 +398,12 @@ module.exports = function({ const services = req.body; if (!Array.isArray(services)) { - return errorResponse(res, 'Request body must be an array of services', 400); + throw new ValidationError('Request body must be an array of services'); } for (const service of services) { if (!service.id || !service.name) { - return errorResponse(res, 'Each service must have id and name fields', 400); + throw new ValidationError('Each service must have id and name fields'); } try { validateServiceConfig(service); @@ -426,7 +426,7 @@ module.exports = function({ const { id } = req.params; if (!await exists(SERVICES_FILE)) { - return errorResponse(res, 'No services found', 404); + throw new NotFoundError('No services found'); } let found = false; @@ -450,19 +450,19 @@ module.exports = function({ const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = req.body; if (!oldSubdomain || !newSubdomain) { - return errorResponse(res, 'oldSubdomain and newSubdomain are required', 400); + throw new ValidationError('oldSubdomain and newSubdomain are required'); } if (!REGEX.SUBDOMAIN.test(oldSubdomain) || !REGEX.SUBDOMAIN.test(newSubdomain)) { - return errorResponse(res, '[DC-301] Invalid subdomain format', 400); + throw new ValidationError('[DC-301] Invalid subdomain format'); } if (port && !isValidPort(port)) { - return errorResponse(res, 'Invalid port number (must be 1-65535)', 400); + throw new ValidationError('Invalid port number (must be 1-65535)'); } if (ip && ip !== 'localhost' && !validatorLib.isIP(ip)) { - return errorResponse(res, '[DC-210] Invalid IP address', 400); + throw new ValidationError('[DC-210] Invalid IP address'); } const results = { dns: null, caddy: null, services: null }; diff --git a/dashcaddy-api/routes/sites.js b/dashcaddy-api/routes/sites.js index 65762dd..da2e966 100644 --- a/dashcaddy-api/routes/sites.js +++ b/dashcaddy-api/routes/sites.js @@ -1,6 +1,7 @@ const express = require('express'); const fs = require('fs'); const { CADDY, REGEX, LIMITS } = require('../constants'); +const { ValidationError } = require('../errors'); module.exports = function(ctx) { const router = express.Router(); @@ -118,7 +119,7 @@ module.exports = function(ctx) { // Remove a site from Caddyfile router.delete('/site/:domain', ctx.asyncHandler(async (req, res) => { const { domain } = req.params; - if (!domain) return ctx.errorResponse(res, 400, 'Domain is required'); + if (!domain) throw new ValidationError('Domain is required'); const result = await ctx.caddy.modify((content) => { const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -143,11 +144,11 @@ module.exports = function(ctx) { // Add a new site to Caddyfile and reload router.post('/site', ctx.asyncHandler(async (req, res) => { const { domain, upstream, config } = req.body; - if (!domain || !upstream) return ctx.errorResponse(res, 400, 'Domain and upstream are required'); - if (!REGEX.DOMAIN.test(domain)) return ctx.errorResponse(res, 400, '[DC-301] Invalid domain format'); + if (!domain || !upstream) throw new ValidationError('Domain and upstream are required'); + if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format'); const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i; - if (!upstreamRegex.test(upstream)) return ctx.errorResponse(res, 400, 'Invalid upstream format. Use host:port'); + if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port'); let content = await ctx.caddy.read(); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -173,10 +174,10 @@ module.exports = function(ctx) { const { subdomain, externalUrl, preserveHost, followRedirects, sslType, caddyfilePath, reloadCaddy: shouldReload, createDns, serviceName, logo } = req.body; if (!subdomain || !externalUrl) { - return ctx.errorResponse(res, 400, 'Subdomain and externalUrl are required'); + throw new ValidationError('Subdomain and externalUrl are required'); } if (!REGEX.SUBDOMAIN.test(subdomain)) { - return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format'); + throw new ValidationError('[DC-301] Invalid subdomain format'); } try { @@ -207,7 +208,7 @@ module.exports = function(ctx) { // Validate URL components are safe for Caddyfile syntax const unsafeCaddyChars = /[{}\n\r]/; if (unsafeCaddyChars.test(urlObj.host) || unsafeCaddyChars.test(urlObj.pathname)) { - return ctx.errorResponse(res, 400, 'External URL contains characters not safe for Caddy configuration'); + throw new ValidationError('External URL contains characters not safe for Caddy configuration'); } const baseUrl = `${urlObj.protocol}//${urlObj.host}`; diff --git a/dashcaddy-api/routes/tailscale.js b/dashcaddy-api/routes/tailscale.js index 07b807b..6c75f86 100644 --- a/dashcaddy-api/routes/tailscale.js +++ b/dashcaddy-api/routes/tailscale.js @@ -126,7 +126,7 @@ module.exports = function(ctx) { const { subdomain, tailscaleOnly, allowedIPs } = req.body; if (!subdomain) { - return ctx.errorResponse(res, 400, 'subdomain is required'); + throw new ValidationError('subdomain is required'); } let content = await ctx.caddy.read(); @@ -142,7 +142,7 @@ module.exports = function(ctx) { const proxyMatch = match[0].match(/reverse_proxy\s+([^\s\n]+)/); if (!proxyMatch) { - return ctx.errorResponse(res, 400, 'Could not parse service configuration'); + throw new ValidationError('Could not parse service configuration'); } const [ip, port] = proxyMatch[1].split(':'); @@ -181,7 +181,7 @@ module.exports = function(ctx) { const { clientId, clientSecret, tailnet } = req.body; if (!clientId || !clientSecret || !tailnet) { - return ctx.errorResponse(res, 400, 'clientId, clientSecret, and tailnet are required'); + throw new ValidationError('clientId, clientSecret, and tailnet are required'); } // Validate by exchanging for a real token @@ -252,7 +252,7 @@ module.exports = function(ctx) { // Get enriched device list from Tailscale API router.get('/api-devices', ctx.asyncHandler(async (req, res) => { if (!ctx.tailscale.config.oauthConfigured) { - return ctx.errorResponse(res, 400, 'Tailscale API not configured. Set up OAuth first.'); + throw new ValidationError('Tailscale API not configured. Set up OAuth first.'); } // Return cached devices from last sync @@ -266,7 +266,7 @@ module.exports = function(ctx) { // Manually trigger an API sync router.post('/sync', ctx.asyncHandler(async (req, res) => { if (!ctx.tailscale.config.oauthConfigured) { - return ctx.errorResponse(res, 400, 'Tailscale API not configured. Set up OAuth first.'); + throw new ValidationError('Tailscale API not configured. Set up OAuth first.'); } const devices = await ctx.tailscale.syncAPI(); @@ -283,7 +283,7 @@ module.exports = function(ctx) { const token = await ctx.tailscale.getAccessToken(); const tailnet = ctx.tailscale.config.tailnet; if (!token || !tailnet) { - return ctx.errorResponse(res, 400, 'Tailscale API not configured'); + throw new ValidationError('Tailscale API not configured'); } const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, { diff --git a/dashcaddy-api/routes/updates.js b/dashcaddy-api/routes/updates.js index 5b5eeb1..2fa22b2 100644 --- a/dashcaddy-api/routes/updates.js +++ b/dashcaddy-api/routes/updates.js @@ -1,5 +1,6 @@ const express = require('express'); const { paginate, parsePaginationParams } = require('../pagination'); +const { ValidationError } = require('../errors'); module.exports = function(ctx) { const router = express.Router(); @@ -53,7 +54,7 @@ module.exports = function(ctx) { router.post('/updates/schedule/:containerId', ctx.asyncHandler(async (req, res) => { const { scheduledTime } = req.body; if (!scheduledTime) { - return ctx.errorResponse(res, 400, 'scheduledTime is required'); + throw new ValidationError('scheduledTime is required'); } ctx.updateManager.scheduleUpdate(req.params.containerId, scheduledTime); res.json({ success: true, message: 'Update scheduled', scheduledTime }); @@ -116,7 +117,7 @@ module.exports = function(ctx) { // Rollback to a previous version router.post('/system/rollback', ctx.asyncHandler(async (req, res) => { const { version } = req.body; - if (!version) return ctx.errorResponse(res, 400, 'version is required'); + if (!version) throw new ValidationError('version is required'); ctx.selfUpdater.rollbackToVersion(version).catch(err => { ctx.logError('self-rollback', err); });