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) { const { servicesStateManager, asyncHandler, helpers, docker, caddy, log, errorResponse } = ctx; const router = express.Router(); // Get available app templates router.get('/apps/templates', asyncHandler(async (req, res) => { res.json({ success: true, templates: ctx.APP_TEMPLATES, categories: ctx.TEMPLATE_CATEGORIES, difficultyLevels: ctx.DIFFICULTY_LEVELS }); }, 'apps-templates')); // Get specific app template router.get('/apps/templates/:appId', asyncHandler(async (req, res) => { const { appId } = req.params; const template = ctx.APP_TEMPLATES[appId]; if (!template) { const { NotFoundError } = require('../../errors'); throw new NotFoundError('App template'); } res.json({ success: true, template }); }, 'apps-template-detail')); // Check port availability 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) { const conflict = conflicts[0]; res.json({ available: false, port, conflict: { usedBy: conflict.usedBy, app: conflict.app, containerId: conflict.containerId } }); } else { res.json({ available: true, port }); } }, 'check-port')); // Get suggested available port router.get('/apps/ports/:basePort/suggest', asyncHandler(async (req, res) => { const basePort = parseInt(req.params.basePort) || 8080; const maxAttempts = 100; 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; } } errorResponse(res, 400, `No available ports found in range ${basePort}-${basePort + maxAttempts}`); }, 'suggest-port')); // Update subdomain for deployed app 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'); } if (!newSubdomain || typeof newSubdomain !== 'string') { throw new ValidationError('newSubdomain is required'); } if (!REGEX.SUBDOMAIN.test(newSubdomain)) { throw new ValidationError('[DC-301] Invalid subdomain format for newSubdomain'); } log.info('deploy', 'Updating subdomain', { oldSubdomain, newSubdomain }); const results = { oldDns: null, newDns: null, caddy: null, service: null }; if (oldSubdomain && ctx.dns.getToken()) { try { const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain); const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost' }); results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage; log.info('dns', 'Old DNS record deleted', { domain: oldDomain }); } catch (error) { results.oldDns = `failed: ${error.message}`; log.warn('dns', 'Old DNS deletion warning', { error: error.message }); } } if (newSubdomain && ctx.dns.getToken()) { try { await ctx.dns.createRecord(newSubdomain, ip || 'localhost'); results.newDns = 'created'; log.info('dns', 'New DNS record created', { domain: ctx.buildDomain(newSubdomain) }); } catch (error) { results.newDns = `failed: ${error.message}`; log.warn('dns', 'New DNS creation warning', { error: error.message }); } } try { 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 caddy.read(); if (oldBlockRegex.test(content)) { const caddyResult = await caddy.modify(c => { const re = new RegExp(`${escapedOld}(?::\\d+)?\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`, 'g'); return c.replace(re, match => match.replace(oldDomain, newDomain)); }); results.caddy = caddyResult.success ? 'updated' : 'updated (reload failed)'; } else { results.caddy = 'old config not found'; } } else { results.caddy = 'caddyfile not found'; } } catch (error) { results.caddy = `failed: ${error.message}`; log.error('caddy', 'Caddy update error', { error: error.message }); } try { if (await exists(ctx.SERVICES_FILE)) { 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'; log.info('deploy', 'Service config updated in services.json'); } else { results.service = 'not found'; } return services; }); } } catch (error) { results.service = `failed: ${error.message}`; log.warn('deploy', 'Service update warning', { error: error.message || String(error) }); } res.json({ success: true, message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`, newUrl: `https://${ctx.buildDomain(newSubdomain)}`, results }); }, 'update-subdomain')); return router; };