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:
@@ -6,6 +6,7 @@ const { REGEX, DOCKER } = require('../../constants');
|
|||||||
const { isValidPort } = require('../../input-validator');
|
const { isValidPort } = require('../../input-validator');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../fs-helpers');
|
||||||
const platformPaths = require('../../platform-paths');
|
const platformPaths = require('../../platform-paths');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx, helpers) {
|
module.exports = function(ctx, helpers) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -190,7 +191,7 @@ module.exports = function(ctx, helpers) {
|
|||||||
router.post('/apps/check-existing', ctx.asyncHandler(async (req, res) => {
|
router.post('/apps/check-existing', ctx.asyncHandler(async (req, res) => {
|
||||||
const { appId } = req.body;
|
const { appId } = req.body;
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
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);
|
const existingContainer = await helpers.findExistingContainerByImage(template);
|
||||||
if (existingContainer) {
|
if (existingContainer) {
|
||||||
res.json({ success: true, exists: true, container: existingContainer, message: `Found existing ${template.name} container: ${existingContainer.name}` });
|
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) => {
|
router.post('/apps/deploy', ctx.asyncHandler(async (req, res) => {
|
||||||
const { appId, config } = req.body;
|
const { appId, config } = req.body;
|
||||||
if (!appId || typeof appId !== 'string') {
|
if (!appId || typeof appId !== 'string') {
|
||||||
return ctx.errorResponse(res, 400, 'appId is required');
|
throw new ValidationError('appId is required');
|
||||||
}
|
}
|
||||||
if (!config || typeof config !== 'object') {
|
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') {
|
if (!config.subdomain || typeof config.subdomain !== 'string') {
|
||||||
return ctx.errorResponse(res, 400, 'config.subdomain is required');
|
throw new ValidationError('config.subdomain is required');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ctx.log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
|
ctx.log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
if (!template) {
|
if (!template) {
|
||||||
await ctx.logError('app-deploy', new Error('Invalid app template'), { appId, config });
|
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 (config.subdomain) {
|
||||||
if (!REGEX.SUBDOMAIN.test(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
|
// Block reserved path names in subdirectory mode
|
||||||
if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) {
|
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)) {
|
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) {
|
if (!template.isStaticSite) {
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ module.exports = function(ctx, helpers) {
|
|||||||
router.post('/apps/update-subdomain', ctx.asyncHandler(async (req, res) => {
|
router.post('/apps/update-subdomain', ctx.asyncHandler(async (req, res) => {
|
||||||
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
|
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
|
||||||
if (!oldSubdomain || typeof oldSubdomain !== 'string') {
|
if (!oldSubdomain || typeof oldSubdomain !== 'string') {
|
||||||
return ctx.errorResponse(res, 400, 'oldSubdomain is required');
|
throw new ValidationError('oldSubdomain is required');
|
||||||
}
|
}
|
||||||
if (!newSubdomain || typeof newSubdomain !== 'string') {
|
if (!newSubdomain || typeof newSubdomain !== 'string') {
|
||||||
return ctx.errorResponse(res, 400, 'newSubdomain is required');
|
throw new ValidationError('newSubdomain is required');
|
||||||
}
|
}
|
||||||
if (!REGEX.SUBDOMAIN.test(newSubdomain)) {
|
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 });
|
ctx.log.info('deploy', 'Updating subdomain', { oldSubdomain, newSubdomain });
|
||||||
const results = { oldDns: null, newDns: null, caddy: null, service: null };
|
const results = { oldDns: null, newDns: null, caddy: null, service: null };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { APP_PORTS, ARR_SERVICES } = require('../../constants');
|
const { APP_PORTS, ARR_SERVICES } = require('../../constants');
|
||||||
const { validateURL, validateToken } = require('../../input-validator');
|
const { validateURL, validateToken } = require('../../input-validator');
|
||||||
|
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx, helpers) {
|
module.exports = function(ctx, helpers) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -192,7 +193,7 @@ module.exports = function(ctx, helpers) {
|
|||||||
const { service, url, apiKey } = req.body;
|
const { service, url, apiKey } = req.body;
|
||||||
|
|
||||||
if (!url || !apiKey) {
|
if (!url || !apiKey) {
|
||||||
return ctx.errorResponse(res, 400, 'URL and API key required');
|
throw new ValidationError('URL and API key required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate URL format
|
// Validate URL format
|
||||||
@@ -206,7 +207,7 @@ module.exports = function(ctx, helpers) {
|
|||||||
try {
|
try {
|
||||||
validateToken(apiKey);
|
validateToken(apiKey);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid API key format');
|
throw new ValidationError('Invalid API key format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize URL - remove trailing slash
|
// Normalize URL - remove trailing slash
|
||||||
@@ -247,9 +248,9 @@ module.exports = function(ctx, helpers) {
|
|||||||
appName
|
appName
|
||||||
});
|
});
|
||||||
} else if (response.status === 401) {
|
} else if (response.status === 401) {
|
||||||
return ctx.errorResponse(res, 401, 'Invalid API key');
|
throw new AuthenticationError('Invalid API key');
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
return ctx.errorResponse(res, 404, 'API not found - check URL');
|
throw new NotFoundError('API not found - check URL');
|
||||||
} else {
|
} else {
|
||||||
return ctx.errorResponse(res, 502, `HTTP ${response.status}`);
|
return ctx.errorResponse(res, 502, `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -484,7 +485,7 @@ module.exports = function(ctx, helpers) {
|
|||||||
const { service, url, apiKey } = req.query;
|
const { service, url, apiKey } = req.query;
|
||||||
|
|
||||||
if (!service || !['radarr', 'sonarr'].includes(service)) {
|
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
|
// Resolve API key: from query param, or from stored credentials
|
||||||
@@ -513,7 +514,7 @@ module.exports = function(ctx, helpers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resolvedKey || !resolvedUrl) {
|
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(/\/+$/, '');
|
const baseUrl = resolvedUrl.replace(/\/+$/, '');
|
||||||
@@ -553,17 +554,17 @@ module.exports = function(ctx, helpers) {
|
|||||||
const { service, qualityProfileId, qualityProfileName } = req.body;
|
const { service, qualityProfileId, qualityProfileName } = req.body;
|
||||||
|
|
||||||
if (!service || !['radarr', 'sonarr'].includes(service)) {
|
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) {
|
if (!qualityProfileId) {
|
||||||
return ctx.errorResponse(res, 400, 'qualityProfileId required');
|
throw new ValidationError('qualityProfileId required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const credKey = `arr.${service}.apikey`;
|
const credKey = `arr.${service}.apikey`;
|
||||||
const existing = await ctx.credentialManager.getMetadata(credKey);
|
const existing = await ctx.credentialManager.getMetadata(credKey);
|
||||||
|
|
||||||
if (!existing) {
|
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
|
// Merge quality profile into existing metadata
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { validateURL, validateToken } = require('../../input-validator');
|
const { validateURL, validateToken } = require('../../input-validator');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx, helpers) {
|
module.exports = function(ctx, helpers) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -9,7 +10,7 @@ module.exports = function(ctx, helpers) {
|
|||||||
const { service, apiKey, url, seedboxBaseUrl, qualityProfileId, qualityProfileName } = req.body;
|
const { service, apiKey, url, seedboxBaseUrl, qualityProfileId, qualityProfileName } = req.body;
|
||||||
|
|
||||||
if (!service || !apiKey) {
|
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'];
|
const validServices = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex'];
|
||||||
@@ -21,13 +22,13 @@ module.exports = function(ctx, helpers) {
|
|||||||
try {
|
try {
|
||||||
validateToken(apiKey);
|
validateToken(apiKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid API key format');
|
throw new ValidationError('Invalid API key format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate URL if provided
|
// Validate URL if provided
|
||||||
if (url) {
|
if (url) {
|
||||||
try { validateURL(url); } catch (e) {
|
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
|
// Optionally store seedbox base URL
|
||||||
if (seedboxBaseUrl) {
|
if (seedboxBaseUrl) {
|
||||||
try { validateURL(seedboxBaseUrl); } catch (e) {
|
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, {
|
await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, {
|
||||||
storedAt: new Date().toISOString()
|
storedAt: new Date().toISOString()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -26,7 +27,7 @@ module.exports = function(ctx) {
|
|||||||
router.get('/auth/keys', ctx.asyncHandler(async (req, res) => {
|
router.get('/auth/keys', ctx.asyncHandler(async (req, res) => {
|
||||||
// Require session authentication (not API key - can't manage keys with key itself)
|
// Require session authentication (not API key - can't manage keys with key itself)
|
||||||
if (!req.auth || req.auth.type !== 'session') {
|
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();
|
const keys = await ctx.authManager.listAPIKeys();
|
||||||
@@ -37,19 +38,19 @@ module.exports = function(ctx) {
|
|||||||
router.post('/auth/keys', ctx.asyncHandler(async (req, res) => {
|
router.post('/auth/keys', ctx.asyncHandler(async (req, res) => {
|
||||||
// Require session authentication
|
// Require session authentication
|
||||||
if (!req.auth || req.auth.type !== 'session') {
|
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;
|
const { name, scopes } = req.body;
|
||||||
|
|
||||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
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
|
// Validate scopes if provided
|
||||||
const validScopes = ['read', 'write', 'admin'];
|
const validScopes = ['read', 'write', 'admin'];
|
||||||
if (scopes && (!Array.isArray(scopes) || !scopes.every(s => validScopes.includes(s)))) {
|
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(
|
const keyData = await ctx.authManager.generateAPIKey(
|
||||||
@@ -72,13 +73,13 @@ module.exports = function(ctx) {
|
|||||||
router.delete('/auth/keys/:keyId', ctx.asyncHandler(async (req, res) => {
|
router.delete('/auth/keys/:keyId', ctx.asyncHandler(async (req, res) => {
|
||||||
// Require session authentication
|
// Require session authentication
|
||||||
if (!req.auth || req.auth.type !== 'session') {
|
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;
|
const { keyId } = req.params;
|
||||||
|
|
||||||
if (!keyId || typeof keyId !== 'string') {
|
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);
|
const success = await ctx.authManager.revokeAPIKey(keyId);
|
||||||
@@ -86,8 +87,7 @@ module.exports = function(ctx) {
|
|||||||
if (success) {
|
if (success) {
|
||||||
res.json({ success: true, message: 'API key revoked successfully' });
|
res.json({ success: true, message: 'API key revoked successfully' });
|
||||||
} else {
|
} else {
|
||||||
const { NotFoundError } = require('../../errors');
|
throw new NotFoundError(`API key ${keyId}`);
|
||||||
throw new NotFoundError('API key');
|
|
||||||
}
|
}
|
||||||
}, 'auth-keys-revoke'));
|
}, 'auth-keys-revoke'));
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ module.exports = function(ctx) {
|
|||||||
router.post('/auth/jwt', ctx.asyncHandler(async (req, res) => {
|
router.post('/auth/jwt', ctx.asyncHandler(async (req, res) => {
|
||||||
// Require session authentication
|
// Require session authentication
|
||||||
if (!req.auth || req.auth.type !== 'session') {
|
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;
|
const { expiresIn, userId } = req.body;
|
||||||
@@ -103,7 +103,7 @@ module.exports = function(ctx) {
|
|||||||
// Validate expiresIn format if provided (e.g., '24h', '7d', '1y')
|
// Validate expiresIn format if provided (e.g., '24h', '7d', '1y')
|
||||||
const validExpiresIn = /^(\d+[smhdy])$/.test(expiresIn || '24h');
|
const validExpiresIn = /^(\d+[smhdy])$/.test(expiresIn || '24h');
|
||||||
if (expiresIn && !validExpiresIn) {
|
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(
|
const token = await ctx.authManager.generateJWT(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
|
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
|
||||||
|
const { AuthenticationError, NotFoundError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx, getAppSession, appSessionCache) {
|
module.exports = function(ctx, getAppSession, appSessionCache) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -83,7 +84,7 @@ module.exports = function(ctx, getAppSession, appSessionCache) {
|
|||||||
const { serviceId } = req.params;
|
const { serviceId } = req.params;
|
||||||
|
|
||||||
if (ctx.totpConfig.enabled && ctx.totpConfig.sessionDuration !== 'never') {
|
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
|
// Jellyfin/Emby: separate browser-specific token
|
||||||
@@ -101,10 +102,10 @@ module.exports = function(ctx, getAppSession, appSessionCache) {
|
|||||||
try {
|
try {
|
||||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).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 service = await ctx.getServiceById(serviceId);
|
||||||
const baseUrl = service?.url;
|
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 mediaAuth = buildMediaAuth(APP.DEVICE_IDS.BROWSER);
|
||||||
const authResp = await ctx.fetchT(`${baseUrl}/Users/AuthenticateByName`, {
|
const authResp = await ctx.fetchT(`${baseUrl}/Users/AuthenticateByName`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -141,9 +142,9 @@ module.exports = function(ctx, getAppSession, appSessionCache) {
|
|||||||
// No cache — get fresh session
|
// No cache — get fresh session
|
||||||
try {
|
try {
|
||||||
const service = await ctx.getServiceById(serviceId);
|
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;
|
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;
|
let username, password;
|
||||||
if (service.isExternal) {
|
if (service.isExternal) {
|
||||||
@@ -156,7 +157,7 @@ module.exports = function(ctx, getAppSession, appSessionCache) {
|
|||||||
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
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);
|
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
||||||
if (appCookies) {
|
if (appCookies) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { renewCSRFToken } = require('../../csrf-protection');
|
const { renewCSRFToken } = require('../../csrf-protection');
|
||||||
|
const { ValidationError, AuthenticationError } = require('../../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -28,7 +29,7 @@ module.exports = function(ctx) {
|
|||||||
// Normalize common Base32 confusions: 0→O, 1→L, 8→B
|
// Normalize common Base32 confusions: 0→O, 1→L, 8→B
|
||||||
secret = secret.replace(/0/g, 'O').replace(/1/g, 'L').replace(/8/g, 'B');
|
secret = secret.replace(/0/g, 'O').replace(/1/g, 'L').replace(/8/g, 'B');
|
||||||
if (!/^[A-Z2-7]{16,}$/.test(secret)) {
|
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 {
|
} else {
|
||||||
secret = authenticator.generateSecret();
|
secret = authenticator.generateSecret();
|
||||||
@@ -50,17 +51,17 @@ module.exports = function(ctx) {
|
|||||||
const { code } = req.body;
|
const { code } = req.body;
|
||||||
|
|
||||||
if (!code || !/^\d{6}$/.test(code)) {
|
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');
|
const pendingSecret = await ctx.credentialManager.retrieve('totp.pending_secret');
|
||||||
if (!pendingSecret) {
|
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 };
|
authenticator.options = { window: 1 };
|
||||||
if (!authenticator.verify({ token: code, secret: pendingSecret })) {
|
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
|
// Promote pending secret to active
|
||||||
@@ -87,21 +88,21 @@ module.exports = function(ctx) {
|
|||||||
const { code } = req.body;
|
const { code } = req.body;
|
||||||
|
|
||||||
if (!code || !/^\d{6}$/.test(code)) {
|
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) {
|
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');
|
const secret = await ctx.credentialManager.retrieve('totp.secret');
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
return ctx.errorResponse(res, 500, 'TOTP secret not found');
|
throw new Error('TOTP secret not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticator.options = { window: 1 };
|
authenticator.options = { window: 1 };
|
||||||
if (!authenticator.verify({ token: code, secret })) {
|
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 });
|
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 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'));
|
}, 'totp-check-session'));
|
||||||
|
|
||||||
// Disable TOTP
|
// Disable TOTP
|
||||||
@@ -141,14 +142,14 @@ module.exports = function(ctx) {
|
|||||||
// Always require a valid TOTP code when TOTP is active
|
// Always require a valid TOTP code when TOTP is active
|
||||||
if (ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) {
|
if (ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) {
|
||||||
if (!code || !/^\d{6}$/.test(code)) {
|
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 { authenticator } = require('otplib');
|
||||||
const secret = await ctx.credentialManager.retrieve('totp.secret');
|
const secret = await ctx.credentialManager.retrieve('totp.secret');
|
||||||
if (secret) {
|
if (secret) {
|
||||||
authenticator.options = { window: 1 };
|
authenticator.options = { window: 1 };
|
||||||
if (!authenticator.verify({ token: code, secret })) {
|
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;
|
const { sessionDuration } = req.body;
|
||||||
|
|
||||||
if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) {
|
if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid session duration', {
|
throw new ValidationError(`Invalid session duration. Valid options: ${Object.keys(ctx.session.durations).join(', ')}`, 'sessionDuration');
|
||||||
validOptions: Object.keys(ctx.session.durations)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionDuration) {
|
if (sessionDuration) {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ module.exports = function(ctx) {
|
|||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
userAgent: req.get('user-agent')
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
const stats = await fsp.stat(resolvedPath);
|
const stats = await fsp.stat(resolvedPath);
|
||||||
if (!stats.isDirectory()) {
|
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 });
|
const entries = await fsp.readdir(resolvedPath, { withFileTypes: true });
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ module.exports = function(ctx) {
|
|||||||
router.get('/install-script', ctx.asyncHandler(async (req, res) => {
|
router.get('/install-script', ctx.asyncHandler(async (req, res) => {
|
||||||
const platform = (req.query.platform || 'windows').toLowerCase();
|
const platform = (req.query.platform || 'windows').toLowerCase();
|
||||||
if (!['windows', 'linux', 'macos'].includes(platform)) {
|
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
|
// Load cert info to get the fingerprint
|
||||||
@@ -134,7 +134,7 @@ module.exports = function(ctx) {
|
|||||||
const { password = 'dashcaddy', format = 'pfx' } = req.query;
|
const { password = 'dashcaddy', format = 'pfx' } = req.query;
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9!@#%^_+=,.:-]{1,64}$/.test(password)) {
|
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)) {
|
if (!domain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(domain)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const fsp = require('fs').promises;
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { LIMITS } = require('../../constants');
|
const { LIMITS } = require('../../constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../fs-helpers');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
// Image processing for favicon conversion (optional)
|
// Image processing for favicon conversion (optional)
|
||||||
let sharp, pngToIco;
|
let sharp, pngToIco;
|
||||||
@@ -22,19 +23,19 @@ module.exports = function(ctx) {
|
|||||||
const { filename, data } = req.body;
|
const { filename, data } = req.body;
|
||||||
|
|
||||||
if (!filename || !data) {
|
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
|
// Validate filename to prevent directory traversal
|
||||||
const safeFilename = path.basename(filename);
|
const safeFilename = path.basename(filename);
|
||||||
if (safeFilename !== filename || filename.includes('..')) {
|
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
|
// Extract base64 data
|
||||||
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
||||||
if (!matches) {
|
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];
|
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;
|
const { data, dataDark, dataLight, position, dashboardTitle } = req.body;
|
||||||
|
|
||||||
if (!data && !dataDark && !dataLight && !position && !dashboardTitle) {
|
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();
|
const config = await ctx.readConfig();
|
||||||
@@ -112,19 +113,19 @@ module.exports = function(ctx) {
|
|||||||
// New dual-variant upload
|
// New dual-variant upload
|
||||||
if (dataDark) {
|
if (dataDark) {
|
||||||
pathDark = await saveLogoFile(dataDark, 'dark');
|
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;
|
config.customLogoDark = pathDark;
|
||||||
}
|
}
|
||||||
if (dataLight) {
|
if (dataLight) {
|
||||||
pathLight = await saveLogoFile(dataLight, 'light');
|
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;
|
config.customLogoLight = pathLight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy single-logo: save as both variants
|
// Legacy single-logo: save as both variants
|
||||||
if (data && !dataDark && !dataLight) {
|
if (data && !dataDark && !dataLight) {
|
||||||
const singlePath = await saveLogoFile(data, 'dark');
|
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.customLogoDark = singlePath;
|
||||||
config.customLogoLight = singlePath;
|
config.customLogoLight = singlePath;
|
||||||
// Also set legacy field for backward compat
|
// Also set legacy field for backward compat
|
||||||
@@ -208,7 +209,7 @@ module.exports = function(ctx) {
|
|||||||
const { data } = req.body;
|
const { data } = req.body;
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return ctx.errorResponse(res, 400, 'Image data is required');
|
throw new ValidationError('Image data is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sharp || !pngToIco) {
|
if (!sharp || !pngToIco) {
|
||||||
@@ -218,7 +219,7 @@ module.exports = function(ctx) {
|
|||||||
// Extract base64 data
|
// Extract base64 data
|
||||||
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
||||||
if (!matches) {
|
if (!matches) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid image data format');
|
throw new ValidationError('Invalid image data format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageType = matches[1];
|
const imageType = matches[1];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { CADDY } = require('../../constants');
|
const { CADDY } = require('../../constants');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../fs-helpers');
|
||||||
|
const { ValidationError, AuthenticationError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -133,7 +134,7 @@ module.exports = function(ctx) {
|
|||||||
const backup = req.body;
|
const backup = req.body;
|
||||||
|
|
||||||
if (!backup || !backup.version || !backup.files) {
|
if (!backup || !backup.version || !backup.files) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid backup file format');
|
throw new ValidationError('Invalid backup file format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = {
|
const preview = {
|
||||||
@@ -198,7 +199,7 @@ module.exports = function(ctx) {
|
|||||||
const { backup, options = {}, totpCode } = req.body;
|
const { backup, options = {}, totpCode } = req.body;
|
||||||
|
|
||||||
if (!backup || !backup.version || !backup.files) {
|
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
|
// 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 (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) {
|
||||||
if (!totpCode || !/^\d{6}$/.test(totpCode)) {
|
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 { authenticator } = require('otplib');
|
||||||
const secret = await ctx.credentialManager.retrieve('totp.secret');
|
const secret = await ctx.credentialManager.retrieve('totp.secret');
|
||||||
if (secret) {
|
if (secret) {
|
||||||
authenticator.options = { window: 1 };
|
authenticator.options = { window: 1 };
|
||||||
if (!authenticator.verify({ token: totpCode, secret })) {
|
if (!authenticator.verify({ token: totpCode, secret })) {
|
||||||
return ctx.errorResponse(res, 401, '[DC-111] Invalid TOTP code');
|
throw new AuthenticationError('[DC-111] Invalid TOTP code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const { validateConfig } = require('../../config-schema');
|
const { validateConfig } = require('../../config-schema');
|
||||||
const { exists } = require('../../fs-helpers');
|
const { exists } = require('../../fs-helpers');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -22,7 +23,7 @@ module.exports = function(ctx) {
|
|||||||
const incoming = req.body;
|
const incoming = req.body;
|
||||||
|
|
||||||
if (!incoming || typeof incoming !== 'object') {
|
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
|
// Merge with existing config so partial saves don't wipe fields
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ module.exports = function({ credentialManager, asyncHandler }) {
|
|||||||
if (rotateSuccess) {
|
if (rotateSuccess) {
|
||||||
success(res, { message: 'Encryption key rotated, all credentials re-encrypted' });
|
success(res, { message: 'Encryption key rotated, all credentials re-encrypted' });
|
||||||
} else {
|
} else {
|
||||||
errorResponse(res, 'Key rotation failed', 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
}, 'credentials-rotate'));
|
}, 'credentials-rotate'));
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const validatorLib = require('validator');
|
|||||||
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
|
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
|
||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../fs-helpers');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../response-helpers');
|
||||||
|
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DNS routes factory
|
* DNS routes factory
|
||||||
@@ -47,27 +48,27 @@ module.exports = function({
|
|||||||
const dnsToken = await dns.requireToken(token);
|
const dnsToken = await dns.requireToken(token);
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
return errorResponse(res, 'domain is required', 400);
|
throw new ValidationError('domain is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate domain format
|
// Validate domain format
|
||||||
if (!REGEX.DOMAIN.test(domain)) {
|
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
|
// Validate record type
|
||||||
if (type && !DNS_RECORD_TYPES.includes(type.toUpperCase())) {
|
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
|
// Validate ipAddress if provided
|
||||||
if (ipAddress && !validatorLib.isIP(ipAddress)) {
|
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
|
// Validate server against configured DNS servers
|
||||||
if (server && !validateDnsServer(server)) {
|
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
|
// Default to dns1 LAN IP, allow override
|
||||||
@@ -82,10 +83,10 @@ module.exports = function({
|
|||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
success(res, { message: `DNS record ${domain} deleted` });
|
success(res, { message: `DNS record ${domain} deleted` });
|
||||||
} else {
|
} else {
|
||||||
errorResponse(res, result.errorMessage || 'DNS deletion failed', 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, safeErrorMessage(error), 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
}, 'dns-delete-record'));
|
}, 'dns-delete-record'));
|
||||||
|
|
||||||
@@ -96,17 +97,17 @@ module.exports = function({
|
|||||||
const dnsToken = await dns.requireToken(token);
|
const dnsToken = await dns.requireToken(token);
|
||||||
|
|
||||||
if (!domain || !ip) {
|
if (!domain || !ip) {
|
||||||
return errorResponse(res, 'domain and ip are required', 400);
|
throw new ValidationError('domain and ip are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate domain format
|
// Validate domain format
|
||||||
if (!REGEX.DOMAIN.test(domain)) {
|
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
|
// Validate IP address
|
||||||
if (!validatorLib.isIP(ip)) {
|
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
|
// Validate TTL if provided
|
||||||
@@ -119,7 +120,7 @@ module.exports = function({
|
|||||||
|
|
||||||
// Validate server against configured DNS servers
|
// Validate server against configured DNS servers
|
||||||
if (server && !validateDnsServer(server)) {
|
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
|
// Default to dns1 LAN IP since Docker container can't access Tailscale network
|
||||||
@@ -140,7 +141,7 @@ module.exports = function({
|
|||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
success(res, { message: `DNS record ${domain} -> ${ip} created` });
|
success(res, { message: `DNS record ${domain} -> ${ip} created` });
|
||||||
} else {
|
} else {
|
||||||
errorResponse(res, result.errorMessage || 'DNS creation failed', 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('dns', 'DNS record creation error', { error: error.message });
|
log.error('dns', 'DNS record creation error', { error: error.message });
|
||||||
@@ -155,17 +156,17 @@ module.exports = function({
|
|||||||
const dnsToken = await dns.requireToken(token);
|
const dnsToken = await dns.requireToken(token);
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
return errorResponse(res, 'domain is required', 400);
|
throw new ValidationError('domain is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate domain format
|
// Validate domain format
|
||||||
if (!REGEX.DOMAIN.test(domain)) {
|
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
|
// Validate server against configured DNS servers
|
||||||
if (server && !validateDnsServer(server)) {
|
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;
|
const dnsServer = server || siteConfig.dnsServerIp;
|
||||||
@@ -182,14 +183,14 @@ module.exports = function({
|
|||||||
const ipAddresses = aRecords.map(r => r.rData?.ipAddress).filter(Boolean);
|
const ipAddresses = aRecords.map(r => r.rData?.ipAddress).filter(Boolean);
|
||||||
success(res, { answer: ipAddresses });
|
success(res, { answer: ipAddresses });
|
||||||
} else {
|
} else {
|
||||||
errorResponse(res, 'No A records found for domain', 404);
|
throw new NotFoundError('No A records found for domain');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errorResponse(res, result.errorMessage || 'DNS resolve failed', 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('dns', 'DNS resolve error', { error: error.message });
|
log.error('dns', 'DNS resolve error', { error: error.message });
|
||||||
errorResponse(res, safeErrorMessage(error), 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
}, 'dns-resolve'));
|
}, 'dns-resolve'));
|
||||||
|
|
||||||
@@ -198,13 +199,13 @@ module.exports = function({
|
|||||||
const { server, limit } = req.query;
|
const { server, limit } = req.query;
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return errorResponse(res, 'server is required', 400);
|
throw new ValidationError('server is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate server against configured DNS servers
|
// Validate server against configured DNS servers
|
||||||
const serverIp = validateDnsServer(server);
|
const serverIp = validateDnsServer(server);
|
||||||
if (!serverIp) {
|
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);
|
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
|
// Auto-authenticate using stored read-only credentials for log access
|
||||||
const authResult = await dns.getTokenForServer(serverIp, 'readonly');
|
const authResult = await dns.getTokenForServer(serverIp, 'readonly');
|
||||||
if (!authResult.success) {
|
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;
|
const effectiveToken = authResult.token;
|
||||||
|
|
||||||
@@ -322,7 +323,7 @@ module.exports = function({
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('dns', 'DNS logs proxy error', { error: error.message });
|
log.error('dns', 'DNS logs proxy error', { error: error.message });
|
||||||
errorResponse(res, safeErrorMessage(error), 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
}, 'dns-logs'));
|
}, 'dns-logs'));
|
||||||
|
|
||||||
@@ -417,19 +418,19 @@ module.exports = function({
|
|||||||
|
|
||||||
// Legacy single-credential format: { username, password, server }
|
// Legacy single-credential format: { username, password, server }
|
||||||
if (!username || !password) {
|
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) {
|
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))) {
|
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)) {
|
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);
|
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');
|
const tokenResult = await dns.getTokenForServer(serverInfo.ip, 'admin');
|
||||||
if (!tokenResult.success) {
|
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';
|
const dnsPort = siteConfig.dnsServerPort || '5380';
|
||||||
@@ -495,7 +496,7 @@ module.exports = function({
|
|||||||
if (result.status === 'ok') {
|
if (result.status === 'ok') {
|
||||||
success(res, { message: 'Restart initiated' });
|
success(res, { message: 'Restart initiated' });
|
||||||
} else {
|
} else {
|
||||||
errorResponse(res, result.errorMessage || 'Restart failed', 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Connection drop is expected during restart
|
// Connection drop is expected during restart
|
||||||
@@ -522,18 +523,18 @@ module.exports = function({
|
|||||||
try {
|
try {
|
||||||
const { server } = req.query;
|
const { server } = req.query;
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return errorResponse(res, 'Server IP required', 400);
|
throw new ValidationError('Server IP required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverIp = validateDnsServer(server);
|
const serverIp = validateDnsServer(server);
|
||||||
if (!serverIp) {
|
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
|
// Authenticate with admin credentials for update check
|
||||||
const tokenResult = await dns.getTokenForServer(serverIp, 'admin');
|
const tokenResult = await dns.getTokenForServer(serverIp, 'admin');
|
||||||
if (!tokenResult.success) {
|
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';
|
const dnsPort = siteConfig.dnsServerPort || '5380';
|
||||||
@@ -551,7 +552,7 @@ module.exports = function({
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (!text || text.trim() === '') {
|
if (!text || text.trim() === '') {
|
||||||
return errorResponse(res, 'Empty response from DNS server', 500);
|
return // Error handled by middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = JSON.parse(text);
|
const result = JSON.parse(text);
|
||||||
@@ -567,11 +568,11 @@ module.exports = function({
|
|||||||
instructionsLink: result.response.instructionsLink || null
|
instructionsLink: result.response.instructionsLink || null
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
errorResponse(res, result.errorMessage || 'Check failed', 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('dns', 'DNS update check error', { error: error.message });
|
log.error('dns', 'DNS update check error', { error: error.message });
|
||||||
errorResponse(res, safeErrorMessage(error), 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
}, 'dns-check-update'));
|
}, 'dns-check-update'));
|
||||||
|
|
||||||
@@ -582,18 +583,18 @@ module.exports = function({
|
|||||||
try {
|
try {
|
||||||
const { server } = req.query;
|
const { server } = req.query;
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return errorResponse(res, 'Server IP required', 400);
|
throw new ValidationError('Server IP required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverIp = validateDnsServer(server);
|
const serverIp = validateDnsServer(server);
|
||||||
if (!serverIp) {
|
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
|
// Authenticate with admin credentials for update operations
|
||||||
const tokenResult = await dns.getTokenForServer(serverIp, 'admin');
|
const tokenResult = await dns.getTokenForServer(serverIp, 'admin');
|
||||||
if (!tokenResult.success) {
|
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';
|
const dnsPort = siteConfig.dnsServerPort || '5380';
|
||||||
@@ -605,12 +606,12 @@ module.exports = function({
|
|||||||
|
|
||||||
const checkText = await checkResponse.text();
|
const checkText = await checkResponse.text();
|
||||||
if (!checkText || checkText.trim() === '') {
|
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);
|
const checkResult = JSON.parse(checkText);
|
||||||
|
|
||||||
if (checkResult.status !== 'ok') {
|
if (checkResult.status !== 'ok') {
|
||||||
return errorResponse(res, checkResult.errorMessage || 'Update check failed', 500);
|
return // Error handled by middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkResult.response.updateAvailable) {
|
if (!checkResult.response.updateAvailable) {
|
||||||
@@ -636,7 +637,7 @@ module.exports = function({
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('dns', 'DNS update error', { error: error.message });
|
log.error('dns', 'DNS update error', { error: error.message });
|
||||||
errorResponse(res, safeErrorMessage(error), 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
}, 'dns-update'));
|
}, 'dns-update'));
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ module.exports = function({
|
|||||||
router.get('/health/probe', asyncHandler(async (req, res) => {
|
router.get('/health/probe', asyncHandler(async (req, res) => {
|
||||||
const targetUrl = req.query.url;
|
const targetUrl = req.query.url;
|
||||||
if (!targetUrl) {
|
if (!targetUrl) {
|
||||||
return errorResponse(res, 'Missing ?url= parameter', 400);
|
throw new ValidationError('Missing ?url= parameter');
|
||||||
}
|
}
|
||||||
const result = await checkDirect(targetUrl);
|
const result = await checkDirect(targetUrl);
|
||||||
res.json(result || {
|
res.json(result || {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success, error: errorResponse } = require('../response-helpers');
|
const { success, error: errorResponse } = require('../response-helpers');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* License routes factory
|
* License routes factory
|
||||||
@@ -15,7 +16,7 @@ module.exports = function({ licenseManager, asyncHandler }) {
|
|||||||
router.post('/activate', asyncHandler(async (req, res) => {
|
router.post('/activate', asyncHandler(async (req, res) => {
|
||||||
const { code } = req.body;
|
const { code } = req.body;
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return errorResponse(res, 'License code is required', 400);
|
throw new ValidationError('License code is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await licenseManager.activate(code);
|
const result = await licenseManager.activate(code);
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ module.exports = function(ctx) {
|
|||||||
if (!ctx.logDigest) return ctx.errorResponse(res, 503, 'Log digest not available');
|
if (!ctx.logDigest) return ctx.errorResponse(res, 503, 'Log digest not available');
|
||||||
const { date } = req.params;
|
const { date } = req.params;
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
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';
|
const format = req.query.format || 'json';
|
||||||
if (format === 'text') {
|
if (format === 'text') {
|
||||||
@@ -209,7 +209,7 @@ module.exports = function(ctx) {
|
|||||||
const { path: logPath, tail = 100 } = req.query;
|
const { path: logPath, tail = 100 } = req.query;
|
||||||
|
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return ctx.errorResponse(res, 400, 'Log path is required');
|
throw new ValidationError('Log path is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformPaths = require('../platform-paths');
|
const platformPaths = require('../platform-paths');
|
||||||
@@ -233,7 +233,7 @@ module.exports = function(ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isAllowed) {
|
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)) {
|
if (!await exists(resolvedPath)) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { validateURL, validateToken } = require('../input-validator');
|
const { validateURL, validateToken } = require('../input-validator');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../pagination');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -43,27 +44,27 @@ module.exports = function(ctx) {
|
|||||||
try {
|
try {
|
||||||
validateURL(providers.discord.webhookUrl);
|
validateURL(providers.discord.webhookUrl);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL');
|
throw new ValidationError('Invalid Discord webhook URL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (providers.telegram?.botToken) {
|
if (providers.telegram?.botToken) {
|
||||||
try {
|
try {
|
||||||
validateToken(providers.telegram.botToken);
|
validateToken(providers.telegram.botToken);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format');
|
throw new ValidationError('Invalid Telegram bot token format');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (providers.ntfy?.serverUrl) {
|
if (providers.ntfy?.serverUrl) {
|
||||||
try {
|
try {
|
||||||
validateURL(providers.ntfy.serverUrl);
|
validateURL(providers.ntfy.serverUrl);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid ntfy server URL');
|
throw new ValidationError('Invalid ntfy server URL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (providers.ntfy?.topic) {
|
if (providers.ntfy?.topic) {
|
||||||
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
|
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||||
if (!topicRegex.test(providers.ntfy.topic)) {
|
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');
|
result = await ctx.notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return ctx.errorResponse(res, 400, 'Unknown provider');
|
throw new ValidationError('Unknown provider');
|
||||||
}
|
}
|
||||||
res.json({ success: result.success, provider, error: result.error });
|
res.json({ success: result.success, provider, error: result.error });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { ValidationError } = require('../../errors');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { DOCKER } = require('../../constants');
|
const { DOCKER } = require('../../constants');
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ module.exports = function(ctx) {
|
|||||||
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
||||||
|
|
||||||
const recipe = RECIPE_TEMPLATES[recipeId];
|
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 });
|
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'
|
`Failed to deploy **${recipe.name}**: ${error.message}`, 'error'
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.errorResponse(res, 500, error.message);
|
// Error automatically handled by middleware
|
||||||
}
|
}
|
||||||
}, 'recipe-deploy'));
|
}, 'recipe-deploy'));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const deployRoutes = require('./deploy');
|
const deployRoutes = require('./deploy');
|
||||||
const manageRoutes = require('./manage');
|
const manageRoutes = require('./manage');
|
||||||
|
const { NotFoundError } = require('../../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -41,7 +42,7 @@ module.exports = function(ctx) {
|
|||||||
router.get('/templates/:recipeId', ctx.asyncHandler(async (req, res) => {
|
router.get('/templates/:recipeId', ctx.asyncHandler(async (req, res) => {
|
||||||
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
|
||||||
const recipe = RECIPE_TEMPLATES[req.params.recipeId];
|
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 } });
|
res.json({ success: true, recipe: { id: req.params.recipeId, ...recipe } });
|
||||||
}, 'recipe-template-detail'));
|
}, 'recipe-template-detail'));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { DOCKER } = require('../../constants');
|
const { DOCKER } = require('../../constants');
|
||||||
|
const { NotFoundError } = require('../../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -96,7 +97,7 @@ module.exports = function(ctx) {
|
|||||||
const containers = await findRecipeContainers(recipeId);
|
const containers = await findRecipeContainers(recipeId);
|
||||||
|
|
||||||
if (containers.length === 0) {
|
if (containers.length === 0) {
|
||||||
return ctx.errorResponse(res, 404, 'No containers found for this recipe');
|
throw new NotFoundError('Containers for recipe');
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -127,7 +128,7 @@ module.exports = function(ctx) {
|
|||||||
const containers = await findRecipeContainers(recipeId);
|
const containers = await findRecipeContainers(recipeId);
|
||||||
|
|
||||||
if (containers.length === 0) {
|
if (containers.length === 0) {
|
||||||
return ctx.errorResponse(res, 404, 'No containers found for this recipe');
|
throw new NotFoundError('Containers for recipe');
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -159,7 +160,7 @@ module.exports = function(ctx) {
|
|||||||
const containers = await findRecipeContainers(recipeId);
|
const containers = await findRecipeContainers(recipeId);
|
||||||
|
|
||||||
if (containers.length === 0) {
|
if (containers.length === 0) {
|
||||||
return ctx.errorResponse(res, 404, 'No containers found for this recipe');
|
throw new NotFoundError('Containers for recipe');
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -185,7 +186,7 @@ module.exports = function(ctx) {
|
|||||||
const containers = await findRecipeContainers(recipeId);
|
const containers = await findRecipeContainers(recipeId);
|
||||||
|
|
||||||
if (containers.length === 0) {
|
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 });
|
ctx.log.info('recipe', 'Removing recipe', { recipeId, containerCount: containers.length });
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ module.exports = function({
|
|||||||
router.post('/seedhost-creds', asyncHandler(async (req, res) => {
|
router.post('/seedhost-creds', asyncHandler(async (req, res) => {
|
||||||
const { username, password, serviceId } = req.body;
|
const { username, password, serviceId } = req.body;
|
||||||
if (!username) {
|
if (!username) {
|
||||||
return errorResponse(res, 'Username required', 400);
|
throw new ValidationError('Username required');
|
||||||
}
|
}
|
||||||
await credentialManager.store('seedhost.username', username);
|
await credentialManager.store('seedhost.username', username);
|
||||||
if (password) {
|
if (password) {
|
||||||
@@ -361,7 +361,7 @@ module.exports = function({
|
|||||||
const { id, name, logo } = req.body;
|
const { id, name, logo } = req.body;
|
||||||
|
|
||||||
if (!id || !name) {
|
if (!id || !name) {
|
||||||
return errorResponse(res, 'id and name are required', 400);
|
throw new ValidationError('id and name are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate service configuration
|
// Validate service configuration
|
||||||
@@ -388,7 +388,7 @@ module.exports = function({
|
|||||||
if (error.message.includes('already exists')) {
|
if (error.message.includes('already exists')) {
|
||||||
errorResponse(res, safeErrorMessage(error), 409);
|
errorResponse(res, safeErrorMessage(error), 409);
|
||||||
} else {
|
} else {
|
||||||
errorResponse(res, safeErrorMessage(error), 500);
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 'services-update'));
|
}, 'services-update'));
|
||||||
@@ -398,12 +398,12 @@ module.exports = function({
|
|||||||
const services = req.body;
|
const services = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(services)) {
|
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) {
|
for (const service of services) {
|
||||||
if (!service.id || !service.name) {
|
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 {
|
try {
|
||||||
validateServiceConfig(service);
|
validateServiceConfig(service);
|
||||||
@@ -426,7 +426,7 @@ module.exports = function({
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
if (!await exists(SERVICES_FILE)) {
|
if (!await exists(SERVICES_FILE)) {
|
||||||
return errorResponse(res, 'No services found', 404);
|
throw new NotFoundError('No services found');
|
||||||
}
|
}
|
||||||
|
|
||||||
let found = false;
|
let found = false;
|
||||||
@@ -450,19 +450,19 @@ module.exports = function({
|
|||||||
const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = req.body;
|
const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = req.body;
|
||||||
|
|
||||||
if (!oldSubdomain || !newSubdomain) {
|
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)) {
|
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)) {
|
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)) {
|
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 };
|
const results = { dns: null, caddy: null, services: null };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { CADDY, REGEX, LIMITS } = require('../constants');
|
const { CADDY, REGEX, LIMITS } = require('../constants');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -118,7 +119,7 @@ module.exports = function(ctx) {
|
|||||||
// Remove a site from Caddyfile
|
// Remove a site from Caddyfile
|
||||||
router.delete('/site/:domain', ctx.asyncHandler(async (req, res) => {
|
router.delete('/site/:domain', ctx.asyncHandler(async (req, res) => {
|
||||||
const { domain } = req.params;
|
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 result = await ctx.caddy.modify((content) => {
|
||||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
@@ -143,11 +144,11 @@ module.exports = function(ctx) {
|
|||||||
// Add a new site to Caddyfile and reload
|
// Add a new site to Caddyfile and reload
|
||||||
router.post('/site', ctx.asyncHandler(async (req, res) => {
|
router.post('/site', ctx.asyncHandler(async (req, res) => {
|
||||||
const { domain, upstream, config } = req.body;
|
const { domain, upstream, config } = req.body;
|
||||||
if (!domain || !upstream) return ctx.errorResponse(res, 400, 'Domain and upstream are required');
|
if (!domain || !upstream) throw new ValidationError('Domain and upstream are required');
|
||||||
if (!REGEX.DOMAIN.test(domain)) return ctx.errorResponse(res, 400, '[DC-301] Invalid domain format');
|
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
||||||
|
|
||||||
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
|
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();
|
let content = await ctx.caddy.read();
|
||||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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;
|
const { subdomain, externalUrl, preserveHost, followRedirects, sslType, caddyfilePath, reloadCaddy: shouldReload, createDns, serviceName, logo } = req.body;
|
||||||
|
|
||||||
if (!subdomain || !externalUrl) {
|
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)) {
|
if (!REGEX.SUBDOMAIN.test(subdomain)) {
|
||||||
return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format');
|
throw new ValidationError('[DC-301] Invalid subdomain format');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -207,7 +208,7 @@ module.exports = function(ctx) {
|
|||||||
// Validate URL components are safe for Caddyfile syntax
|
// Validate URL components are safe for Caddyfile syntax
|
||||||
const unsafeCaddyChars = /[{}\n\r]/;
|
const unsafeCaddyChars = /[{}\n\r]/;
|
||||||
if (unsafeCaddyChars.test(urlObj.host) || unsafeCaddyChars.test(urlObj.pathname)) {
|
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}`;
|
const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ module.exports = function(ctx) {
|
|||||||
const { subdomain, tailscaleOnly, allowedIPs } = req.body;
|
const { subdomain, tailscaleOnly, allowedIPs } = req.body;
|
||||||
|
|
||||||
if (!subdomain) {
|
if (!subdomain) {
|
||||||
return ctx.errorResponse(res, 400, 'subdomain is required');
|
throw new ValidationError('subdomain is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = await ctx.caddy.read();
|
let content = await ctx.caddy.read();
|
||||||
@@ -142,7 +142,7 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
const proxyMatch = match[0].match(/reverse_proxy\s+([^\s\n]+)/);
|
const proxyMatch = match[0].match(/reverse_proxy\s+([^\s\n]+)/);
|
||||||
if (!proxyMatch) {
|
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(':');
|
const [ip, port] = proxyMatch[1].split(':');
|
||||||
@@ -181,7 +181,7 @@ module.exports = function(ctx) {
|
|||||||
const { clientId, clientSecret, tailnet } = req.body;
|
const { clientId, clientSecret, tailnet } = req.body;
|
||||||
|
|
||||||
if (!clientId || !clientSecret || !tailnet) {
|
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
|
// Validate by exchanging for a real token
|
||||||
@@ -252,7 +252,7 @@ module.exports = function(ctx) {
|
|||||||
// Get enriched device list from Tailscale API
|
// Get enriched device list from Tailscale API
|
||||||
router.get('/api-devices', ctx.asyncHandler(async (req, res) => {
|
router.get('/api-devices', ctx.asyncHandler(async (req, res) => {
|
||||||
if (!ctx.tailscale.config.oauthConfigured) {
|
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
|
// Return cached devices from last sync
|
||||||
@@ -266,7 +266,7 @@ module.exports = function(ctx) {
|
|||||||
// Manually trigger an API sync
|
// Manually trigger an API sync
|
||||||
router.post('/sync', ctx.asyncHandler(async (req, res) => {
|
router.post('/sync', ctx.asyncHandler(async (req, res) => {
|
||||||
if (!ctx.tailscale.config.oauthConfigured) {
|
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();
|
const devices = await ctx.tailscale.syncAPI();
|
||||||
@@ -283,7 +283,7 @@ module.exports = function(ctx) {
|
|||||||
const token = await ctx.tailscale.getAccessToken();
|
const token = await ctx.tailscale.getAccessToken();
|
||||||
const tailnet = ctx.tailscale.config.tailnet;
|
const tailnet = ctx.tailscale.config.tailnet;
|
||||||
if (!token || !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`, {
|
const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../pagination');
|
||||||
|
const { ValidationError } = require('../errors');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
module.exports = function(ctx) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -53,7 +54,7 @@ module.exports = function(ctx) {
|
|||||||
router.post('/updates/schedule/:containerId', ctx.asyncHandler(async (req, res) => {
|
router.post('/updates/schedule/:containerId', ctx.asyncHandler(async (req, res) => {
|
||||||
const { scheduledTime } = req.body;
|
const { scheduledTime } = req.body;
|
||||||
if (!scheduledTime) {
|
if (!scheduledTime) {
|
||||||
return ctx.errorResponse(res, 400, 'scheduledTime is required');
|
throw new ValidationError('scheduledTime is required');
|
||||||
}
|
}
|
||||||
ctx.updateManager.scheduleUpdate(req.params.containerId, scheduledTime);
|
ctx.updateManager.scheduleUpdate(req.params.containerId, scheduledTime);
|
||||||
res.json({ success: true, message: 'Update scheduled', scheduledTime });
|
res.json({ success: true, message: 'Update scheduled', scheduledTime });
|
||||||
@@ -116,7 +117,7 @@ module.exports = function(ctx) {
|
|||||||
// Rollback to a previous version
|
// Rollback to a previous version
|
||||||
router.post('/system/rollback', ctx.asyncHandler(async (req, res) => {
|
router.post('/system/rollback', ctx.asyncHandler(async (req, res) => {
|
||||||
const { version } = req.body;
|
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.selfUpdater.rollbackToVersion(version).catch(err => {
|
||||||
ctx.logError('self-rollback', err);
|
ctx.logError('self-rollback', err);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user