diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index ee7a3b3..2e457cb 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -7,29 +7,43 @@ const { isValidPort } = require('../../input-validator'); const { exists } = require('../../fs-helpers'); const platformPaths = require('../../platform-paths'); const { ValidationError } = require('../errors'); +/** + * Apps deployment routes factory + * @param {Object} deps - Explicit dependencies + * @param {Object} deps.docker - Docker client wrapper + * @param {Object} deps.caddy - Caddy client + * @param {Object} deps.credentialManager - Credential manager + * @param {Object} deps.servicesStateManager - Services state manager + * @param {Object} deps.portLockManager - Port lock manager + * @param {Function} deps.asyncHandler - Async route handler wrapper + * @param {Function} deps.errorResponse - Error response helper + * @param {Object} deps.log - Logger instance + * @param {Object} deps.helpers - Apps helpers module + * @returns {express.Router} + */ -module.exports = function(ctx, helpers) { +module.exports = function({ docker, caddy, credentialManager, servicesStateManager, portLockManager, asyncHandler, errorResponse, log, helpers }) { const router = express.Router(); async function deployDashCAStaticSite(template, userConfig) { const destPath = platformPaths.caCertDir; try { - ctx.log.info('deploy', 'DashCA: Starting static site deployment'); + log.info('deploy', 'DashCA: Starting static site deployment'); if (!await exists(destPath)) { await fsp.mkdir(destPath, { recursive: true }); - ctx.log.info('deploy', 'DashCA: Created destination directory', { path: destPath }); + log.info('deploy', 'DashCA: Created destination directory', { path: destPath }); } - ctx.log.info('deploy', 'DashCA: Verifying certificate files'); + log.info('deploy', 'DashCA: Verifying certificate files'); const rootCertExists = await exists(`${destPath}/root.crt`); const intermediateCertExists = await exists(`${destPath}/intermediate.crt`); - if (rootCertExists) ctx.log.info('deploy', 'DashCA: Root certificate found'); - else ctx.log.warn('deploy', 'DashCA: Root certificate not found', { expected: path.join(destPath, 'root.crt') }); - if (intermediateCertExists) ctx.log.info('deploy', 'DashCA: Intermediate certificate found'); + if (rootCertExists) log.info('deploy', 'DashCA: Root certificate found'); + else log.warn('deploy', 'DashCA: Root certificate not found', { expected: path.join(destPath, 'root.crt') }); + if (intermediateCertExists) log.info('deploy', 'DashCA: Intermediate certificate found'); const indexPath = path.join(destPath, 'index.html'); if (!await exists(indexPath)) { - ctx.log.info('deploy', 'DashCA: Creating minimal landing page'); + log.info('deploy', 'DashCA: Creating minimal landing page'); const minimalHtml = ` @@ -58,15 +72,15 @@ module.exports = function(ctx, helpers) { `; await fsp.writeFile(indexPath, minimalHtml); - ctx.log.info('deploy', 'DashCA: Created minimal landing page'); + log.info('deploy', 'DashCA: Created minimal landing page'); } else { - ctx.log.info('deploy', 'DashCA: Using existing index.html'); + log.info('deploy', 'DashCA: Using existing index.html'); } - ctx.log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath); - ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully'); + log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath); + log.info('deploy', 'DashCA: Static site deployment completed successfully'); } catch (error) { - ctx.log.error('deploy', 'DashCA deployment error', { error: error.message }); + log.error('deploy', 'DashCA deployment error', { error: error.message }); throw new Error(`DashCA deployment failed: ${error.message}`); } } @@ -82,9 +96,9 @@ module.exports = function(ctx, helpers) { let lockId = null; try { - ctx.log.info('deploy', 'Acquiring port locks', { ports: requestedPorts }); - lockId = await ctx.portLockManager.acquirePorts(requestedPorts); - ctx.log.info('deploy', 'Port locks acquired', { lockId }); + log.info('deploy', 'Acquiring port locks', { ports: requestedPorts }); + lockId = await portLockManager.acquirePorts(requestedPorts); + log.info('deploy', 'Port locks acquired', { lockId }); } catch (lockError) { throw new Error(`Failed to acquire port locks: ${lockError.message}`); } @@ -92,9 +106,9 @@ module.exports = function(ctx, helpers) { try { // Remove stale container with same name try { - const existingContainer = ctx.docker.client.getContainer(containerName); + const existingContainer = docker.client.getContainer(containerName); const info = await existingContainer.inspect(); - ctx.log.info('docker', 'Removing stale container', { containerName, status: info.State.Status }); + log.info('docker', 'Removing stale container', { containerName, status: info.State.Status }); await existingContainer.remove({ force: true }); await new Promise(r => setTimeout(r, 2000)); } catch (e) { @@ -144,43 +158,43 @@ module.exports = function(ctx, helpers) { } try { - ctx.log.info('docker', 'Pulling image', { image: processedTemplate.docker.image }); - await ctx.docker.pull(processedTemplate.docker.image); - ctx.log.info('docker', 'Image pulled successfully', { image: processedTemplate.docker.image }); + log.info('docker', 'Pulling image', { image: processedTemplate.docker.image }); + await docker.pull(processedTemplate.docker.image); + log.info('docker', 'Image pulled successfully', { image: processedTemplate.docker.image }); } catch (e) { - ctx.log.warn('docker', 'Image pull failed, checking if local image exists', { image: processedTemplate.docker.image, error: e.message }); + log.warn('docker', 'Image pull failed, checking if local image exists', { image: processedTemplate.docker.image, error: e.message }); try { - const images = await ctx.docker.client.listImages({ filters: { reference: [processedTemplate.docker.image] } }); + const images = await docker.client.listImages({ filters: { reference: [processedTemplate.docker.image] } }); if (images.length === 0) throw new Error(`[DC-201] Image ${processedTemplate.docker.image} not found locally and pull failed: ${e.message}`); - ctx.log.info('docker', 'Using existing local image', { image: processedTemplate.docker.image }); + log.info('docker', 'Using existing local image', { image: processedTemplate.docker.image }); } catch (listError) { throw new Error(`[DC-201] Failed to pull or find image ${processedTemplate.docker.image}: ${e.message}`); } } - const container = await ctx.docker.client.createContainer(containerConfig); + const container = await docker.client.createContainer(containerConfig); await container.start(); // Prune dangling images to prevent disk bloat try { - const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); + log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); } } catch (pruneErr) { - ctx.log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message }); + log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message }); } - await ctx.portLockManager.releasePorts(lockId); - ctx.log.info('deploy', 'Port locks released', { lockId }); + await portLockManager.releasePorts(lockId); + log.info('deploy', 'Port locks released', { lockId }); return container.id; } catch (deployError) { if (lockId) { try { - await ctx.portLockManager.releasePorts(lockId); - ctx.log.info('deploy', 'Port locks released after error', { lockId }); + await portLockManager.releasePorts(lockId); + log.info('deploy', 'Port locks released after error', { lockId }); } catch (releaseError) { - ctx.log.error('deploy', 'Failed to release port locks', { lockId, error: releaseError.message }); + log.error('deploy', 'Failed to release port locks', { lockId, error: releaseError.message }); } } throw deployError; @@ -188,7 +202,7 @@ module.exports = function(ctx, helpers) { } // Check for existing container before deployment - router.post('/apps/check-existing', ctx.asyncHandler(async (req, res) => { + router.post('/apps/check-existing', asyncHandler(async (req, res) => { const { appId } = req.body; const template = ctx.APP_TEMPLATES[appId]; if (!template) throw new ValidationError('Invalid app template'); @@ -201,7 +215,7 @@ module.exports = function(ctx, helpers) { }, 'check-existing')); // Deploy new app - router.post('/apps/deploy', ctx.asyncHandler(async (req, res) => { + router.post('/apps/deploy', asyncHandler(async (req, res) => { const { appId, config } = req.body; if (!appId || typeof appId !== 'string') { throw new ValidationError('appId is required'); @@ -213,10 +227,10 @@ module.exports = function(ctx, helpers) { throw new ValidationError('config.subdomain is required'); } try { - ctx.log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain }); + log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain }); const template = ctx.APP_TEMPLATES[appId]; if (!template) { - await ctx.logError('app-deploy', new Error('Invalid app template'), { appId, config }); + await logError('app-deploy', new Error('Invalid app template'), { appId, config }); throw new ValidationError('Invalid app template'); } @@ -226,7 +240,7 @@ module.exports = function(ctx, helpers) { } // Block reserved path names in subdirectory mode if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) { - return ctx.errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`); + return errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`); } } if (config.port && !isValidPort(config.port)) { @@ -236,7 +250,7 @@ module.exports = function(ctx, helpers) { if (!template.isStaticSite) { const allowedHostnames = ['localhost', 'host.docker.internal']; if (config.ip && !validatorLib.isIP(config.ip) && !allowedHostnames.includes(config.ip)) { - return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address. Use a valid IP (e.g., 192.168.x.x) or "localhost".'); + return errorResponse(res, 400, '[DC-210] Invalid IP address. Use a valid IP (e.g., 192.168.x.x) or "localhost".'); } if (!config.ip) config.ip = ctx.siteConfig.dnsServerIp || 'localhost'; } else { @@ -248,24 +262,24 @@ module.exports = function(ctx, helpers) { let usedExisting = false; if (template.isStaticSite) { - ctx.log.info('deploy', 'Deploying static site', { appId }); + log.info('deploy', 'Deploying static site', { appId }); if (appId === 'dashca') { await deployDashCAStaticSite(template, config); containerId = null; - ctx.log.info('deploy', 'Static site deployed', { appId }); + log.info('deploy', 'Static site deployed', { appId }); } else { throw new Error(`Unknown static site type: ${appId}`); } } else if (config.useExisting && config.existingContainerId) { containerId = config.existingContainerId; usedExisting = true; - ctx.log.info('deploy', 'Using existing container', { containerId }); + log.info('deploy', 'Using existing container', { containerId }); if (config.existingPort && !config.port) config.port = config.existingPort; } else { containerId = await deployContainer(appId, config, template); - ctx.log.info('deploy', 'Container deployed', { containerId }); + log.info('deploy', 'Container deployed', { containerId }); await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort); - ctx.log.info('deploy', 'Container is healthy', { containerId }); + log.info('deploy', 'Container is healthy', { containerId }); } const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain; @@ -275,11 +289,11 @@ module.exports = function(ctx, helpers) { if (config.createDns && !isSubdirectoryMode) { try { await ctx.dns.createRecord(config.subdomain, config.ip); - ctx.log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip }); + log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip }); } catch (dnsError) { - await ctx.logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip }); + await logError('app-deploy-dns', dnsError, { appId, subdomain: config.subdomain, ip: config.ip }); dnsWarning = `DNS creation failed: ${dnsError.message}. You may need to create the DNS record manually.`; - ctx.log.warn('deploy', 'DNS creation failed during deploy', { error: dnsError.message }); + log.warn('deploy', 'DNS creation failed during deploy', { error: dnsError.message }); } } @@ -298,7 +312,7 @@ module.exports = function(ctx, helpers) { } caddyConfig = helpers.generateStaticSiteConfig(config.subdomain, sitePath, caddyOptions); } else { - caddyConfig = ctx.caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions); + caddyConfig = caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions); } // Write Caddy config (subdirectory: inject into main block; subdomain: append as new block) @@ -308,7 +322,7 @@ module.exports = function(ctx, helpers) { } else { await helpers.addCaddyConfig(config.subdomain, caddyConfig); } - ctx.log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), routingMode: ctx.siteConfig.routingMode, tailscaleOnly: config.tailscaleOnly || false }); + log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), routingMode: ctx.siteConfig.routingMode, tailscaleOnly: config.tailscaleOnly || false }); // Build service URL based on routing mode const serviceUrl = ctx.buildServiceUrl(config.subdomain); @@ -361,7 +375,7 @@ module.exports = function(ctx, helpers) { deployedAt: new Date().toISOString(), deploymentManifest }); - ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); + log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); const response = { success: true, containerId, usedExisting, @@ -378,11 +392,11 @@ module.exports = function(ctx, helpers) { res.json(response); } catch (error) { - await ctx.logError('app-deploy', error, { appId, config }); - ctx.log.error('deploy', 'Deployment failed', { appId, error: error.message }); + await logError('app-deploy', error, { appId, config }); + log.error('deploy', 'Deployment failed', { appId, error: error.message }); const template = ctx.APP_TEMPLATES[appId]; ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error'); - ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); + errorResponse(res, 500, ctx.safeErrorMessage(error)); } }, 'apps-deploy')); diff --git a/dashcaddy-api/routes/apps/helpers.js b/dashcaddy-api/routes/apps/helpers.js index 6e9d76b..450e07f 100644 --- a/dashcaddy-api/routes/apps/helpers.js +++ b/dashcaddy-api/routes/apps/helpers.js @@ -6,12 +6,23 @@ const { REGEX, DOCKER } = require('../../constants'); const { exists } = require('../../fs-helpers'); const platformPaths = require('../../platform-paths'); -module.exports = function(ctx) { +/** + * Apps helpers factory + * @param {Object} deps - Explicit dependencies + * @param {Object} deps.docker - Docker client wrapper + * @param {Object} deps.caddy - Caddy client + * @param {Object} deps.credentialManager - Credential manager + * @param {Object} deps.servicesStateManager - Services state manager + * @param {Function} deps.fetchT - Timeout-wrapped fetch + * @param {Object} deps.log - Logger instance + * @returns {Object} Helper functions + */ +module.exports = function({ docker, caddy, credentialManager, servicesStateManager, fetchT, log }) { async function checkPortConflicts(ports, excludeContainerName = null) { const conflicts = []; try { - const containers = await ctx.docker.client.listContainers({ all: true }); + const containers = await docker.client.listContainers({ all: true }); for (const container of containers) { if (excludeContainerName && container.Names.some(n => n === `/${excludeContainerName}`)) continue; if (container.State !== 'running') continue; @@ -27,14 +38,14 @@ module.exports = function(ctx) { } } } catch (e) { - ctx.log.warn('docker', 'Could not check port conflicts', { error: e.message }); + log.warn('docker', 'Could not check port conflicts', { error: e.message }); } return conflicts; } async function findExistingContainerByImage(template) { try { - const containers = await ctx.docker.client.listContainers({ all: false }); + const containers = await docker.client.listContainers({ all: false }); const templateImage = template.docker.image.split(':')[0]; for (const container of containers) { const containerImage = container.Image.split(':')[0]; @@ -53,7 +64,7 @@ module.exports = function(ctx) { } return null; } catch (e) { - ctx.log.warn('docker', 'Could not check for existing containers', { error: e.message }); + log.warn('docker', 'Could not check for existing containers', { error: e.message }); return null; } } @@ -140,7 +151,7 @@ module.exports = function(ctx) { normalizedHost === root || normalizedHost.startsWith(root + path.sep) ); if (!isAllowed) { - ctx.log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots }); + log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots }); return vol; // Keep original volume, don't apply unsafe override } return `${toDockerDesktopPath(override.hostPath)}:${containerPath}`; @@ -243,39 +254,39 @@ module.exports = function(ctx) { for (let i = 0; i < maxAttempts; i++) { try { - const container = ctx.docker.client.getContainer(containerId); + const container = docker.client.getContainer(containerId); const info = await container.inspect(); if (info.State.Running) { if (info.State.Health) { if (info.State.Health.Status === 'healthy') { - ctx.log.info('docker', 'Container is healthy (Docker health check)', { containerId }); + log.info('docker', 'Container is healthy (Docker health check)', { containerId }); return true; } } else if (healthPath && port && httpCheckFailed < 5) { try { - const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, { + const response = await fetchT(`http://localhost:${port}${healthPath}`, { signal: AbortSignal.timeout(3000), redirect: 'manual' }); if (response.ok || (response.status >= 300 && response.status < 400)) { - ctx.log.info('docker', 'Health check passed', { containerId, status: response.status }); + log.info('docker', 'Health check passed', { containerId, status: response.status }); return true; } } catch (e) { httpCheckFailed++; - ctx.log.debug('docker', 'HTTP health check failed', { attempt: httpCheckFailed, error: e.message }); + log.debug('docker', 'HTTP health check failed', { attempt: httpCheckFailed, error: e.message }); } } else { if (i >= 5) { - ctx.log.info('docker', 'Container is running', { containerId, waitedSeconds: i * delay / 1000 }); + log.info('docker', 'Container is running', { containerId, waitedSeconds: i * delay / 1000 }); return true; } } } } catch (e) { - ctx.log.debug('docker', 'Health check attempt failed', { attempt: i + 1, error: e.message }); + log.debug('docker', 'Health check attempt failed', { attempt: i + 1, error: e.message }); } if (i < maxAttempts - 1) { - ctx.log.debug('docker', 'Waiting for container to be healthy', { attempt: i + 1, maxAttempts }); + log.debug('docker', 'Waiting for container to be healthy', { attempt: i + 1, maxAttempts }); await new Promise(resolve => setTimeout(resolve, delay)); } } @@ -284,15 +295,15 @@ module.exports = function(ctx) { async function addCaddyConfig(subdomain, config) { const domain = ctx.buildDomain(subdomain); - const existing = await ctx.caddy.read(); + const existing = await caddy.read(); if (existing.includes(`${domain} {`)) { - ctx.log.info('caddy', 'Caddy config already exists, skipping add', { domain }); - await ctx.caddy.reload(existing); + log.info('caddy', 'Caddy config already exists, skipping add', { domain }); + await caddy.reload(existing); return; } - const result = await ctx.caddy.modify(c => c + `\n${config}\n`); + const result = await caddy.modify(c => c + `\n${config}\n`); if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`); - await ctx.caddy.verifySite(domain); + await caddy.verifySite(domain); } // Reserved paths that cannot be used as subpath names in subdirectory mode @@ -303,7 +314,7 @@ module.exports = function(ctx) { async function ensureMainDomainBlock() { if (ctx.siteConfig.routingMode !== 'subdirectory' || !ctx.siteConfig.domain) return; - const content = await ctx.caddy.read(); + const content = await caddy.read(); const domain = ctx.siteConfig.domain; const ROUTE_MARKER = '# === DashCaddy App Routes ==='; @@ -312,7 +323,7 @@ module.exports = function(ctx) { // Domain block exists but lacks markers — inject them if (content.includes(`${domain} {`)) { - const result = await ctx.caddy.modify(c => { + const result = await caddy.modify(c => { // Insert markers before the final catch-all handle block inside the domain block const domainStart = c.indexOf(`${domain} {`); // Find standalone "handle {" (catch-all SPA fallback) — match tabs or spaces @@ -325,7 +336,7 @@ module.exports = function(ctx) { return c.slice(0, handleIdx) + markerBlock + c.slice(handleIdx); }); if (result.success) { - ctx.log.info('caddy', 'Injected route markers into existing domain block', { domain }); + log.info('caddy', 'Injected route markers into existing domain block', { domain }); } return; } @@ -335,9 +346,9 @@ module.exports = function(ctx) { const apiPort = process.env.PORT || 3001; const block = `\n${domain} {\n root * ${dashboardRoot}\n encode gzip\n\n handle /api/* {\n reverse_proxy localhost:${apiPort}\n }\n\n handle /probe/* {\n reverse_proxy localhost:${apiPort}\n }\n\n ${ROUTE_MARKER}\n # === End App Routes ===\n\n handle {\n @notFile not file {path}\n rewrite @notFile /index.html\n file_server\n }\n}\n`; - const result = await ctx.caddy.modify(c => c + block); + const result = await caddy.modify(c => c + block); if (result.success) { - ctx.log.info('caddy', 'Created main domain block with route markers', { domain }); + log.info('caddy', 'Created main domain block with route markers', { domain }); } else { throw new Error(`[DC-303] Failed to create main domain block for ${domain}: ${result.error}`); } @@ -349,9 +360,9 @@ module.exports = function(ctx) { const endMarker = `# --- End: ${subdomain} ---`; const END_ROUTE_MARKER = '# === End App Routes ==='; - const result = await ctx.caddy.modify(content => { + const result = await caddy.modify(content => { if (content.includes(marker)) { - ctx.log.info('caddy', 'Subpath config already exists, skipping', { subdomain }); + log.info('caddy', 'Subpath config already exists, skipping', { subdomain }); return null; } @@ -378,7 +389,7 @@ module.exports = function(ctx) { const marker = `# --- DashCaddy: ${subdomain} ---`; const endMarker = `# --- End: ${subdomain} ---`; - return await ctx.caddy.modify(content => { + return await caddy.modify(content => { const startIdx = content.indexOf(marker); if (startIdx === -1) return null; diff --git a/dashcaddy-api/routes/apps/index.js b/dashcaddy-api/routes/apps/index.js index f28226c..a14798e 100644 --- a/dashcaddy-api/routes/apps/index.js +++ b/dashcaddy-api/routes/apps/index.js @@ -5,14 +5,35 @@ const initRemoval = require('./removal'); const initTemplates = require('./templates'); const initRestore = require('./restore'); +/** + * Apps routes aggregator + * Assembles all apps sub-routes with their dependencies + * @param {Object} ctx - Application context (for backward compatibility) + * @returns {express.Router} + */ module.exports = function(ctx) { const router = express.Router(); - const helpers = initHelpers(ctx); - router.use(initDeploy(ctx, helpers)); - router.use(initRemoval(ctx, helpers)); - router.use(initTemplates(ctx, helpers)); - router.use(initRestore(ctx, helpers)); + // Extract dependencies from context + const deps = { + docker: ctx.docker, + caddy: ctx.caddy, + credentialManager: ctx.credentialManager, + servicesStateManager: ctx.servicesStateManager, + portLockManager: ctx.portLockManager, + asyncHandler: ctx.asyncHandler, + errorResponse: ctx.errorResponse, + log: ctx.log + }; + + // Initialize helpers with dependencies + const helpers = initHelpers(deps); + + // Mount sub-routes with explicit dependencies + router.use(initDeploy({ ...deps, helpers })); + router.use(initRemoval({ ...deps, helpers })); + router.use(initTemplates({ ...deps, helpers })); + router.use(initRestore({ ...deps, helpers })); return router; }; diff --git a/dashcaddy-api/routes/apps/removal.js b/dashcaddy-api/routes/apps/removal.js index 2e14356..7643dfb 100644 --- a/dashcaddy-api/routes/apps/removal.js +++ b/dashcaddy-api/routes/apps/removal.js @@ -1,35 +1,46 @@ const express = require('express'); const { exists } = require('../../fs-helpers'); -module.exports = function(ctx, helpers) { +module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, log, helpers }) { const router = express.Router(); // Remove deployed app - router.delete('/apps/:appId', ctx.asyncHandler(async (req, res) => { +/** + * Apps removal routes factory + * @param {Object} deps - Explicit dependencies + * @param {Object} deps.docker - Docker client wrapper + * @param {Object} deps.caddy - Caddy client + * @param {Object} deps.servicesStateManager - Services state manager + * @param {Function} deps.asyncHandler - Async route handler wrapper + * @param {Object} deps.log - Logger instance + * @param {Object} deps.helpers - Apps helpers module + * @returns {express.Router} + */ + router.delete('/apps/:appId', asyncHandler(async (req, res) => { const { appId } = req.params; const { containerId, subdomain, ip, deleteContainer } = req.query; const shouldDeleteContainer = deleteContainer === 'true'; const results = { container: null, dns: null, caddy: null, service: null }; try { - ctx.log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer }); + log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer }); if (containerId && shouldDeleteContainer) { try { - const container = ctx.docker.client.getContainer(containerId); - try { await container.stop(); ctx.log.info('docker', 'Container stopped', { containerId }); } - catch (stopError) { ctx.log.debug('docker', 'Container stop note', { containerId, note: stopError.message }); } + const container = docker.client.getContainer(containerId); + try { await container.stop(); log.info('docker', 'Container stopped', { containerId }); } + catch (stopError) { log.debug('docker', 'Container stop note', { containerId, note: stopError.message }); } await container.remove({ force: true }); results.container = 'removed'; - ctx.log.info('docker', 'Container removed', { containerId }); + log.info('docker', 'Container removed', { containerId }); // Prune dangling images after removal try { - const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); + log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); } } catch (pruneErr) { - ctx.log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message }); + log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message }); } } catch (error) { results.container = error.message.includes('no such container') ? 'already removed' : error.message; @@ -53,7 +64,7 @@ module.exports = function(ctx, helpers) { token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp }); results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed'); - ctx.log.info('dns', 'DNS record removal', { result: results.dns }); + log.info('dns', 'DNS record removal', { result: results.dns }); } catch (error) { results.dns = error.message; } @@ -66,7 +77,7 @@ module.exports = function(ctx, helpers) { if (shouldDeleteContainer && subdomain) { try { // Check if this service was deployed in subdirectory mode - const services = await ctx.servicesStateManager.read(); + const services = await servicesStateManager.read(); const serviceList = Array.isArray(services) ? services : []; const service = serviceList.find(s => s.id === subdomain); @@ -79,14 +90,14 @@ module.exports = function(ctx, helpers) { const domain = ctx.buildDomain(subdomain); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g'); - const caddyResult = await ctx.caddy.modify(currentContent => { + const caddyResult = await caddy.modify(currentContent => { const replaced = currentContent.replace(siteBlockRegex, '\n'); if (replaced.length === currentContent.length) return null; return replaced.replace(/\n{3,}/g, '\n\n'); }); results.caddy = caddyResult.success ? 'removed' : (caddyResult.rolledBack ? 'removed (reload failed)' : 'not found'); } - ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy }); + log.info('caddy', 'Caddy config removal', { result: results.caddy }); } catch (error) { results.caddy = error.message; } @@ -97,7 +108,7 @@ module.exports = function(ctx, helpers) { try { if (await exists(ctx.SERVICES_FILE)) { let removed = false; - await ctx.servicesStateManager.update(services => { + await servicesStateManager.update(services => { const initialLength = services.length; const filtered = services.filter(s => s.id !== subdomain); removed = filtered.length !== initialLength; @@ -105,15 +116,15 @@ module.exports = function(ctx, helpers) { }); results.service = removed ? 'removed' : 'not found'; } - ctx.log.info('deploy', 'Service config removal', { result: results.service }); + log.info('deploy', 'Service config removal', { result: results.service }); } catch (error) { results.service = error.message; } res.json({ success: true, message: `App ${appId} removal completed`, results }); } catch (error) { - await ctx.logError('app-removal', error); - ctx.errorResponse(res, 500, ctx.safeErrorMessage(error), { results }); + await logError('app-removal', error); + errorResponse(res, 500, ctx.safeErrorMessage(error), { results }); } }, 'apps-delete')); diff --git a/dashcaddy-api/routes/apps/restore.js b/dashcaddy-api/routes/apps/restore.js index 91158a0..6b72c10 100644 --- a/dashcaddy-api/routes/apps/restore.js +++ b/dashcaddy-api/routes/apps/restore.js @@ -1,7 +1,18 @@ const express = require('express'); const { DOCKER } = require('../../constants'); -module.exports = function(ctx, helpers) { +/** + * Apps restore routes factory + * @param {Object} deps - Explicit dependencies + * @param {Object} deps.docker - Docker client wrapper + * @param {Object} deps.caddy - Caddy client + * @param {Object} deps.servicesStateManager - Services state manager + * @param {Function} deps.asyncHandler - Async route handler wrapper + * @param {Object} deps.log - Logger instance + * @param {Object} deps.helpers - Apps helpers module + * @returns {express.Router} + */ +module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, log, helpers }) { const router = express.Router(); /** @@ -9,16 +20,16 @@ module.exports = function(ctx, helpers) { * Pulls image, creates container, starts it, recreates Caddy config. * Skips if container is already running. */ - router.post('/apps/:appId/restore', ctx.asyncHandler(async (req, res) => { + router.post('/apps/:appId/restore', asyncHandler(async (req, res) => { const { appId } = req.params; - const services = await ctx.servicesStateManager.read(); + const services = await servicesStateManager.read(); const service = services.find(s => s.id === appId); if (!service) { - return ctx.errorResponse(res, 404, `Service "${appId}" not found in services.json`); + return errorResponse(res, 404, `Service "${appId}" not found in services.json`); } if (!service.deploymentManifest) { - return ctx.errorResponse(res, 400, `Service "${appId}" has no deployment manifest — it was deployed before the manifest feature was added. Redeploy it manually to create a manifest.`); + return errorResponse(res, 400, `Service "${appId}" has no deployment manifest — it was deployed before the manifest feature was added. Redeploy it manually to create a manifest.`); } const result = await restoreService(service); @@ -29,8 +40,8 @@ module.exports = function(ctx, helpers) { * Restore all services that have deployment manifests. * Returns per-service results. */ - router.post('/apps/restore-all', ctx.asyncHandler(async (req, res) => { - const services = await ctx.servicesStateManager.read(); + router.post('/apps/restore-all', asyncHandler(async (req, res) => { + const services = await servicesStateManager.read(); const restoreable = services.filter(s => s.deploymentManifest); if (restoreable.length === 0) { @@ -70,8 +81,8 @@ module.exports = function(ctx, helpers) { /** * List all services and their restore status. */ - router.get('/apps/restore-status', ctx.asyncHandler(async (req, res) => { - const services = await ctx.servicesStateManager.read(); + router.get('/apps/restore-status', asyncHandler(async (req, res) => { + const services = await servicesStateManager.read(); const status = []; for (const service of services) { @@ -87,7 +98,7 @@ module.exports = function(ctx, helpers) { // Check if container is currently running if (service.containerId) { try { - const container = ctx.docker.client.getContainer(service.containerId); + const container = docker.client.getContainer(service.containerId); const info = await container.inspect(); entry.containerRunning = info.State.Running; } catch (e) { @@ -108,11 +119,11 @@ module.exports = function(ctx, helpers) { const manifest = service.deploymentManifest; const template = ctx.APP_TEMPLATES[manifest.templateId]; - ctx.log.info('restore', `Restoring service: ${service.name}`, { id: service.id, templateId: manifest.templateId }); + log.info('restore', `Restoring service: ${service.name}`, { id: service.id, templateId: manifest.templateId }); // Static sites: just recreate Caddy config if (template?.isStaticSite) { - ctx.log.info('restore', `Restoring static site Caddy config: ${service.name}`); + log.info('restore', `Restoring static site Caddy config: ${service.name}`); const caddyOptions = { tailscaleOnly: manifest.caddy.tailscaleOnly, allowedIPs: manifest.caddy.allowedIPs, @@ -132,10 +143,10 @@ module.exports = function(ctx, helpers) { // Docker container: check if already running if (service.containerId) { try { - const existing = ctx.docker.client.getContainer(service.containerId); + const existing = docker.client.getContainer(service.containerId); const info = await existing.inspect(); if (info.State.Running) { - ctx.log.info('restore', `Container already running, skipping: ${service.name}`); + log.info('restore', `Container already running, skipping: ${service.name}`); return { id: service.id, name: service.name, @@ -151,11 +162,11 @@ module.exports = function(ctx, helpers) { // Also check by name (container ID may have changed) const containerName = `${DOCKER.CONTAINER_PREFIX}${manifest.config.subdomain}`; try { - const byName = ctx.docker.client.getContainer(containerName); + const byName = docker.client.getContainer(containerName); const info = await byName.inspect(); if (info.State.Running) { // Update the service entry with the current container ID - await ctx.servicesStateManager.update(services => { + await servicesStateManager.update(services => { const svc = services.find(s => s.id === service.id); if (svc) svc.containerId = info.Id; return services; @@ -183,18 +194,18 @@ module.exports = function(ctx, helpers) { } // Pull image - ctx.log.info('restore', `Pulling image: ${manifest.container.image}`); + log.info('restore', `Pulling image: ${manifest.container.image}`); try { - await ctx.docker.pull(manifest.container.image); + await docker.pull(manifest.container.image); } catch (e) { // Check if image exists locally - const images = await ctx.docker.client.listImages({ + const images = await docker.client.listImages({ filters: { reference: [manifest.container.image] } }); if (images.length === 0) { throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`); } - ctx.log.warn('restore', `Pull failed, using local image: ${manifest.container.image}`); + log.warn('restore', `Pull failed, using local image: ${manifest.container.image}`); } // Build container config from manifest @@ -231,10 +242,10 @@ module.exports = function(ctx, helpers) { } // Create and start container - ctx.log.info('restore', `Creating container: ${containerName}`); - const container = await ctx.docker.client.createContainer(containerConfig); + log.info('restore', `Creating container: ${containerName}`); + const container = await docker.client.createContainer(containerConfig); await container.start(); - ctx.log.info('restore', `Container started: ${containerName}`); + log.info('restore', `Container started: ${containerName}`); // Recreate Caddy config const port = manifest.config.port; @@ -245,19 +256,19 @@ module.exports = function(ctx, helpers) { }; if (manifest.caddy.routingMode === 'subdirectory') { - const caddyConfig = ctx.caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions); + const caddyConfig = caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions); try { await helpers.ensureMainDomainBlock(); await helpers.addSubpathConfig(manifest.config.subdomain, caddyConfig); } catch (e) { - ctx.log.warn('restore', `Caddy config may already exist: ${e.message}`); + log.warn('restore', `Caddy config may already exist: ${e.message}`); } } else { - const caddyConfig = ctx.caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions); + const caddyConfig = caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions); try { await helpers.addCaddyConfig(manifest.config.subdomain, caddyConfig); } catch (e) { - ctx.log.warn('restore', `Caddy config may already exist: ${e.message}`); + log.warn('restore', `Caddy config may already exist: ${e.message}`); } } @@ -265,14 +276,14 @@ module.exports = function(ctx, helpers) { if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') { try { await ctx.dns.createRecord(manifest.config.subdomain, manifest.config.ip); - ctx.log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain }); + log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain }); } catch (e) { - ctx.log.warn('restore', `DNS recreation failed: ${e.message}`); + log.warn('restore', `DNS recreation failed: ${e.message}`); } } // Update the service entry with the new container ID - await ctx.servicesStateManager.update(services => { + await servicesStateManager.update(services => { const svc = services.find(s => s.id === service.id); if (svc) { svc.containerId = container.id; diff --git a/dashcaddy-api/routes/apps/templates.js b/dashcaddy-api/routes/apps/templates.js index 5e725a4..1242916 100644 --- a/dashcaddy-api/routes/apps/templates.js +++ b/dashcaddy-api/routes/apps/templates.js @@ -1,12 +1,20 @@ const express = require('express'); const { exists } = require('../../fs-helpers'); +/** + * Apps templates routes factory + * @param {Object} deps - Explicit dependencies + * @param {Object} deps.servicesStateManager - Services state manager + * @param {Function} deps.asyncHandler - Async route handler wrapper + * @param {Object} deps.helpers - Apps helpers module + * @returns {express.Router} + */ const { REGEX } = require('../../constants'); -module.exports = function(ctx, helpers) { +module.exports = function({ servicesStateManager, asyncHandler, helpers }) { const router = express.Router(); // Get available app templates - router.get('/apps/templates', ctx.asyncHandler(async (req, res) => { + router.get('/apps/templates', asyncHandler(async (req, res) => { res.json({ success: true, templates: ctx.APP_TEMPLATES, @@ -16,7 +24,7 @@ module.exports = function(ctx, helpers) { }, 'apps-templates')); // Get specific app template - router.get('/apps/templates/:appId', ctx.asyncHandler(async (req, res) => { + router.get('/apps/templates/:appId', asyncHandler(async (req, res) => { const { appId } = req.params; const template = ctx.APP_TEMPLATES[appId]; if (!template) { @@ -27,7 +35,7 @@ module.exports = function(ctx, helpers) { }, 'apps-template-detail')); // Check port availability - router.get('/apps/ports/:port/check', ctx.asyncHandler(async (req, res) => { + router.get('/apps/ports/:port/check', asyncHandler(async (req, res) => { const port = req.params.port; const conflicts = await helpers.checkPortConflicts([port]); if (conflicts.length > 0) { @@ -39,21 +47,21 @@ module.exports = function(ctx, helpers) { }, 'check-port')); // Get suggested available port - router.get('/apps/ports/:basePort/suggest', ctx.asyncHandler(async (req, res) => { + router.get('/apps/ports/:basePort/suggest', asyncHandler(async (req, res) => { const basePort = parseInt(req.params.basePort) || 8080; const maxAttempts = 100; - const usedPorts = await ctx.docker.getUsedPorts(); + const usedPorts = await docker.getUsedPorts(); for (let port = basePort; port < basePort + maxAttempts; port++) { if (!usedPorts.has(port)) { res.json({ success: true, suggestedPort: port, basePort }); return; } } - ctx.errorResponse(res, 400, `No available ports found in range ${basePort}-${basePort + maxAttempts}`); + errorResponse(res, 400, `No available ports found in range ${basePort}-${basePort + maxAttempts}`); }, 'suggest-port')); // Update subdomain for deployed app - router.post('/apps/update-subdomain', ctx.asyncHandler(async (req, res) => { + router.post('/apps/update-subdomain', asyncHandler(async (req, res) => { const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body; if (!oldSubdomain || typeof oldSubdomain !== 'string') { throw new ValidationError('oldSubdomain is required'); @@ -64,7 +72,7 @@ module.exports = function(ctx, helpers) { if (!REGEX.SUBDOMAIN.test(newSubdomain)) { throw new ValidationError('[DC-301] Invalid subdomain format for newSubdomain'); } - ctx.log.info('deploy', 'Updating subdomain', { oldSubdomain, newSubdomain }); + log.info('deploy', 'Updating subdomain', { oldSubdomain, newSubdomain }); const results = { oldDns: null, newDns: null, caddy: null, service: null }; if (oldSubdomain && ctx.dns.getToken()) { @@ -74,10 +82,10 @@ module.exports = function(ctx, helpers) { token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost' }); results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage; - ctx.log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); + log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); } catch (error) { results.oldDns = `failed: ${error.message}`; - ctx.log.warn('dns', 'Old DNS deletion warning', { error: error.message }); + log.warn('dns', 'Old DNS deletion warning', { error: error.message }); } } @@ -85,22 +93,22 @@ module.exports = function(ctx, helpers) { try { await ctx.dns.createRecord(newSubdomain, ip || 'localhost'); results.newDns = 'created'; - ctx.log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) }); + log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) }); } catch (error) { results.newDns = `failed: ${error.message}`; - ctx.log.warn('dns', 'New DNS creation warning', { error: error.message }); + log.warn('dns', 'New DNS creation warning', { error: error.message }); } } try { - if (await exists(ctx.caddy.filePath)) { + if (await exists(caddy.filePath)) { const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain); const newDomain = newSubdomain.includes('.') ? newSubdomain : ctx.buildDomain(newSubdomain); const escapedOld = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const oldBlockRegex = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g'); - const content = await ctx.caddy.read(); + const content = await caddy.read(); if (oldBlockRegex.test(content)) { - const caddyResult = await ctx.caddy.modify(c => { + const caddyResult = await caddy.modify(c => { const re = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g'); return c.replace(re, match => match.replace(oldDomain, newDomain)); }); @@ -113,17 +121,17 @@ module.exports = function(ctx, helpers) { } } catch (error) { results.caddy = `failed: ${error.message}`; - ctx.log.error('caddy', 'Caddy update error', { error: error.message }); + log.error('caddy', 'Caddy update error', { error: error.message }); } try { if (await exists(ctx.SERVICES_FILE)) { - await ctx.servicesStateManager.update(services => { + await servicesStateManager.update(services => { const serviceIndex = services.findIndex(s => s.id === oldSubdomain || s.id === serviceId); if (serviceIndex !== -1) { services[serviceIndex].id = newSubdomain; results.service = 'updated'; - ctx.log.info('deploy', 'Service config updated in services.json'); + log.info('deploy', 'Service config updated in services.json'); } else { results.service = 'not found'; } @@ -132,7 +140,7 @@ module.exports = function(ctx, helpers) { } } catch (error) { results.service = `failed: ${error.message}`; - ctx.log.warn('deploy', 'Service update warning', { error: error.message || String(error) }); + log.warn('deploy', 'Service update warning', { error: error.message || String(error) }); } res.json({