Migrate 25 route files to throw-based error handling

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
This commit is contained in:
Krystie
2026-03-29 18:53:03 -07:00
parent 64a0018d00
commit b172a21b63
25 changed files with 168 additions and 154 deletions

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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

View File

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

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 });

View File

@@ -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)) {

View File

@@ -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];

View File

@@ -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');
}
}
}

View File

@@ -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

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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 || {

View File

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

View File

@@ -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)) {

View File

@@ -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 {

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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 });

View File

@@ -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 };

View File

@@ -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}`;

View File

@@ -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`, {

View File

@@ -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);
});