From 13d612df5d85e08e81e2e4e3136e1a610e49ed7f Mon Sep 17 00:00:00 2001 From: Krystie Date: Sat, 28 Mar 2026 19:22:42 -0700 Subject: [PATCH] refactor(routes): Phase 3.1 - standardize services.js with explicit dependencies - Replaced god object ctx with explicit dependency injection - Added JSDoc documenting all required dependencies - Updated response calls to use response-helpers (success/error) - Maintained all existing functionality - Self-documenting: you can see exactly what this route needs - Easier testing: mock only what's actually used (14 deps vs 50+ ctx properties) --- dashcaddy-api/routes/services.js | 215 ++++++++++++++++++------------- dashcaddy-api/server.js | 16 ++- 2 files changed, 137 insertions(+), 94 deletions(-) diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index 59fddc3..d1968a8 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -9,8 +9,41 @@ const { validateServiceConfig, isValidPort } = require('../input-validator'); const { exists } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); const { resolveServiceUrl } = require('../url-resolver'); +const { success, error: errorResponse } = require('../response-helpers'); -module.exports = function(ctx) { +/** + * Services route factory + * @param {Object} deps - Explicit dependencies + * @param {Object} deps.servicesStateManager - State manager for services.json + * @param {Object} deps.credentialManager - Credential storage manager + * @param {Object} deps.siteConfig - Site configuration + * @param {Function} deps.buildServiceUrl - URL builder function + * @param {Function} deps.buildDomain - Domain builder function + * @param {Function} deps.fetchT - Fetch wrapper with timeout + * @param {Function} deps.asyncHandler - Async route handler wrapper + * @param {string} deps.SERVICES_FILE - Path to services.json + * @param {Object} deps.log - Logger instance + * @param {Function} deps.safeErrorMessage - Safe error message extractor + * @param {Function} deps.resyncHealthChecker - Health checker resync function + * @param {Object} deps.caddy - Caddy management interface + * @param {Object} deps.dns - DNS management interface + * @returns {express.Router} + */ +module.exports = function({ + servicesStateManager, + credentialManager, + siteConfig, + buildServiceUrl, + buildDomain, + fetchT, + asyncHandler, + SERVICES_FILE, + log, + safeErrorMessage, + resyncHealthChecker, + caddy, + dns +}) { const router = express.Router(); const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt'; const PROBE_CONCURRENCY = 6; @@ -28,13 +61,13 @@ module.exports = function(ctx) { } async function loadServicesList() { - if (!await exists(ctx.SERVICES_FILE)) return []; - const data = await ctx.servicesStateManager.read(); + if (!await exists(SERVICES_FILE)) return []; + const data = await servicesStateManager.read(); return Array.isArray(data) ? data : data.services || []; } function resolveProbeUrl(id, service) { - return resolveServiceUrl(id, service, ctx.siteConfig, ctx.buildServiceUrl); + return resolveServiceUrl(id, service, siteConfig, buildServiceUrl); } const PROBE_TIMEOUT = 3000; // 3s — covers DNS + connect + response @@ -72,7 +105,7 @@ module.exports = function(ctx) { } async function probeViaPylon(targetUrl) { - const pylonConfig = ctx.siteConfig?.pylon; + const pylonConfig = siteConfig?.pylon; if (!pylonConfig?.url) return null; try { const probeUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(targetUrl)}`; @@ -80,7 +113,7 @@ module.exports = function(ctx) { if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - const response = await ctx.fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers }); + const response = await fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers }); clearTimeout(timeout); if (!response.ok) return null; const data = await response.json(); @@ -106,7 +139,7 @@ module.exports = function(ctx) { } // Pylon relay fallback — if direct probe failed, try through the pylon - if (error && ctx.siteConfig?.pylon) { + if (error && siteConfig?.pylon) { const pylonResult = await probeViaPylon(url); if (pylonResult && pylonResult.status) { const responseTime = Number((process.hrtime.bigint() - startedAt) / 1000000n); @@ -161,100 +194,99 @@ module.exports = function(ctx) { // ===== SERVICE CREDENTIAL ENDPOINTS ===== // Store credentials for a service - router.post('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => { + router.post('/services/:serviceId/credentials', asyncHandler(async (req, res) => { const { serviceId } = req.params; const { apiKey, username, password } = req.body; if (apiKey) { - await ctx.credentialManager.store(`service.${serviceId}.apikey`, apiKey); + await credentialManager.store(`service.${serviceId}.apikey`, apiKey); } if (username) { - await ctx.credentialManager.store(`service.${serviceId}.username`, username); + await credentialManager.store(`service.${serviceId}.username`, username); } if (password) { - await ctx.credentialManager.store(`service.${serviceId}.password`, password); + await credentialManager.store(`service.${serviceId}.password`, password); } - res.json({ success: true, message: `Credentials stored for ${serviceId}` }); + success(res, { message: `Credentials stored for ${serviceId}` }); }, 'store-service-creds')); // Delete credentials for a service - router.delete('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => { + router.delete('/services/:serviceId/credentials', asyncHandler(async (req, res) => { const { serviceId } = req.params; - await ctx.credentialManager.delete(`service.${serviceId}.apikey`); - await ctx.credentialManager.delete(`service.${serviceId}.username`); - await ctx.credentialManager.delete(`service.${serviceId}.password`); - res.json({ success: true, message: `Credentials removed for ${serviceId}` }); + await credentialManager.delete(`service.${serviceId}.apikey`); + await credentialManager.delete(`service.${serviceId}.username`); + await credentialManager.delete(`service.${serviceId}.password`); + success(res, { message: `Credentials removed for ${serviceId}` }); }, 'delete-service-creds')); // Check credential status for a service (what's stored) - router.get('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => { + router.get('/services/:serviceId/credentials', asyncHandler(async (req, res) => { try { const { serviceId } = req.params; - const arrKey = await ctx.credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null); - const svcKey = await ctx.credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null); - const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); - res.json({ - success: true, + const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null); + const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null); + const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); + success(res, { hasApiKey: !!(arrKey || svcKey), hasBasicAuth: !!username, username: username || null }); } catch (error) { - res.json({ success: true, hasApiKey: false, hasBasicAuth: false }); + success(res, { hasApiKey: false, hasBasicAuth: false }); } }, 'service-creds')); // ===== SEEDHOST CREDENTIAL ENDPOINTS ===== // Store seedhost credentials (shared username + per-service passwords) - router.post('/seedhost-creds', ctx.asyncHandler(async (req, res) => { + router.post('/seedhost-creds', asyncHandler(async (req, res) => { const { username, password, serviceId } = req.body; if (!username) { - return ctx.errorResponse(res, 400, 'Username required'); + return errorResponse(res, 'Username required', 400); } - await ctx.credentialManager.store('seedhost.username', username); + await credentialManager.store('seedhost.username', username); if (password) { if (serviceId) { - await ctx.credentialManager.store(`seedhost.password.${serviceId}`, password); + await credentialManager.store(`seedhost.password.${serviceId}`, password); } else { - await ctx.credentialManager.store('seedhost.password', password); + await credentialManager.store('seedhost.password', password); } } - res.json({ success: true, message: 'Seedhost credentials stored' }); + success(res, { message: 'Seedhost credentials stored' }); }, 'store-seedhost-creds')); // Get seedhost credential status - router.get('/seedhost-creds', ctx.asyncHandler(async (req, res) => { + router.get('/seedhost-creds', asyncHandler(async (req, res) => { try { - const username = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null); + const username = await credentialManager.retrieve('seedhost.username').catch(() => null); const serviceId = req.query.serviceId; let hasPassword = false; if (serviceId) { - const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null); + const svcPass = await credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null); hasPassword = !!svcPass; } // Fall back to checking shared password if (!hasPassword) { - const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null); + const sharedPass = await credentialManager.retrieve('seedhost.password').catch(() => null); hasPassword = !!sharedPass; } - res.json({ success: true, hasCredentials: !!username && hasPassword, username: username || null, hasPassword }); + success(res, { hasCredentials: !!username && hasPassword, username: username || null, hasPassword }); } catch (error) { - res.json({ success: true, hasCredentials: false }); + success(res, { hasCredentials: false }); } }, 'seedhost-creds')); // Delete seedhost credentials - router.delete('/seedhost-creds', ctx.asyncHandler(async (req, res) => { + router.delete('/seedhost-creds', asyncHandler(async (req, res) => { const serviceId = req.query.serviceId; if (serviceId) { - await ctx.credentialManager.delete(`seedhost.password.${serviceId}`); - res.json({ success: true, message: `Password for ${serviceId} removed` }); + await credentialManager.delete(`seedhost.password.${serviceId}`); + success(res, { message: `Password for ${serviceId} removed` }); } else { - await ctx.credentialManager.delete('seedhost.username'); - await ctx.credentialManager.delete('seedhost.password'); - res.json({ success: true, message: 'Seedhost credentials removed' }); + await credentialManager.delete('seedhost.username'); + await credentialManager.delete('seedhost.password'); + success(res, { message: 'Seedhost credentials removed' }); } }, 'delete-seedhost-creds')); @@ -263,7 +295,7 @@ module.exports = function(ctx) { // Batched live status for dashboard cards const STATUS_DEADLINE = 10000; // 10s — return whatever we have by then - router.get('/services/status', ctx.asyncHandler(async (req, res) => { + router.get('/services/status', asyncHandler(async (req, res) => { const services = await loadServicesList(); const serviceMap = new Map(services.filter(s => s && s.id).map(s => [s.id, s])); const ids = []; @@ -276,7 +308,7 @@ module.exports = function(ctx) { } addId('internet'); - Object.keys(ctx.siteConfig?.dnsServers || {}).forEach(addId); + Object.keys(siteConfig?.dnsServers || {}).forEach(addId); services.forEach(service => addId(service.id)); // Collect results as they arrive; deadline returns whatever we have @@ -300,8 +332,7 @@ module.exports = function(ctx) { }); res.set('Cache-Control', 'no-store'); - res.json({ - success: true, + success(res, { checkedAt: new Date().toISOString(), partial, statuses @@ -309,37 +340,37 @@ module.exports = function(ctx) { }, 'services-status')); // List all services - router.get('/services', ctx.asyncHandler(async (req, res) => { - if (!await exists(ctx.SERVICES_FILE)) { + router.get('/services', asyncHandler(async (req, res) => { + if (!await exists(SERVICES_FILE)) { return res.json([]); } - const services = await ctx.servicesStateManager.read(); + const services = await servicesStateManager.read(); const paginationParams = parsePaginationParams(req.query); const result = paginate(services, paginationParams); if (paginationParams) { - res.json({ success: true, services: result.data, pagination: result.pagination }); + success(res, { services: result.data, pagination: result.pagination }); } else { res.json(result.data); } }, 'services-list')); // Add a new service - router.post('/services', ctx.asyncHandler(async (req, res) => { + router.post('/services', asyncHandler(async (req, res) => { try { const { id, name, logo } = req.body; if (!id || !name) { - return ctx.errorResponse(res, 400, 'id and name are required'); + return errorResponse(res, 'id and name are required', 400); } // Validate service configuration try { validateServiceConfig({ id, name }); } catch (validationErr) { - return ctx.errorResponse(res, 400, validationErr.message, { errors: validationErr.errors }); + return errorResponse(res, validationErr.message, 400, { errors: validationErr.errors }); } - await ctx.servicesStateManager.update(services => { + await servicesStateManager.update(services => { // Check if service already exists if (services.find(s => s.id === id)) { throw new Error(`Service "${id}" already exists`); @@ -349,57 +380,56 @@ module.exports = function(ctx) { return services; }); - ctx.resyncHealthChecker?.().catch(() => {}); - res.json({ success: true, message: `Service "${name}" added to dashboard` }); + resyncHealthChecker?.().catch(() => {}); + success(res, { message: `Service "${name}" added to dashboard` }); } catch (error) { - ctx.log.error('deploy', 'Error adding service', { error: error.message }); + log.error('deploy', 'Error adding service', { error: error.message }); if (error.message.includes('already exists')) { - ctx.errorResponse(res, 409, ctx.safeErrorMessage(error)); + errorResponse(res, safeErrorMessage(error), 409); } else { - ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); + errorResponse(res, safeErrorMessage(error), 500); } } }, 'services-update')); // Bulk import/replace services (for dashboard import feature) - router.put('/services', ctx.asyncHandler(async (req, res) => { + router.put('/services', asyncHandler(async (req, res) => { const services = req.body; if (!Array.isArray(services)) { - return ctx.errorResponse(res, 400, 'Request body must be an array of services'); + return errorResponse(res, 'Request body must be an array of services', 400); } for (const service of services) { if (!service.id || !service.name) { - return ctx.errorResponse(res, 400, 'Each service must have id and name fields'); + return errorResponse(res, 'Each service must have id and name fields', 400); } try { validateServiceConfig(service); } catch (validationErr) { - return ctx.errorResponse(res, 400, `Invalid service "${service.id}": ${validationErr.message}`, { errors: validationErr.errors }); + return errorResponse(res, `Invalid service "${service.id}": ${validationErr.message}`, 400, { errors: validationErr.errors }); } } - await ctx.servicesStateManager.write(services); - ctx.resyncHealthChecker?.().catch(() => {}); + await servicesStateManager.write(services); + resyncHealthChecker?.().catch(() => {}); - res.json({ - success: true, + success(res, { message: `Successfully imported ${services.length} services`, count: services.length }); }, 'services-import')); // Delete a service - router.delete('/services/:id', ctx.asyncHandler(async (req, res) => { + router.delete('/services/:id', asyncHandler(async (req, res) => { const { id } = req.params; - if (!await exists(ctx.SERVICES_FILE)) { - return ctx.errorResponse(res, 404, 'No services found'); + if (!await exists(SERVICES_FILE)) { + return errorResponse(res, 'No services found', 404); } let found = false; - await ctx.servicesStateManager.update(services => { + await servicesStateManager.update(services => { const initialLength = services.length; const filtered = services.filter(s => s.id !== id); found = filtered.length !== initialLength; @@ -407,39 +437,39 @@ module.exports = function(ctx) { }); if (!found) { - return ctx.errorResponse(res, 404, `Service "${id}" not found`); + return errorResponse(res, `Service "${id}" not found`, 404); } - ctx.resyncHealthChecker?.().catch(() => {}); - res.json({ success: true, message: `Service "${id}" removed from dashboard` }); + resyncHealthChecker?.().catch(() => {}); + success(res, { message: `Service "${id}" removed from dashboard` }); }, 'services-delete')); // Update service configuration (subdomain, port, IP, tailscale, name, logo) - router.post('/services/update', ctx.asyncHandler(async (req, res) => { + router.post('/services/update', asyncHandler(async (req, res) => { const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = req.body; if (!oldSubdomain || !newSubdomain) { - return ctx.errorResponse(res, 400, 'oldSubdomain and newSubdomain are required'); + return errorResponse(res, 'oldSubdomain and newSubdomain are required', 400); } if (!REGEX.SUBDOMAIN.test(oldSubdomain) || !REGEX.SUBDOMAIN.test(newSubdomain)) { - return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format'); + return errorResponse(res, '[DC-301] Invalid subdomain format', 400); } if (port && !isValidPort(port)) { - return ctx.errorResponse(res, 400, 'Invalid port number (must be 1-65535)'); + return errorResponse(res, 'Invalid port number (must be 1-65535)', 400); } if (ip && ip !== 'localhost' && !validatorLib.isIP(ip)) { - return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address'); + return errorResponse(res, '[DC-210] Invalid IP address', 400); } const results = { dns: null, caddy: null, services: null }; - const oldDomain = ctx.buildDomain(oldSubdomain); - const newDomain = ctx.buildDomain(newSubdomain); + const oldDomain = buildDomain(oldSubdomain); + const newDomain = buildDomain(newSubdomain); - let content = await ctx.caddy.read(); + let content = await caddy.read(); const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp( @@ -456,11 +486,11 @@ module.exports = function(ctx) { const finalIp = ip || existingIp; const finalPort = port || existingPort; - const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, { + const newConfig = caddy.generateConfig(newSubdomain, finalIp, finalPort, { tailscaleOnly: tailscaleOnly || false }); - const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig)); + const caddyResult = await caddy.modify(c => c.replace(siteBlockRegex, newConfig)); results.caddy = caddyResult.success ? 'updated' : `config saved, reload failed: ${caddyResult.error}`; } else { results.caddy = 'old config not found'; @@ -468,9 +498,9 @@ module.exports = function(ctx) { if (oldSubdomain !== newSubdomain) { try { - const dnsToken = ctx.dns.getToken(); - await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' }); - await ctx.dns.createRecord(newSubdomain, ip || 'localhost'); + const dnsToken = dns.getToken(); + await dns.call(siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' }); + await dns.createRecord(newSubdomain, ip || 'localhost'); results.dns = 'updated'; } catch (e) { results.dns = `failed: ${e.message}`; @@ -479,8 +509,8 @@ module.exports = function(ctx) { results.dns = 'unchanged'; } - if (await exists(ctx.SERVICES_FILE)) { - await ctx.servicesStateManager.update(services => { + if (await exists(SERVICES_FILE)) { + await servicesStateManager.update(services => { const serviceIndex = services.findIndex(s => s.id === oldSubdomain || s.url?.includes(oldSubdomain)); if (serviceIndex !== -1) { const existing = services[serviceIndex]; @@ -493,7 +523,7 @@ module.exports = function(ctx) { port: finalPort, ip: finalIp, tailscaleOnly: tailscaleOnly || false, - url: ctx.buildServiceUrl(newSubdomain) + url: buildServiceUrl(newSubdomain) }; if (name) services[serviceIndex].name = name; if (logo) services[serviceIndex].logo = logo; @@ -505,9 +535,8 @@ module.exports = function(ctx) { }); } - ctx.resyncHealthChecker?.().catch(() => {}); - res.json({ - success: true, + resyncHealthChecker?.().catch(() => {}); + success(res, { message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`, results }); diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 8b090f5..3545a7a 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -1188,7 +1188,21 @@ apiRouter.use(configRoutes(ctx)); apiRouter.use('/dns', dnsRoutes(ctx)); apiRouter.use('/notifications', notificationRoutes(ctx)); apiRouter.use('/containers', containerRoutes(ctx)); -apiRouter.use(serviceRoutes(ctx)); +apiRouter.use(serviceRoutes({ + servicesStateManager: ctx.servicesStateManager, + credentialManager: ctx.credentialManager, + siteConfig: ctx.siteConfig, + buildServiceUrl: ctx.buildServiceUrl, + buildDomain: ctx.buildDomain, + fetchT: ctx.fetchT, + asyncHandler: ctx.asyncHandler, + SERVICES_FILE: ctx.SERVICES_FILE, + log: ctx.log, + safeErrorMessage: ctx.safeErrorMessage, + resyncHealthChecker: ctx.resyncHealthChecker, + caddy: ctx.caddy, + dns: ctx.dns +})); apiRouter.use(healthRoutes(ctx)); apiRouter.use(monitoringRoutes(ctx)); apiRouter.use(updatesRoutes(ctx));