Files
dashcaddy/dashcaddy-api/routes/apps/helpers.js
Sami f865790fe1 fix(routes): restore ctx access in 15 route files broken by Phase 2.1 refactor
The modular refactor changed function signatures to destructured deps but
left internal ctx.* references intact, causing "ctx is not defined" errors
on /api/config, /api/logo, and many other endpoints. Also implements
loadTotpConfig and saveTotpConfig which were left as stubs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 03:01:29 -07:00

423 lines
17 KiB
JavaScript

const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const { REGEX, DOCKER } = require('../../constants');
const { exists } = require('../../fs-helpers');
const platformPaths = require('../../platform-paths');
/**
* 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(ctx) {
const { docker, caddy, credentialManager, servicesStateManager, fetchT, log } = ctx;
async function checkPortConflicts(ports, excludeContainerName = null) {
const conflicts = [];
try {
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;
for (const portInfo of (container.Ports || [])) {
if (portInfo.PublicPort) {
const publicPort = portInfo.PublicPort.toString();
if (ports.includes(publicPort)) {
const containerName = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
const appLabel = container.Labels?.['sami.app'] || 'unknown';
conflicts.push({ port: publicPort, usedBy: containerName, app: appLabel, containerId: container.Id.substring(0, 12) });
}
}
}
}
} catch (e) {
log.warn('docker', 'Could not check port conflicts', { error: e.message });
}
return conflicts;
}
async function findExistingContainerByImage(template) {
try {
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];
if (containerImage === templateImage || containerImage.endsWith('/' + templateImage)) {
const ports = container.Ports.filter(p => p.PublicPort).map(p => ({
hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type
}));
return {
id: container.Id, shortId: container.Id.slice(0, 12),
name: container.Names[0]?.replace(/^\//, '') || 'unknown',
image: container.Image, status: container.Status, state: container.State,
ports, primaryPort: ports.length > 0 ? ports[0].hostPort : null,
labels: container.Labels || {}
};
}
}
return null;
} catch (e) {
log.warn('docker', 'Could not check for existing containers', { error: e.message });
return null;
}
}
// Convert host path to Docker-compatible mount format (platform-aware)
const toDockerDesktopPath = platformPaths.toDockerMountPath;
function processTemplateVariables(template, config) {
const processed = JSON.parse(JSON.stringify(template));
const mediaPathInput = config.mediaPath || template.mediaMount?.defaultPath || '/media';
const mediaPaths = mediaPathInput.split(',').map(p => p.trim()).filter(p => p).map(p => toDockerDesktopPath(p));
const replacements = {
'{{HOST_IP}}': config.ip,
'{{SUBDOMAIN}}': config.subdomain,
'{{PORT}}': config.port || template.defaultPort,
'{{MEDIA_PATH}}': mediaPaths[0] || '/media',
'{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC',
'{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex')
};
function replaceInObject(obj) {
for (const key in obj) {
if (typeof obj[key] === 'string') {
Object.entries(replacements).forEach(([placeholder, value]) => {
obj[key] = obj[key].replace(new RegExp(placeholder, 'g'), value);
});
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
replaceInObject(obj[key]);
}
}
}
replaceInObject(processed);
// Handle multiple media paths
if (mediaPaths.length > 1 && processed.docker?.volumes) {
const containerPath = template.mediaMount?.containerPath || '/media';
const newVolumes = [];
for (const vol of processed.docker.volumes) {
if (vol.includes(mediaPaths[0]) && vol.includes(containerPath)) {
for (const p of mediaPaths) {
const folderName = p.split(/[/\\]/).filter(p => p).pop() || 'media';
newVolumes.push(`${p}:${containerPath}/${folderName}`);
}
} else {
newVolumes.push(vol);
}
}
processed.docker.volumes = newVolumes;
}
// Handle Plex claim token
if (config.plexClaimToken && processed.docker?.environment?.PLEX_CLAIM !== undefined) {
processed.docker.environment.PLEX_CLAIM = config.plexClaimToken;
}
// Inject URL base env var for subdirectory routing mode
if (ctx.siteConfig.routingMode === 'subdirectory' && template.urlBaseEnv) {
processed.docker.environment = processed.docker.environment || {};
const basePath = `/${config.subdomain}`;
// Some apps need the full URL, not just the path
if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) {
processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/';
} else {
processed.docker.environment[template.urlBaseEnv] = basePath;
}
}
// Apply custom volume overrides
if (config.customVolumes?.length && processed.docker?.volumes) {
processed.docker.volumes = processed.docker.volumes.map(vol => {
const parts = vol.split(':');
const containerPath = parts.slice(1).join(':');
const override = config.customVolumes.find(cv => cv.containerPath === containerPath);
if (override && override.hostPath) {
// Validate host path is under allowed roots (docker data dir or media paths)
const normalizedHost = path.resolve(override.hostPath);
const allowedRoots = [path.resolve(platformPaths.dockerData)];
if (config.mediaPath) {
config.mediaPath.split(',').map(p => p.trim()).filter(Boolean).forEach(p => allowedRoots.push(path.resolve(p)));
}
const isAllowed = allowedRoots.some(root =>
normalizedHost === root || normalizedHost.startsWith(root + path.sep)
);
if (!isAllowed) {
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}`;
}
return vol;
});
}
return processed;
}
function generateStaticSiteConfig(subdomain, sitePath, options = {}) {
const { tailscaleOnly = false, httpAccess = false, apiProxy = null } = options;
const domain = ctx.buildDomain(subdomain);
// Shared block content used by both HTTPS and HTTP blocks
function siteBlockContent() {
let c = '';
c += ` root * ${sitePath}\n\n`;
if (tailscaleOnly) {
c += ` @blocked not remote_ip 100.64.0.0/10\n`;
c += ` respond @blocked "Access denied. Tailscale connection required." 403\n\n`;
}
if (apiProxy) {
c += ` handle /api/* {\n`;
c += ` reverse_proxy ${apiProxy}\n`;
c += ` }\n\n`;
}
c += ` @crt path *.crt\n`;
c += ` handle @crt {\n`;
c += ` header Content-Type application/x-x509-ca-cert\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` header Cache-Control "public, max-age=86400"\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` @der path *.der\n`;
c += ` handle @der {\n`;
c += ` header Content-Type application/x-x509-ca-cert\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` header Cache-Control "public, max-age=86400"\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` @mobileconfig path *.mobileconfig\n`;
c += ` handle @mobileconfig {\n`;
c += ` header Content-Type application/x-apple-aspen-config\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` header Cache-Control "public, max-age=86400"\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` @ps1 path *.ps1\n`;
c += ` handle @ps1 {\n`;
c += ` header Content-Type text/plain\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` @sh path *.sh\n`;
c += ` handle @sh {\n`;
c += ` header Content-Type text/x-shellscript\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` # Static site with SPA fallback\n`;
c += ` handle {\n`;
c += ` @notFile not file {path}\n`;
c += ` rewrite @notFile /index.html\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` # No cache for HTML\n`;
c += ` @htmlfiles {\n`;
c += ` path *.html\n`;
c += ` path /\n`;
c += ` }\n`;
c += ` header @htmlfiles Cache-Control "no-store"\n`;
return c;
}
// HTTPS block
let config = `${domain} {\n`;
config += ` tls internal\n\n`;
config += siteBlockContent();
config += `}`;
// HTTP companion block for devices that haven't trusted the CA yet
if (httpAccess) {
config += `\n\n# HTTP access for first-time certificate installation\n`;
config += `http://${domain} {\n`;
config += siteBlockContent();
config += `}`;
}
return config;
}
async function waitForHealthCheck(containerId, healthPath, port, maxAttempts = 20) {
const delay = 2000;
let httpCheckFailed = 0;
for (let i = 0; i < maxAttempts; i++) {
try {
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') {
log.info('docker', 'Container is healthy (Docker health check)', { containerId });
return true;
}
} else if (healthPath && port && httpCheckFailed < 5) {
try {
const response = await fetchT(`http://localhost:${port}${healthPath}`, {
signal: AbortSignal.timeout(3000), redirect: 'manual'
});
if (response.ok || (response.status >= 300 && response.status < 400)) {
log.info('docker', 'Health check passed', { containerId, status: response.status });
return true;
}
} catch (e) {
httpCheckFailed++;
log.debug('docker', 'HTTP health check failed', { attempt: httpCheckFailed, error: e.message });
}
} else {
if (i >= 5) {
log.info('docker', 'Container is running', { containerId, waitedSeconds: i * delay / 1000 });
return true;
}
}
}
} catch (e) {
log.debug('docker', 'Health check attempt failed', { attempt: i + 1, error: e.message });
}
if (i < maxAttempts - 1) {
log.debug('docker', 'Waiting for container to be healthy', { attempt: i + 1, maxAttempts });
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error(`[DC-202] Container failed to become healthy after ${maxAttempts} attempts (${maxAttempts * delay / 1000}s)`);
}
async function addCaddyConfig(subdomain, config) {
const domain = ctx.buildDomain(subdomain);
const existing = await caddy.read();
if (existing.includes(`${domain} {`)) {
log.info('caddy', 'Caddy config already exists, skipping add', { domain });
await caddy.reload(existing);
return;
}
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 caddy.verifySite(domain);
}
// Reserved paths that cannot be used as subpath names in subdirectory mode
const RESERVED_SUBPATHS = ['api', 'probe', 'assets', 'health', 'dist', 'js', 'css', 'fonts', 'favicon.ico'];
/** Ensure the main domain block with route markers exists in the Caddyfile.
* Called before the first subdirectory app is deployed. */
async function ensureMainDomainBlock() {
if (ctx.siteConfig.routingMode !== 'subdirectory' || !ctx.siteConfig.domain) return;
const content = await caddy.read();
const domain = ctx.siteConfig.domain;
const ROUTE_MARKER = '# === DashCaddy App Routes ===';
// Already has markers — nothing to do
if (content.includes(ROUTE_MARKER)) return;
// Domain block exists but lacks markers — inject them
if (content.includes(`${domain} {`)) {
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
const searchFrom = domainStart + domain.length;
const handleMatch = c.slice(searchFrom).match(/^([ \t]+)handle\s*\{/m);
if (!handleMatch) return null;
const handleIdx = searchFrom + handleMatch.index;
const indent = handleMatch[1];
const markerBlock = `${indent}${ROUTE_MARKER}\n${indent}# === End App Routes ===\n\n`;
return c.slice(0, handleIdx) + markerBlock + c.slice(handleIdx);
});
if (result.success) {
log.info('caddy', 'Injected route markers into existing domain block', { domain });
}
return;
}
// No domain block at all — create one with dashboard + markers
const dashboardRoot = platformPaths.sitePath('status');
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 caddy.modify(c => c + block);
if (result.success) {
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}`);
}
}
/** Inject a subpath config block between route markers in the Caddyfile. */
async function addSubpathConfig(subdomain, configBlock) {
const marker = `# --- DashCaddy: ${subdomain} ---`;
const endMarker = `# --- End: ${subdomain} ---`;
const END_ROUTE_MARKER = '# === End App Routes ===';
const result = await caddy.modify(content => {
if (content.includes(marker)) {
log.info('caddy', 'Subpath config already exists, skipping', { subdomain });
return null;
}
const endIdx = content.indexOf(END_ROUTE_MARKER);
if (endIdx === -1) {
throw new Error(`Route marker "${END_ROUTE_MARKER}" not found in Caddyfile`);
}
// Detect indentation from the end marker line
const lineStart = content.lastIndexOf('\n', endIdx) + 1;
const indent = content.slice(lineStart, endIdx).match(/^([ \t]*)/)?.[1] || '\t';
const injection = `${indent}${marker}\n${configBlock}\n${indent}${endMarker}\n`;
return content.slice(0, endIdx) + injection + content.slice(endIdx);
});
if (!result.success) {
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
}
}
/** Remove a subpath config block from between its markers in the Caddyfile. */
async function removeSubpathConfig(subdomain) {
const marker = `# --- DashCaddy: ${subdomain} ---`;
const endMarker = `# --- End: ${subdomain} ---`;
return await caddy.modify(content => {
const startIdx = content.indexOf(marker);
if (startIdx === -1) return null;
const endIdx = content.indexOf(endMarker);
if (endIdx === -1) return null;
// Remove from the line start of the marker to the line end of the end marker
const lineStart = content.lastIndexOf('\n', startIdx);
const lineEnd = content.indexOf('\n', endIdx + endMarker.length);
const modified = content.slice(0, lineStart) + content.slice(lineEnd);
return modified.replace(/\n{3,}/g, '\n\n');
});
}
return {
checkPortConflicts,
findExistingContainerByImage,
toDockerDesktopPath,
processTemplateVariables,
waitForHealthCheck,
addCaddyConfig,
addSubpathConfig,
removeSubpathConfig,
ensureMainDomainBlock,
RESERVED_SUBPATHS,
generateStaticSiteConfig
};
};