const express = require('express'); const { exists } = require('../../fs-helpers'); module.exports = function(ctx) { const { docker, caddy, servicesStateManager, asyncHandler, errorResponse, log, helpers } = ctx; const router = express.Router(); /** * 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 { log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer }); if (containerId && shouldDeleteContainer) { try { 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'; log.info('docker', 'Container removed', { containerId }); // Prune dangling images after removal try { const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' }); } } catch (pruneErr) { 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; } } else if (containerId && !shouldDeleteContainer) { results.container = 'kept (user choice)'; } if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) { try { const domain = ctx.buildDomain(subdomain); const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', { token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true' }); let recordIp = ip || 'localhost'; if (getResult.status === 'ok' && getResult.response?.records) { const aRecord = getResult.response.records.find(r => r.type === 'A'); if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress; } const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp }); results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed'); log.info('dns', 'DNS record removal', { result: results.dns }); } catch (error) { results.dns = error.message; } } else if (!shouldDeleteContainer) { results.dns = 'kept (user choice)'; } else { results.dns = 'skipped (no subdomain or token)'; } if (shouldDeleteContainer && subdomain) { try { // Check if this service was deployed in subdirectory mode const services = await servicesStateManager.read(); const serviceList = Array.isArray(services) ? services : []; const service = serviceList.find(s => s.id === subdomain); if (service?.routingMode === 'subdirectory') { // Subdirectory mode: remove handle block from inside main domain block const subResult = await helpers.removeSubpathConfig(subdomain); results.caddy = subResult?.success ? 'removed' : 'not found'; } else { // Subdomain mode: remove standalone domain block const domain = ctx.buildDomain(subdomain); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g'); 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'); } log.info('caddy', 'Caddy config removal', { result: results.caddy }); } catch (error) { results.caddy = error.message; } } else if (!shouldDeleteContainer) { results.caddy = 'kept (user choice)'; } try { if (await exists(ctx.SERVICES_FILE)) { let removed = false; await servicesStateManager.update(services => { const initialLength = services.length; const filtered = services.filter(s => s.id !== subdomain); removed = filtered.length !== initialLength; return filtered; }); results.service = removed ? 'removed' : 'not found'; } 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 logError('app-removal', error); errorResponse(res, 500, ctx.safeErrorMessage(error), { results }); } }, 'apps-delete')); return router; };