Phase 1: Add ESLint/Prettier config + baseline auto-fixes

This commit is contained in:
Krystie
2026-03-22 11:00:25 +01:00
parent 41a0cdee7e
commit e2c67a8fe8
90 changed files with 4008 additions and 3066 deletions

View File

@@ -62,7 +62,7 @@ module.exports = function(ctx, helpers) {
ctx.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: For full features, copy certificate files to ${ destPath}`);
ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully');
} catch (error) {
ctx.log.error('deploy', 'DashCA deployment error', { error: error.message });
@@ -121,14 +121,14 @@ module.exports = function(ctx, helpers) {
PortBindings: {},
Binds: translatedVolumes,
RestartPolicy: { Name: 'unless-stopped' },
LogConfig: DOCKER.LOG_CONFIG
LogConfig: DOCKER.LOG_CONFIG,
},
Env: Object.entries(processedTemplate.docker.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: {
'sami.managed': 'true', 'sami.app': appId,
'sami.subdomain': userConfig.subdomain,
'sami.deployed': new Date().toISOString()
}
'sami.deployed': new Date().toISOString(),
},
};
processedTemplate.docker.ports.forEach(portMapping => {
@@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) {
try {
const pruneResult = await ctx.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' });
ctx.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 });
@@ -324,7 +324,7 @@ module.exports = function(ctx, helpers) {
tailscaleOnly: config.tailscaleOnly || false,
allowedIPs: config.allowedIPs || [],
customVolumes: config.customVolumes || undefined,
useExisting: false
useExisting: false,
},
container: template.isStaticSite ? null : {
image: processedTemplate.docker.image,
@@ -340,14 +340,14 @@ module.exports = function(ctx, helpers) {
}
return env;
})(),
capabilities: processedTemplate.docker.capabilities || undefined
capabilities: processedTemplate.docker.capabilities || undefined,
},
caddy: {
tailscaleOnly: config.tailscaleOnly || false,
allowedIPs: config.allowedIPs || [],
subpathSupport: template.subpathSupport || 'strip',
routingMode: ctx.siteConfig.routingMode
}
routingMode: ctx.siteConfig.routingMode,
},
};
await ctx.addServiceToConfig({
@@ -358,7 +358,7 @@ module.exports = function(ctx, helpers) {
tailscaleOnly: config.tailscaleOnly || false,
routingMode: ctx.siteConfig.routingMode,
deployedAt: new Date().toISOString(),
deploymentManifest
deploymentManifest,
});
ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain });
@@ -366,7 +366,7 @@ module.exports = function(ctx, helpers) {
success: true, containerId, usedExisting,
url: serviceUrl,
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
setupInstructions: template.setupInstructions || []
setupInstructions: template.setupInstructions || [],
};
if (dnsWarning) response.warning = dnsWarning;

View File

@@ -38,16 +38,16 @@ module.exports = function(ctx) {
const templateImage = template.docker.image.split(':')[0];
for (const container of containers) {
const containerImage = container.Image.split(':')[0];
if (containerImage === templateImage || containerImage.endsWith('/' + templateImage)) {
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
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 || {}
labels: container.Labels || {},
};
}
}
@@ -72,7 +72,7 @@ module.exports = function(ctx) {
'{{PORT}}': config.port || template.defaultPort,
'{{MEDIA_PATH}}': mediaPaths[0] || '/media',
'{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC',
'{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex')
'{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex'),
};
function replaceInObject(obj) {
@@ -117,7 +117,7 @@ module.exports = function(ctx) {
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) + '/';
processed.docker.environment[template.urlBaseEnv] = `${ctx.buildServiceUrl(config.subdomain) }/`;
} else {
processed.docker.environment[template.urlBaseEnv] = basePath;
}
@@ -137,7 +137,7 @@ module.exports = function(ctx) {
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)
normalizedHost === root || normalizedHost.startsWith(root + path.sep),
);
if (!isAllowed) {
ctx.log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots });
@@ -162,76 +162,76 @@ module.exports = function(ctx) {
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`;
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 += ' handle /api/* {\n';
c += ` reverse_proxy ${apiProxy}\n`;
c += ` }\n\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`;
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 += ' tls internal\n\n';
config += siteBlockContent();
config += `}`;
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 += '\n\n# HTTP access for first-time certificate installation\n';
config += `http://${domain} {\n`;
config += siteBlockContent();
config += `}`;
config += '}';
}
return config;
@@ -254,7 +254,7 @@ module.exports = function(ctx) {
} else if (healthPath && port && httpCheckFailed < 5) {
try {
const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, {
signal: AbortSignal.timeout(3000), redirect: 'manual'
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 });
@@ -290,7 +290,7 @@ module.exports = function(ctx) {
await ctx.caddy.reload(existing);
return;
}
const result = await ctx.caddy.modify(c => c + `\n${config}\n`);
const result = await ctx.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);
}
@@ -405,6 +405,6 @@ module.exports = function(ctx) {
removeSubpathConfig,
ensureMainDomainBlock,
RESERVED_SUBPATHS,
generateStaticSiteConfig
generateStaticSiteConfig,
};
};

View File

@@ -26,7 +26,7 @@ module.exports = function(ctx, helpers) {
try {
const pruneResult = await ctx.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' });
ctx.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 });
@@ -42,7 +42,7 @@ module.exports = function(ctx, helpers) {
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'
token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true',
});
let recordIp = ip || 'localhost';
if (getResult.status === 'ok' && getResult.response?.records) {
@@ -50,7 +50,7 @@ module.exports = function(ctx, helpers) {
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
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 });

View File

@@ -37,7 +37,7 @@ module.exports = function(ctx, helpers) {
return res.json({
success: true,
message: 'No services have deployment manifests to restore',
results: []
results: [],
});
}
@@ -51,7 +51,7 @@ module.exports = function(ctx, helpers) {
id: service.id,
name: service.name,
status: 'failed',
error: error.message
error: error.message,
});
}
}
@@ -63,7 +63,7 @@ module.exports = function(ctx, helpers) {
res.json({
success: true,
message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`,
results
results,
});
}, 'apps-restore-all'));
@@ -81,7 +81,7 @@ module.exports = function(ctx, helpers) {
hasManifest: !!service.deploymentManifest,
templateId: service.deploymentManifest?.templateId || service.appTemplate || null,
deployedAt: service.deployedAt || null,
containerRunning: false
containerRunning: false,
};
// Check if container is currently running
@@ -125,7 +125,7 @@ module.exports = function(ctx, helpers) {
name: service.name,
status: 'restored',
type: 'static',
message: `Static site "${service.name}" config preserved`
message: `Static site "${service.name}" config preserved`,
};
}
@@ -140,7 +140,7 @@ module.exports = function(ctx, helpers) {
id: service.id,
name: service.name,
status: 'skipped',
message: 'Container already running'
message: 'Container already running',
};
}
} catch (e) {
@@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) {
id: service.id,
name: service.name,
status: 'skipped',
message: 'Container already running (found by name)'
message: 'Container already running (found by name)',
};
}
// Exists but not running — remove stale container
@@ -178,7 +178,7 @@ module.exports = function(ctx, helpers) {
id: service.id,
name: service.name,
status: 'failed',
error: 'No container configuration in manifest'
error: 'No container configuration in manifest',
};
}
@@ -189,7 +189,7 @@ module.exports = function(ctx, helpers) {
} catch (e) {
// Check if image exists locally
const images = await ctx.docker.client.listImages({
filters: { reference: [manifest.container.image] }
filters: { reference: [manifest.container.image] },
});
if (images.length === 0) {
throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`);
@@ -206,7 +206,7 @@ module.exports = function(ctx, helpers) {
PortBindings: {},
Binds: manifest.container.volumes || [],
RestartPolicy: { Name: 'unless-stopped' },
LogConfig: DOCKER.LOG_CONFIG
LogConfig: DOCKER.LOG_CONFIG,
},
Env: Object.entries(manifest.container.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: {
@@ -214,8 +214,8 @@ module.exports = function(ctx, helpers) {
'sami.app': manifest.templateId,
'sami.subdomain': manifest.config.subdomain,
'sami.deployed': new Date().toISOString(),
'sami.restored': 'true'
}
'sami.restored': 'true',
},
};
// Set up port bindings
@@ -287,7 +287,7 @@ module.exports = function(ctx, helpers) {
status: 'restored',
type: 'container',
containerId: container.id,
message: `${service.name} restored successfully`
message: `${service.name} restored successfully`,
};
}

View File

@@ -11,7 +11,7 @@ module.exports = function(ctx, helpers) {
success: true,
templates: ctx.APP_TEMPLATES,
categories: ctx.TEMPLATE_CATEGORIES,
difficultyLevels: ctx.DIFFICULTY_LEVELS
difficultyLevels: ctx.DIFFICULTY_LEVELS,
});
}, 'apps-templates'));
@@ -71,7 +71,7 @@ module.exports = function(ctx, helpers) {
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'
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 });
@@ -139,7 +139,7 @@ module.exports = function(ctx, helpers) {
success: true,
message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`,
newUrl: `https://${ctx.buildDomain(newSubdomain)}`,
results
results,
});
}, 'update-subdomain'));

View File

@@ -11,12 +11,12 @@ module.exports = function(ctx, helpers) {
const results = { radarr: null, sonarr: null };
// Step 1: Authenticate with Overseerr via Plex token
let overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`;
const overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`;
const overseerrSession = await helpers.getOverseerrSession();
if (!overseerrSession) {
return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.'
hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.',
});
}
@@ -30,8 +30,8 @@ module.exports = function(ctx, helpers) {
headers: {
'Content-Type': 'application/json',
'Cookie': overseerrSession.cookie,
...options.headers
}
...options.headers,
},
});
return response;
};
@@ -41,12 +41,12 @@ module.exports = function(ctx, helpers) {
const statusRes = await overseerrFetch('/api/v1/status');
if (!statusRes.ok) {
return ctx.errorResponse(res, 502, 'Cannot connect to Overseerr', {
hint: 'Make sure Overseerr is running on port 5055'
hint: 'Make sure Overseerr is running on port 5055',
});
}
} catch (e) {
return ctx.errorResponse(res, 502, `Cannot reach Overseerr: ${e.message}`, {
hint: 'Check if Overseerr container is running'
hint: 'Check if Overseerr container is running',
});
}
@@ -59,14 +59,14 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles from Radarr
const profilesRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': radarr.apiKey }
headers: { 'X-Api-Key': radarr.apiKey },
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Radarr
const rootFoldersRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': radarr.apiKey }
headers: { 'X-Api-Key': radarr.apiKey },
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -87,12 +87,12 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released',
isDefault: true,
externalUrl: radarr.url,
tags: []
tags: [],
};
const radarrRes = await overseerrFetch('/api/v1/settings/radarr', {
method: 'POST',
body: JSON.stringify(radarrConfig)
body: JSON.stringify(radarrConfig),
});
if (radarrRes.ok) {
@@ -115,14 +115,14 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles from Sonarr
const profilesRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': sonarr.apiKey }
headers: { 'X-Api-Key': sonarr.apiKey },
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Sonarr
const rootFoldersRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': sonarr.apiKey }
headers: { 'X-Api-Key': sonarr.apiKey },
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -131,7 +131,7 @@ module.exports = function(ctx, helpers) {
let languageProfileId = 1;
try {
const langRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': sonarr.apiKey }
headers: { 'X-Api-Key': sonarr.apiKey },
});
if (langRes.ok) {
const langProfiles = await langRes.json();
@@ -158,12 +158,12 @@ module.exports = function(ctx, helpers) {
isDefault: true,
enableSeasonFolders: true,
externalUrl: sonarr.url,
tags: []
tags: [],
};
const sonarrRes = await overseerrFetch('/api/v1/settings/sonarr', {
method: 'POST',
body: JSON.stringify(sonarrConfig)
body: JSON.stringify(sonarrConfig),
});
if (sonarrRes.ok) {
@@ -182,7 +182,7 @@ module.exports = function(ctx, helpers) {
res.json({
success: anyConfigured,
message: anyConfigured ? 'Services configured in Overseerr' : 'Configuration failed',
results
results,
});
}, 'arr-configure-overseerr'));
@@ -210,7 +210,7 @@ module.exports = function(ctx, helpers) {
}
// Normalize URL - remove trailing slash
let baseUrl = url.replace(/\/+$/, '');
const baseUrl = url.replace(/\/+$/, '');
// Build the API endpoint
let apiEndpoint;
@@ -233,7 +233,7 @@ module.exports = function(ctx, helpers) {
const response = await ctx.fetchT(apiEndpoint, {
method: 'GET',
headers,
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
if (response.ok) {
@@ -244,7 +244,7 @@ module.exports = function(ctx, helpers) {
return res.json({
success: true,
version,
appName
appName,
});
} else if (response.status === 401) {
return ctx.errorResponse(res, 401, 'Invalid API key');
@@ -288,7 +288,7 @@ module.exports = function(ctx, helpers) {
containerName: container.Names[0]?.replace(/^\//, ''),
port: exposedPort,
url: `http://host.docker.internal:${exposedPort}`,
localUrl: `http://localhost:${exposedPort}`
localUrl: `http://localhost:${exposedPort}`,
};
// Extract API key for arr services
@@ -305,7 +305,7 @@ module.exports = function(ctx, helpers) {
radarrFound: !!detected.radarr?.apiKey,
sonarrFound: !!detected.sonarr?.apiKey,
lidarrFound: !!detected.lidarr?.apiKey,
prowlarrFound: !!detected.prowlarr?.apiKey
prowlarrFound: !!detected.prowlarr?.apiKey,
};
ctx.log.info('arr', 'Detected services', summary);
@@ -313,14 +313,14 @@ module.exports = function(ctx, helpers) {
if (!summary.overseerrFound) {
return ctx.errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', {
detected,
summary
summary,
});
}
if (!summary.radarrFound && !summary.sonarrFound) {
return ctx.errorResponse(res, 400, 'No Radarr or Sonarr found with valid API keys. Deploy at least one first.', {
detected,
summary
summary,
});
}
@@ -331,7 +331,7 @@ module.exports = function(ctx, helpers) {
return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
setupUrl: detected.overseerr.localUrl,
detected,
summary
summary,
});
}
@@ -344,8 +344,8 @@ module.exports = function(ctx, helpers) {
headers: {
'Content-Type': 'application/json',
'Cookie': overseerrSession.cookie,
...options.headers
}
...options.headers,
},
});
};
@@ -356,14 +356,14 @@ module.exports = function(ctx, helpers) {
try {
// Fetch quality profiles from Radarr
const profilesRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': detected.radarr.apiKey }
headers: { 'X-Api-Key': detected.radarr.apiKey },
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Radarr
const rootFoldersRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': detected.radarr.apiKey }
headers: { 'X-Api-Key': detected.radarr.apiKey },
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -384,12 +384,12 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released',
isDefault: true,
externalUrl: detected.radarr.localUrl,
tags: []
tags: [],
};
const resp = await overseerrFetch('/api/v1/settings/radarr', {
method: 'POST',
body: JSON.stringify(radarrConfig)
body: JSON.stringify(radarrConfig),
});
configResults.radarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`;
@@ -403,14 +403,14 @@ module.exports = function(ctx, helpers) {
try {
// Fetch quality profiles from Sonarr
const profilesRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey }
headers: { 'X-Api-Key': detected.sonarr.apiKey },
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Sonarr
const rootFoldersRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey }
headers: { 'X-Api-Key': detected.sonarr.apiKey },
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -419,7 +419,7 @@ module.exports = function(ctx, helpers) {
let languageProfileId = 1;
try {
const langRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey }
headers: { 'X-Api-Key': detected.sonarr.apiKey },
});
if (langRes.ok) {
const langProfiles = await langRes.json();
@@ -444,12 +444,12 @@ module.exports = function(ctx, helpers) {
isDefault: true,
enableSeasonFolders: true,
externalUrl: detected.sonarr.localUrl,
tags: []
tags: [],
};
const resp = await overseerrFetch('/api/v1/settings/sonarr', {
method: 'POST',
body: JSON.stringify(sonarrConfig)
body: JSON.stringify(sonarrConfig),
});
configResults.sonarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`;
@@ -466,7 +466,7 @@ module.exports = function(ctx, helpers) {
'deploymentSuccess',
'Arr Stack Auto-Connected',
`Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`,
'success'
'success',
);
}
@@ -475,7 +475,7 @@ module.exports = function(ctx, helpers) {
message: anyConfigured ? 'Auto-setup completed successfully!' : 'Configuration failed',
detected,
configResults,
summary
summary,
});
}, 'arr-auto-setup'));

View File

@@ -39,7 +39,7 @@ module.exports = function(ctx, helpers) {
service,
source: url ? 'external' : 'local',
url: url || null,
storedAt: new Date().toISOString()
storedAt: new Date().toISOString(),
};
// Test connection if URL is known
@@ -77,7 +77,7 @@ module.exports = function(ctx, helpers) {
return ctx.errorResponse(res, 400, 'Invalid seedbox base URL');
}
await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, {
storedAt: new Date().toISOString()
storedAt: new Date().toISOString(),
});
}
@@ -87,7 +87,7 @@ module.exports = function(ctx, helpers) {
success: true,
message: `${service} API key stored`,
connectionTest,
url: resolvedUrl
url: resolvedUrl,
});
}, 'arr-credentials-store'));
@@ -106,7 +106,7 @@ module.exports = function(ctx, helpers) {
url: metadata?.url || null,
lastVerified: metadata?.lastVerified || null,
version: metadata?.version || null,
source: metadata?.source || null
source: metadata?.source || null,
};
}

View File

@@ -13,7 +13,7 @@ module.exports = function(ctx, helpers) {
sonarr: null,
overseerr: null,
lidarr: null,
prowlarr: null
prowlarr: null,
};
// Service detection patterns
@@ -35,7 +35,7 @@ module.exports = function(ctx, helpers) {
image: container.Image,
port: exposedPort,
status: container.State,
url: helpers.getServiceUrl(containerName, exposedPort)
url: helpers.getServiceUrl(containerName, exposedPort),
};
// Get API key for arr services (not Plex or Overseerr)
@@ -58,8 +58,8 @@ module.exports = function(ctx, helpers) {
plexReady: !!(detected.plex?.token),
radarrReady: !!(detected.radarr?.apiKey),
sonarrReady: !!(detected.sonarr?.apiKey),
overseerrRunning: !!detected.overseerr
}
overseerrRunning: !!detected.overseerr,
},
});
}, 'arr-detect'));
@@ -86,7 +86,7 @@ module.exports = function(ctx, helpers) {
containerId: container.Id,
containerName: container.Names[0]?.replace(/^\//, ''),
port: portInfo?.PublicPort || config.port,
status: container.State
status: container.State,
};
}
}
@@ -122,7 +122,7 @@ module.exports = function(ctx, helpers) {
hasToken: false,
containerId: null,
containerName: null,
version: null
version: null,
};
// Check Docker first
@@ -143,7 +143,7 @@ module.exports = function(ctx, helpers) {
// Store for later use
await ctx.credentialManager.store('arr.plex.token', token, {
service: 'plex', source: 'local', url: entry.url,
lastVerified: new Date().toISOString()
lastVerified: new Date().toISOString(),
});
} else {
entry.status = 'needs_key';
@@ -160,7 +160,7 @@ module.exports = function(ctx, helpers) {
try {
const radarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, {
headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000)
signal: AbortSignal.timeout(5000),
});
if (radarrCheck.ok) {
const radarrSettings = await radarrCheck.json();
@@ -170,7 +170,7 @@ module.exports = function(ctx, helpers) {
try {
const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, {
headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000)
signal: AbortSignal.timeout(5000),
});
if (sonarrCheck.ok) {
const sonarrSettings = await sonarrCheck.json();
@@ -180,7 +180,7 @@ module.exports = function(ctx, helpers) {
try {
const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, {
headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000)
signal: AbortSignal.timeout(5000),
});
if (plexCheck.ok) {
const plexSettings = await plexCheck.json();
@@ -273,7 +273,7 @@ module.exports = function(ctx, helpers) {
fullyConnected: statuses.filter(s => s.status === 'connected').length,
needsApiKey: statuses.filter(s => s.status === 'needs_key').length,
errors: statuses.filter(s => s.status === 'error').length,
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2,
};
res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary });

View File

@@ -12,7 +12,7 @@ module.exports = function(ctx) {
const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/config.xml'],
AttachStdout: true,
AttachStderr: true
AttachStderr: true,
});
const stream = await exec.start();
@@ -38,7 +38,7 @@ module.exports = function(ctx) {
try {
const containers = await ctx.docker.client.listContainers({ all: false });
const container = containers.find(c =>
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex'))
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')),
);
if (!container) return null;
@@ -47,7 +47,7 @@ module.exports = function(ctx) {
const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'],
AttachStdout: true,
AttachStderr: true
AttachStderr: true,
});
const stream = await exec.start();
@@ -97,7 +97,7 @@ module.exports = function(ctx) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authToken: plexToken }),
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
if (!authRes.ok) {
@@ -125,7 +125,7 @@ module.exports = function(ctx) {
// 1. Get Plex server identity (for return info)
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
if (!identityRes.ok) throw new Error('Cannot reach Plex server');
const identity = await identityRes.json();
@@ -136,16 +136,16 @@ module.exports = function(ctx) {
const plexConfig = {
ip: 'host.docker.internal',
port: APP_PORTS.plex,
useSsl: false
useSsl: false,
};
const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookie
'Cookie': sessionCookie,
},
body: JSON.stringify(plexConfig)
body: JSON.stringify(plexConfig),
});
if (!configRes.ok) {
@@ -157,7 +157,7 @@ module.exports = function(ctx) {
await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, {
method: 'POST',
headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
} catch (e) {
ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message });
@@ -168,7 +168,7 @@ module.exports = function(ctx) {
try {
const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(5000)
signal: AbortSignal.timeout(5000),
});
if (libRes.ok) {
const plexSettings = await libRes.json();
@@ -188,7 +188,7 @@ module.exports = function(ctx) {
try {
const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, {
headers: { 'X-Api-Key': prowlarrApiKey },
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
existingApps = existingRes.ok ? await existingRes.json() : [];
} catch (e) {
@@ -217,8 +217,8 @@ module.exports = function(ctx) {
{ name: 'prowlarrUrl', value: prowlarrUrl },
{ name: 'baseUrl', value: config.url },
{ name: 'apiKey', value: config.apiKey },
{ name: 'syncCategories', value: syncCategories }
]
{ name: 'syncCategories', value: syncCategories },
],
};
try {
@@ -226,10 +226,10 @@ module.exports = function(ctx) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': prowlarrApiKey
'X-Api-Key': prowlarrApiKey,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`;
} catch (e) {
@@ -262,7 +262,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(apiEndpoint, {
method: 'GET',
headers,
signal: AbortSignal.timeout(15000)
signal: AbortSignal.timeout(15000),
});
if (response.ok) {
@@ -297,6 +297,6 @@ module.exports = function(ctx) {
getOverseerrApiKey,
connectPlexToOverseerr,
configureProwlarrApps,
testServiceConnection
testServiceConnection,
};
};

View File

@@ -14,7 +14,7 @@ module.exports = function(ctx, helpers) {
if (!plexToken) {
return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', {
hint: 'Deploy Plex with a claim token or manually configure it.'
hint: 'Deploy Plex with a claim token or manually configure it.',
});
}
@@ -32,7 +32,7 @@ module.exports = function(ctx, helpers) {
// Fetch libraries
const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
if (!libRes.ok) {
@@ -45,7 +45,7 @@ module.exports = function(ctx, helpers) {
title: dir.title,
type: dir.type,
count: parseInt(dir.count) || 0,
scannedAt: dir.scannedAt
scannedAt: dir.scannedAt,
}));
// Get server name
@@ -54,7 +54,7 @@ module.exports = function(ctx, helpers) {
try {
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000)
signal: AbortSignal.timeout(5000),
});
if (identityRes.ok) {
const identity = await identityRes.json();
@@ -66,7 +66,7 @@ module.exports = function(ctx, helpers) {
// Store token for future use
await ctx.credentialManager.store('arr.plex.token', plexToken, {
service: 'plex', source: 'local', url: plexUrl,
lastVerified: new Date().toISOString()
lastVerified: new Date().toISOString(),
});
res.json({ success: true, serverName, version, libraries });

View File

@@ -44,7 +44,7 @@ module.exports = function(ctx, helpers) {
steps.push({
step: `Test ${svc.charAt(0).toUpperCase() + svc.slice(1)} connection`,
status: test.success ? 'success' : 'failed',
details: test.success ? `v${test.version}` : test.error
details: test.success ? `v${test.version}` : test.error,
});
if (test.success) {
@@ -55,12 +55,12 @@ module.exports = function(ctx, helpers) {
const stored = await ctx.credentialManager.store(`arr.${svc}.apikey`, apiKey, {
service: svc, source: 'external', url,
lastVerified: new Date().toISOString(),
version: test.version
version: test.version,
});
steps.push({
step: `Save ${svc} credentials`,
status: stored ? 'success' : 'failed',
details: stored ? 'Encrypted and saved' : 'Storage failed'
details: stored ? 'Encrypted and saved' : 'Storage failed',
});
}
}
@@ -94,7 +94,7 @@ module.exports = function(ctx, helpers) {
steps.push({
step: 'Get Overseerr API key',
status: 'failed',
details: 'Could not authenticate with Overseerr (Plex not running or not linked)'
details: 'Could not authenticate with Overseerr (Plex not running or not linked)',
});
} else {
steps.push({ step: 'Get Overseerr API key', status: 'success', details: 'Extracted from container' });
@@ -110,7 +110,7 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles
const profilesRes = await ctx.fetchT(`${radarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
@@ -118,7 +118,7 @@ module.exports = function(ctx, helpers) {
// Fetch root folders
const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -141,20 +141,20 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released',
isDefault: true,
externalUrl: connectedServices.radarr.url,
tags: []
tags: [],
};
const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie },
body: JSON.stringify(radarrConfig),
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
steps.push({
step: 'Configure Radarr in Overseerr',
status: radarrRes.ok ? 'success' : 'failed',
details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text()
details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text(),
});
} catch (e) {
steps.push({ step: 'Configure Radarr in Overseerr', status: 'failed', details: e.message });
@@ -170,14 +170,14 @@ module.exports = function(ctx, helpers) {
const profilesRes = await ctx.fetchT(`${sonarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -186,7 +186,7 @@ module.exports = function(ctx, helpers) {
try {
const langRes = await ctx.fetchT(`${sonarrUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(5000)
signal: AbortSignal.timeout(5000),
});
if (langRes.ok) {
const langProfiles = await langRes.json();
@@ -212,20 +212,20 @@ module.exports = function(ctx, helpers) {
isDefault: true,
enableSeasonFolders: true,
externalUrl: connectedServices.sonarr.url,
tags: []
tags: [],
};
const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie },
body: JSON.stringify(sonarrConfig),
signal: AbortSignal.timeout(10000)
signal: AbortSignal.timeout(10000),
});
steps.push({
step: 'Configure Sonarr in Overseerr',
status: sonarrRes.ok ? 'success' : 'failed',
details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text()
details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text(),
});
} catch (e) {
steps.push({ step: 'Configure Sonarr in Overseerr', status: 'failed', details: e.message });
@@ -239,7 +239,7 @@ module.exports = function(ctx, helpers) {
steps.push({
step: 'Connect Plex to Overseerr',
status: 'success',
details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced`
details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced`,
});
} catch (e) {
steps.push({ step: 'Connect Plex to Overseerr', status: 'failed', details: e.message });
@@ -259,13 +259,13 @@ module.exports = function(ctx, helpers) {
const prowlarrResults = await helpers.configureProwlarrApps(
connectedServices.prowlarr.url.replace(/\/+$/, ''),
connectedServices.prowlarr.apiKey,
appsToConnect
appsToConnect,
);
for (const [app, status] of Object.entries(prowlarrResults)) {
steps.push({
step: `Add ${app.charAt(0).toUpperCase() + app.slice(1)} to Prowlarr`,
status: status === 'configured' || status === 'already_configured' ? 'success' : 'failed',
details: status
details: status,
});
}
} catch (e) {
@@ -283,14 +283,14 @@ module.exports = function(ctx, helpers) {
'deploymentSuccess',
'Smart Arr Connect Complete',
`${succeeded}/${steps.length} steps completed successfully`,
'success'
'success',
);
}
res.json({
success: succeeded > 0,
steps,
summary: { totalSteps: steps.length, succeeded, failed }
summary: { totalSteps: steps.length, succeeded, failed },
});
}, 'smart-connect'));

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
y: 365 * 24 * 60 * 60 * 1000
y: 365 * 24 * 60 * 60 * 1000,
};
return value * (multipliers[unit] || multipliers.h);
@@ -54,7 +54,7 @@ module.exports = function(ctx) {
const keyData = await ctx.authManager.generateAPIKey(
name.trim(),
scopes || ['read', 'write']
scopes || ['read', 'write'],
);
res.json({
@@ -64,7 +64,7 @@ module.exports = function(ctx) {
name: keyData.name,
scopes: keyData.scopes,
createdAt: keyData.createdAt,
warning: 'Save this key securely - it will not be shown again'
warning: 'Save this key securely - it will not be shown again',
});
}, 'auth-keys-generate'));
@@ -109,9 +109,9 @@ module.exports = function(ctx) {
const token = await ctx.authManager.generateJWT(
{
sub: userId || 'dashcaddy-admin',
scope: ['admin'] // Session-generated JWTs have admin scope
scope: ['admin'], // Session-generated JWTs have admin scope
},
expiresIn || '24h'
expiresIn || '24h',
);
// Calculate expiration timestamp
@@ -122,7 +122,7 @@ module.exports = function(ctx) {
success: true,
token,
expiresAt,
usage: 'Include in Authorization header as: Bearer <token>'
usage: 'Include in Authorization header as: Bearer <token>',
});
}, 'auth-jwt-generate'));

View File

@@ -29,7 +29,7 @@ module.exports = function(ctx) {
const { spawnSync } = require('child_process');
const proc = spawnSync('wget', [
'-q', '-S', `--post-data=${routerBody}`, '-O', '/dev/null',
`${baseUrl}/cgi-bin/login.ha`
`${baseUrl}/cgi-bin/login.ha`,
], { timeout: 5000, encoding: 'utf8' });
const result = (proc.stderr || '').split('\n').slice(0, 2).join('\n');
const locationMatch = result.match(/Location:\s*(.+)/);

View File

@@ -10,8 +10,8 @@ module.exports = function(ctx) {
config: {
enabled: ctx.totpConfig.enabled,
sessionDuration: ctx.totpConfig.sessionDuration,
isSetUp: ctx.totpConfig.isSetUp
}
isSetUp: ctx.totpConfig.isSetUp,
},
});
}, 'totp-config-get'));
@@ -35,7 +35,7 @@ module.exports = function(ctx) {
const otpauth = authenticator.keyuri('user', 'DashCaddy', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth, {
width: 256, margin: 2,
color: { dark: '#ffffff', light: '#00000000' }
color: { dark: '#ffffff', light: '#00000000' },
});
res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret });
@@ -166,7 +166,7 @@ module.exports = function(ctx) {
if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) {
return ctx.errorResponse(res, 400, 'Invalid session duration', {
validOptions: Object.keys(ctx.session.durations)
validOptions: Object.keys(ctx.session.durations),
});
}
@@ -180,7 +180,7 @@ module.exports = function(ctx) {
await ctx.saveTotpConfig();
res.json({
success: true,
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp },
});
}, 'totp-config'));

View File

@@ -24,7 +24,7 @@ module.exports = function(ctx) {
const allRoots = BROWSE_ROOTS.map(r => ({
name: r.hostPath,
path: r.hostPath,
containerPath: r.containerPath
containerPath: r.containerPath,
}));
const roots = [];
@@ -45,7 +45,7 @@ module.exports = function(ctx) {
const allRoots = BROWSE_ROOTS.map(r => ({
name: r.hostPath,
path: r.hostPath,
type: 'drive'
type: 'drive',
}));
const roots = [];
for (const r of allRoots) {
@@ -58,12 +58,12 @@ module.exports = function(ctx) {
}
const matchingRoot = BROWSE_ROOTS.find(r =>
requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, '')
requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, ''),
);
if (!matchingRoot) {
return ctx.errorResponse(res, 400, 'Path not in browseable roots', {
availableRoots: BROWSE_ROOTS.map(r => r.hostPath)
availableRoots: BROWSE_ROOTS.map(r => r.hostPath),
});
}
@@ -80,7 +80,7 @@ module.exports = function(ctx) {
requestedPath, containerFullPath, allowedRoots,
error: error.message,
ip: req.ip,
userAgent: req.get('user-agent')
userAgent: req.get('user-agent'),
});
return ctx.errorResponse(res, 403, 'Access denied - path traversal detected');
}
@@ -108,7 +108,7 @@ module.exports = function(ctx) {
.map(entry => ({
name: entry.name,
path: path.join(requestedPath, entry.name).replace(/\\/g, '/'),
type: 'folder'
type: 'folder',
}))
.sort((a, b) => a.name.localeCompare(b.name));
@@ -119,7 +119,7 @@ module.exports = function(ctx) {
path: requestedPath,
parent: path.dirname(requestedPath).replace(/\\/g, '/') || null,
items: result.data,
...(result.pagination && { pagination: result.pagination })
...(result.pagination && { pagination: result.pagination }),
});
}, 'browse-dir'));
@@ -128,12 +128,12 @@ module.exports = function(ctx) {
const mediaServerPatterns = [
'plex', 'jellyfin', 'emby', 'kodi', 'navidrome', 'airsonic',
'subsonic', 'funkwhale', 'beets', 'lidarr', 'sonarr', 'radarr',
'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli'
'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli',
];
const excludePatterns = [
'/config', '/cache', '/transcode', '/data/config', '/app',
'/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile'
'/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile',
];
const containers = await ctx.docker.client.listContainers({ all: false });
@@ -155,7 +155,7 @@ module.exports = function(ctx) {
let hostPath, containerPath;
if (parts[0].length === 1 && /[A-Za-z]/.test(parts[0])) {
hostPath = parts[0] + ':' + parts[1];
hostPath = `${parts[0] }:${ parts[1]}`;
containerPath = parts[2] || '';
} else {
hostPath = parts[0];
@@ -164,7 +164,7 @@ module.exports = function(ctx) {
const isExcluded = excludePatterns.some(p =>
containerPath.toLowerCase().includes(p.toLowerCase()) ||
hostPath.toLowerCase().includes(p.toLowerCase())
hostPath.toLowerCase().includes(p.toLowerCase()),
);
if (isExcluded) continue;
if (seenPaths.has(hostPath)) continue;
@@ -175,7 +175,7 @@ module.exports = function(ctx) {
detectedMounts.push({
hostPath, containerPath, folderName,
sourceContainer: containerInfo.Names[0]?.replace('/', '') || containerInfo.Id.slice(0, 12),
sourceImage: containerInfo.Image.split('/').pop().split(':')[0]
sourceImage: containerInfo.Image.split('/').pop().split(':')[0],
});
}
}
@@ -185,7 +185,7 @@ module.exports = function(ctx) {
mounts: detectedMounts,
message: detectedMounts.length > 0
? `Found ${detectedMounts.length} media mount(s) from existing containers`
: 'No existing media mounts detected'
: 'No existing media mounts detected',
});
}, 'detect-media-mounts'));

View File

@@ -25,22 +25,22 @@ module.exports = function(ctx) {
}
const certInfo = JSON.parse(await fsp.readFile(certInfoFile, 'utf8'));
const expirationDate = new Date(certInfo.validUntil);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
const expirationDate = new Date(certInfo.validUntil);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
res.json({
success: true,
certificate: {
name: certInfo.name,
fingerprint: certInfo.fingerprint,
validFrom: certInfo.validFrom,
validUntil: certInfo.validUntil,
daysUntilExpiration,
algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256',
serialNumber: certInfo.serialNumber,
downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt`
}
});
res.json({
success: true,
certificate: {
name: certInfo.name,
fingerprint: certInfo.fingerprint,
validFrom: certInfo.validFrom,
validUntil: certInfo.validUntil,
daysUntilExpiration,
algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256',
serialNumber: certInfo.serialNumber,
downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt`,
},
});
}, 'ca-info'));
// Serve root CA certificate directly (works even without DashCA deployed)
@@ -99,7 +99,7 @@ module.exports = function(ctx) {
// Look for template in multiple locations (packaged app vs dev)
const templatePaths = [
path.join(__dirname, '..', 'scripts', templateName),
path.join('/app', 'scripts', templateName)
path.join('/app', 'scripts', templateName),
];
let templateContent;
@@ -208,12 +208,12 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
const serverCertContent = await fsp.readFile(certFile, 'utf8');
const intermediateCertContent = await fsp.readFile(intermediateCert, 'utf8');
const rootCertContent = await fsp.readFile(rootCert, 'utf8');
await fsp.writeFile(fullChainFile, serverCertContent + '\n' + intermediateCertContent + '\n' + rootCertContent);
await fsp.writeFile(fullChainFile, `${serverCertContent }\n${ intermediateCertContent }\n${ rootCertContent}`);
execSync(`openssl pkcs12 -export -out "${pfxFile}" -inkey "${keyFile}" -in "${certFile}" -certfile "${intermediateCert}" -password "pass:${password}"`, { stdio: 'pipe' });
const keyContent = await fsp.readFile(keyFile, 'utf8');
await fsp.writeFile(pemFile, keyContent + '\n' + serverCertContent + '\n' + intermediateCertContent);
await fsp.writeFile(pemFile, `${keyContent }\n${ serverCertContent }\n${ intermediateCertContent}`);
}
if (format === 'pfx') {
@@ -260,26 +260,26 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
const certFile = path.join(certsDir, domain, 'server.crt');
if (!await exists(certFile)) return null;
try {
const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString();
const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain;
const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : '';
const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : '';
const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : '';
try {
const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString();
const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain;
const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : '';
const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : '';
const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : '';
const expirationDate = new Date(notAfter);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
const expirationDate = new Date(notAfter);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
return {
domain, subject,
validFrom: notBefore, validUntil: notAfter,
daysUntilExpiration, fingerprint,
status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid'
};
} catch {
return null;
}
}))).filter(Boolean);
return {
domain, subject,
validFrom: notBefore, validUntil: notAfter,
daysUntilExpiration, fingerprint,
status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid',
};
} catch {
return null;
}
}))).filter(Boolean);
res.json({ success: true, certificates });
}, 'ca-certs'));

View File

@@ -56,7 +56,7 @@ module.exports = function(ctx) {
res.json({
success: true,
path: `/assets/${safeFilename}`,
message: `Logo saved to ${filePath}`
message: `Logo saved to ${filePath}`,
});
}, 'assets-upload'));
@@ -75,7 +75,7 @@ module.exports = function(ctx) {
customLogo: config.customLogo || config.customLogoDark || null,
position: config.logoPosition || 'left',
dashboardTitle: config.dashboardTitle || 'DashCaddy',
isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo
isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo,
});
}, 'logo-get'));
@@ -153,7 +153,7 @@ module.exports = function(ctx) {
path: pathDark || pathLight,
position: config.logoPosition || 'left',
dashboardTitle: config.dashboardTitle || 'DashCaddy',
message: 'Branding settings saved'
message: 'Branding settings saved',
});
}, 'logo-upload'));
@@ -186,7 +186,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'Branding reset to defaults'
message: 'Branding reset to defaults',
});
}, 'logo-delete'));
@@ -199,7 +199,7 @@ module.exports = function(ctx) {
res.json({
success: true,
customFavicon: config.customFavicon || null,
isDefault: !config.customFavicon
isDefault: !config.customFavicon,
});
}, 'favicon-get'));
@@ -237,8 +237,8 @@ module.exports = function(ctx) {
sharp(buffer)
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer()
)
.toBuffer(),
),
);
// Convert to ICO
@@ -261,7 +261,7 @@ module.exports = function(ctx) {
res.json({
success: true,
path: '/assets/favicon.ico',
message: 'Favicon created successfully'
message: 'Favicon created successfully',
});
}, 'favicon'));
@@ -285,7 +285,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'Favicon reset to default'
message: 'Favicon reset to default',
});
}, 'favicon-delete'));

View File

@@ -34,7 +34,7 @@ module.exports = function(ctx) {
dashcaddyVersion: '1.0.0',
files: {},
themes: {},
assets: {}
assets: {},
};
// Collect all configuration files (encryption key now included for self-contained restore)
@@ -48,7 +48,7 @@ module.exports = function(ctx) {
{ key: 'encryptionKey', path: ENCRYPTION_KEY_FILE, required: false },
{ key: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false },
{ key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false },
{ key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false }
{ key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false },
];
for (const file of filesToBackup) {
@@ -59,12 +59,12 @@ module.exports = function(ctx) {
try {
backup.files[file.key] = {
type: 'json',
data: JSON.parse(content)
data: JSON.parse(content),
};
} catch {
backup.files[file.key] = {
type: 'text',
data: content
data: content,
};
}
} else if (file.required) {
@@ -85,7 +85,7 @@ module.exports = function(ctx) {
const otpauth = authenticator.keyuri('user', 'DashCaddy', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth, {
width: 256, margin: 2,
color: { dark: '#000000', light: '#ffffff' }
color: { dark: '#000000', light: '#ffffff' },
});
backup.totp = { qrCode: qrDataUrl, issuer: 'DashCaddy' };
}
@@ -140,7 +140,7 @@ module.exports = function(ctx) {
valid: true,
version: backup.version,
exportedAt: backup.exportedAt,
files: {}
files: {},
};
// Check each file in the backup
@@ -154,7 +154,7 @@ module.exports = function(ctx) {
encryptionKey: { path: ENCRYPTION_KEY_FILE, description: 'Encryption key (for credentials)' },
totpConfig: { path: ctx.TOTP_CONFIG_FILE, description: 'TOTP authentication config' },
tailscaleConfig: { path: ctx.TAILSCALE_CONFIG_FILE, description: 'Tailscale config' },
notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' }
notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' },
};
for (const [key, value] of Object.entries(backup.files)) {
@@ -167,7 +167,7 @@ module.exports = function(ctx) {
inBackup: true,
currentExists,
action: currentExists ? 'overwrite' : 'create',
type: value.type
type: value.type,
};
}
}
@@ -204,7 +204,7 @@ module.exports = function(ctx) {
// Require TOTP verification for restores that include security-sensitive files
const sensitiveKeys = ['credentials', 'totpConfig', 'encryptionKey'];
const restoresSensitive = sensitiveKeys.some(key =>
backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key)
backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key),
);
if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) {
if (!totpCode || !/^\d{6}$/.test(totpCode)) {
@@ -223,7 +223,7 @@ module.exports = function(ctx) {
const results = {
restored: [],
skipped: [],
errors: []
errors: [],
};
const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key');
@@ -236,7 +236,7 @@ module.exports = function(ctx) {
encryptionKey: ENCRYPTION_KEY_FILE,
totpConfig: ctx.TOTP_CONFIG_FILE,
tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE,
notifications: ctx.NOTIFICATIONS_FILE
notifications: ctx.NOTIFICATIONS_FILE,
};
// Restore each file
@@ -286,7 +286,7 @@ module.exports = function(ctx) {
const loadResponse = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: caddyContent
body: caddyContent,
});
if (loadResponse.ok) {
@@ -345,7 +345,7 @@ module.exports = function(ctx) {
if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true });
for (const [slug, data] of Object.entries(backup.themes)) {
if (/^[a-z0-9-]+$/.test(slug)) {
fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(data, null, 2), 'utf8');
fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(data, null, 2), 'utf8');
}
}
results.restored.push(`themes:${Object.keys(backup.themes).length}`);
@@ -376,7 +376,7 @@ module.exports = function(ctx) {
message: success
? `Restored ${results.restored.length} file(s) successfully`
: `Restore completed with ${results.errors.length} error(s)`,
results
results,
});
ctx.log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });

View File

@@ -46,90 +46,90 @@ module.exports = function(ctx) {
const containerId = req.params.id;
const container = await getVerifiedContainer(containerId);
// Get container info
const containerInfo = await container.inspect();
const imageName = containerInfo.Config.Image;
const containerName = containerInfo.Name.replace(/^\//, '');
// Get container info
const containerInfo = await container.inspect();
const imageName = containerInfo.Config.Image;
const containerName = containerInfo.Name.replace(/^\//, '');
ctx.log.info('docker', 'Updating container', { containerName, imageName });
ctx.log.info('docker', 'Updating container', { containerName, imageName });
// Pull the latest image
ctx.log.info('docker', `Pulling latest image: ${imageName}`);
await ctx.docker.pull(imageName);
// Pull the latest image
ctx.log.info('docker', `Pulling latest image: ${imageName}`);
await ctx.docker.pull(imageName);
// Get current container config for recreation
const hostConfig = containerInfo.HostConfig;
const config = {
Image: imageName,
name: containerName,
Env: containerInfo.Config.Env,
ExposedPorts: containerInfo.Config.ExposedPorts,
Labels: containerInfo.Config.Labels,
HostConfig: {
Binds: hostConfig.Binds,
PortBindings: hostConfig.PortBindings,
RestartPolicy: hostConfig.RestartPolicy,
NetworkMode: hostConfig.NetworkMode,
ExtraHosts: hostConfig.ExtraHosts,
Privileged: hostConfig.Privileged,
CapAdd: hostConfig.CapAdd,
CapDrop: hostConfig.CapDrop,
Devices: hostConfig.Devices,
LogConfig: DOCKER.LOG_CONFIG // Ensure log rotation on updated containers
},
NetworkingConfig: {}
// Get current container config for recreation
const hostConfig = containerInfo.HostConfig;
const config = {
Image: imageName,
name: containerName,
Env: containerInfo.Config.Env,
ExposedPorts: containerInfo.Config.ExposedPorts,
Labels: containerInfo.Config.Labels,
HostConfig: {
Binds: hostConfig.Binds,
PortBindings: hostConfig.PortBindings,
RestartPolicy: hostConfig.RestartPolicy,
NetworkMode: hostConfig.NetworkMode,
ExtraHosts: hostConfig.ExtraHosts,
Privileged: hostConfig.Privileged,
CapAdd: hostConfig.CapAdd,
CapDrop: hostConfig.CapDrop,
Devices: hostConfig.Devices,
LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers
},
NetworkingConfig: {},
};
// Get network settings if using a custom network
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
const networkName = hostConfig.NetworkMode;
config.NetworkingConfig.EndpointsConfig = {
[networkName]: containerInfo.NetworkSettings.Networks[networkName],
};
}
// Get network settings if using a custom network
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
const networkName = hostConfig.NetworkMode;
config.NetworkingConfig.EndpointsConfig = {
[networkName]: containerInfo.NetworkSettings.Networks[networkName]
};
// Stop and remove old container
ctx.log.info('docker', 'Stopping container', { containerName });
await container.stop().catch(() => {}); // Ignore if already stopped
ctx.log.info('docker', 'Removing container', { containerName });
await container.remove();
// Wait for port release (Windows/Docker Desktop can be slow to free ports)
await new Promise(r => setTimeout(r, 3000));
// Create and start new container
ctx.log.info('docker', 'Creating new container', { containerName });
let newContainer;
try {
newContainer = await ctx.docker.client.createContainer(config);
ctx.log.info('docker', 'Starting container', { containerName });
await newContainer.start();
} catch (startError) {
// Clean up the failed container so it doesn't block future attempts
ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
if (newContainer) {
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
}
throw startError;
}
// Stop and remove old container
ctx.log.info('docker', 'Stopping container', { containerName });
await container.stop().catch(() => {}); // Ignore if already stopped
ctx.log.info('docker', 'Removing container', { containerName });
await container.remove();
const newContainerInfo = await newContainer.inspect();
// Wait for port release (Windows/Docker Desktop can be slow to free ports)
await new Promise(r => setTimeout(r, 3000));
// Create and start new container
ctx.log.info('docker', 'Creating new container', { containerName });
let newContainer;
try {
newContainer = await ctx.docker.client.createContainer(config);
ctx.log.info('docker', 'Starting container', { containerName });
await newContainer.start();
} catch (startError) {
// Clean up the failed container so it doesn't block future attempts
ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
if (newContainer) {
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
}
throw startError;
// Prune dangling images after update
try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` });
}
} catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
}
const newContainerInfo = await newContainer.inspect();
// Prune dangling images after update
try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
}
} catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
}
res.json({
success: true,
message: `Container ${containerName} updated successfully`,
newContainerId: newContainerInfo.Id
});
res.json({
success: true,
message: `Container ${containerName} updated successfully`,
newContainerId: newContainerInfo.Id,
});
}, 'container-update'));
// Check for available updates (compares local and remote image digests)
@@ -148,7 +148,7 @@ module.exports = function(ctx) {
const pullStream = await ctx.docker.pull(imageName);
const downloadedLayers = pullStream.filter(e =>
e.status === 'Downloading' || e.status === 'Download complete'
e.status === 'Downloading' || e.status === 'Download complete',
);
updateAvailable = downloadedLayers.length > 0;
@@ -167,7 +167,7 @@ module.exports = function(ctx) {
success: true,
imageName,
updateAvailable,
currentDigest: localDigest
currentDigest: localDigest,
});
}, 'container-check-update'));
@@ -178,7 +178,7 @@ module.exports = function(ctx) {
stdout: true,
stderr: true,
tail: 100,
timestamps: true
timestamps: true,
});
res.json({ success: true, logs: logs.toString() });
}, 'container-logs'));
@@ -194,7 +194,7 @@ module.exports = function(ctx) {
router.get('/discover', ctx.asyncHandler(async (req, res) => {
const containers = await ctx.docker.client.listContainers({ all: true });
const samiContainers = containers.filter(container =>
container.Labels && container.Labels['sami.managed'] === 'true'
container.Labels && container.Labels['sami.managed'] === 'true',
);
const discoveredContainers = samiContainers.map(container => ({
@@ -205,7 +205,7 @@ module.exports = function(ctx) {
status: container.Status,
appTemplate: container.Labels['sami.app'],
subdomain: container.Labels['sami.subdomain'],
ports: container.Ports
ports: container.Ports,
}));
const paginationParams = parsePaginationParams(req.query);

View File

@@ -113,7 +113,7 @@ module.exports = function(ctx) {
const zone = parts.slice(1).join('.') || ctx.siteConfig.tld.replace(/^\./, '');
const result = await ctx.dns.call(dnsServer, '/api/zones/records/add', {
token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true'
token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true',
});
if (result.status === 'ok') {
@@ -151,7 +151,7 @@ module.exports = function(ctx) {
try {
const result = await ctx.dns.call(dnsServer, '/api/zones/records/get', {
token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true'
token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true',
});
if (result.status === 'ok' && result.response && result.response.records) {
@@ -218,7 +218,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(technitiumUrl, {
method: 'GET',
headers: { 'Accept': 'text/plain' },
timeout: 10000
timeout: 10000,
});
if (!response.ok) {
@@ -232,7 +232,7 @@ module.exports = function(ctx) {
server: server,
count: 0,
logs: [],
message: 'No logs available for this server'
message: 'No logs available for this server',
});
}
return ctx.errorResponse(res, response.status, ctx.safeErrorMessage(errorJson.errorMessage || errorText));
@@ -255,7 +255,7 @@ module.exports = function(ctx) {
server: server,
count: 0,
logs: [],
message: 'No logs available for this server'
message: 'No logs available for this server',
});
}
// Invalidate cached token on auth errors so next request re-authenticates
@@ -287,7 +287,7 @@ module.exports = function(ctx) {
class: match[6].trim(),
rcode: match[7].trim(),
answer: match[8].trim() || null,
raw: line
raw: line,
};
}
return { raw: line, parsed: false };
@@ -299,7 +299,7 @@ module.exports = function(ctx) {
server: server,
logFile: logFileName,
count: parsedLogs.length,
logs: parsedLogs
logs: parsedLogs,
});
} catch (error) {
@@ -319,7 +319,7 @@ module.exports = function(ctx) {
hasCredentials,
hasToken,
tokenExpiry: ctx.dns.getTokenExpiry(),
isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null
isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null,
});
}, 'dns-token-status'));
@@ -394,7 +394,7 @@ module.exports = function(ctx) {
return res.json({
success: anySuccess,
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
results
results,
});
}
@@ -430,7 +430,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'DNS credentials saved and verified (encrypted)',
tokenExpiry: ctx.dns.getTokenExpiry()
tokenExpiry: ctx.dns.getTokenExpiry(),
});
}, 'dns-credentials'));
@@ -495,7 +495,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'Token refreshed successfully',
tokenExpiry: ctx.dns.getTokenExpiry()
tokenExpiry: ctx.dns.getTokenExpiry(),
});
} else {
ctx.errorResponse(res, 401, result.error);
@@ -529,8 +529,8 @@ module.exports = function(ctx) {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': APP.USER_AGENTS.API
}
'User-Agent': APP.USER_AGENTS.API,
},
});
const text = await response.text();
@@ -550,7 +550,7 @@ module.exports = function(ctx) {
updateTitle: result.response.updateTitle || null,
updateMessage: result.response.updateMessage || null,
downloadLink: result.response.downloadLink || null,
instructionsLink: result.response.instructionsLink || null
instructionsLink: result.response.instructionsLink || null,
});
} else {
ctx.errorResponse(res, 500, result.errorMessage || 'Check failed');
@@ -586,7 +586,7 @@ module.exports = function(ctx) {
// Check if update is available
const checkResponse = await ctx.fetchT(
`http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`,
{ method: 'GET', headers: { 'Accept': 'application/json' } }
{ method: 'GET', headers: { 'Accept': 'application/json' } },
);
const checkText = await checkResponse.text();
@@ -604,7 +604,7 @@ module.exports = function(ctx) {
success: true,
message: 'Already up to date',
currentVersion: checkResult.response.currentVersion,
updated: false
updated: false,
});
}
@@ -620,7 +620,7 @@ module.exports = function(ctx) {
downloadLink: checkResult.response.downloadLink || null,
instructionsLink: checkResult.response.instructionsLink || null,
updated: false,
manualUpdateRequired: true
manualUpdateRequired: true,
});
} catch (error) {
ctx.log.error('dns', 'DNS update error', { error: error.message });

View File

@@ -14,22 +14,22 @@ module.exports = function(ctx) {
}
const logContent = await fsp.readFile(ctx.ERROR_LOG_FILE, 'utf8');
const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim());
const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim());
const logs = logEntries.map(entry => {
const lines = entry.trim().split('\n');
const firstLine = lines[0] || '';
const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/);
const logs = logEntries.map(entry => {
const lines = entry.trim().split('\n');
const firstLine = lines[0] || '';
const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/);
if (match) {
return {
timestamp: match[1],
context: match[2],
error: match[3]
};
}
return null;
}).filter(Boolean);
if (match) {
return {
timestamp: match[1],
context: match[2],
error: match[3],
};
}
return null;
}).filter(Boolean);
res.json({ success: true, logs: logs.slice(-50).reverse() });
}, 'error-logs-get'));

View File

@@ -34,7 +34,7 @@ module.exports = function(ctx) {
try {
let url = null;
let checkType = 'http';
const checkType = 'http';
// Determine URL to check
url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl);
@@ -52,7 +52,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(url, {
method: 'HEAD',
signal: controller.signal,
redirect: 'follow'
redirect: 'follow',
});
clearTimeout(timeout);
@@ -60,7 +60,7 @@ module.exports = function(ctx) {
status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy',
statusCode: response.status,
url,
checkedAt: new Date().toISOString()
checkedAt: new Date().toISOString(),
};
} catch (fetchError) {
clearTimeout(timeout);
@@ -73,7 +73,7 @@ module.exports = function(ctx) {
const getResponse = await ctx.fetchT(url, {
method: 'GET',
signal: getController.signal,
redirect: 'follow'
redirect: 'follow',
});
clearTimeout(getTimeout);
@@ -81,14 +81,14 @@ module.exports = function(ctx) {
status: getResponse.ok || getResponse.status < 500 ? 'healthy' : 'unhealthy',
statusCode: getResponse.status,
url,
checkedAt: new Date().toISOString()
checkedAt: new Date().toISOString(),
};
} catch (e) {
health[serviceId] = {
status: 'unhealthy',
reason: e.name === 'AbortError' ? 'Timeout' : e.message,
url,
checkedAt: new Date().toISOString()
checkedAt: new Date().toISOString(),
};
}
}
@@ -96,7 +96,7 @@ module.exports = function(ctx) {
health[serviceId] = {
status: 'error',
reason: e.message,
checkedAt: new Date().toISOString()
checkedAt: new Date().toISOString(),
};
}
}));
@@ -113,7 +113,7 @@ module.exports = function(ctx) {
success: true,
health: paginatedHealth,
checkedAt: lastHealthCheck,
...(result.pagination && { pagination: result.pagination })
...(result.pagination && { pagination: result.pagination }),
});
}, 'health-services'));
@@ -123,7 +123,7 @@ module.exports = function(ctx) {
success: true,
health: serviceHealthCache,
lastCheck: lastHealthCheck,
cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null
cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null,
});
}, 'health-cached'));
@@ -157,7 +157,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow'
redirect: 'follow',
});
clearTimeout(timeout);
@@ -168,8 +168,8 @@ module.exports = function(ctx) {
status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy',
statusCode: response.status,
url,
checkedAt: new Date().toISOString()
}
checkedAt: new Date().toISOString(),
},
});
} catch (e) {
clearTimeout(timeout);
@@ -180,8 +180,8 @@ module.exports = function(ctx) {
status: 'unhealthy',
reason: e.name === 'AbortError' ? 'Timeout' : e.message,
url,
checkedAt: new Date().toISOString()
}
checkedAt: new Date().toISOString(),
},
});
}
}, 'health-service'));
@@ -201,7 +201,7 @@ module.exports = function(ctx) {
return res.json({
status: 'error',
message: 'Root CA certificate not found',
daysUntilExpiration: null
daysUntilExpiration: null,
});
}
@@ -232,14 +232,14 @@ module.exports = function(ctx) {
status: status,
message: message,
daysUntilExpiration: daysUntilExpiration,
expiresAt: notAfter
expiresAt: notAfter,
});
} catch (error) {
await ctx.logError('GET /api/health/ca', error);
res.json({
status: 'error',
message: error.message,
daysUntilExpiration: null
daysUntilExpiration: null,
});
}
}, 'health-ca'));

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: result.message,
license: result.activation
license: result.activation,
});
} else {
ctx.errorResponse(res, 400, result.message);
@@ -53,8 +53,8 @@ module.exports = function(ctx) {
tier: status.tier,
...(available ? {} : {
upgradeUrl: '/settings#license',
message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium`
})
message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium`,
}),
});
}, 'license-feature-check'));

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
name: c.Names[0]?.replace(/^\//, '') || 'unknown',
image: c.Image,
status: c.State,
created: c.Created
created: c.Created,
}));
const paginationParams = parsePaginationParams(req.query);
@@ -46,7 +46,7 @@ module.exports = function(ctx) {
const logs = await container.logs({
stdout: true, stderr: true,
tail, since, timestamps
tail, since, timestamps,
});
// Parse Docker log stream (demultiplex stdout/stderr)
@@ -65,7 +65,7 @@ module.exports = function(ctx) {
if (line) {
lines.push({
stream: streamType === 2 ? 'stderr' : 'stdout',
text: line
text: line,
});
}
offset += 8 + size;
@@ -75,7 +75,7 @@ module.exports = function(ctx) {
success: true,
containerId, containerName,
logs: lines,
count: lines.length
count: lines.length,
});
}, 'logs-container'));
@@ -100,7 +100,7 @@ module.exports = function(ctx) {
const logStream = await container.logs({
stdout: true, stderr: true,
follow: true, tail: 50, timestamps: true
follow: true, tail: 50, timestamps: true,
});
let buffer = Buffer.alloc(0);
@@ -119,7 +119,7 @@ module.exports = function(ctx) {
const data = JSON.stringify({
stream: streamType === 2 ? 'stderr' : 'stdout',
text: line,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
res.write(`data: ${data}\n\n`);
}
@@ -248,7 +248,7 @@ module.exports = function(ctx) {
const logs = tailLines.map(line => ({
stream: 'stdout',
text: line,
timestamp: extractTimestamp(line)
timestamp: extractTimestamp(line),
}));
res.json({
@@ -256,7 +256,7 @@ module.exports = function(ctx) {
logPath: normalizedPath,
logs,
count: logs.length,
totalLines: lines.length
totalLines: lines.length,
});
}, 'logs-file'));

View File

@@ -96,17 +96,17 @@ module.exports = function(ctx) {
image: containerInfo.Image,
status: containerInfo.State,
cpu: {
percent: Math.round(cpuPercent * 100) / 100
percent: Math.round(cpuPercent * 100) / 100,
},
memory: {
used: memUsage,
limit: memLimit,
percent: Math.round(memPercent * 100) / 100
percent: Math.round(memPercent * 100) / 100,
},
network: {
rx: netRx,
tx: netTx
}
tx: netTx,
},
});
} catch (e) {
// Skip containers we can't get stats for
@@ -151,15 +151,15 @@ module.exports = function(ctx) {
status: info.State.Status,
started: info.State.StartedAt,
cpu: {
percent: Math.round(cpuPercent * 100) / 100
percent: Math.round(cpuPercent * 100) / 100,
},
memory: {
used: memUsage,
limit: memLimit,
percent: Math.round((memUsage / memLimit) * 100 * 100) / 100
percent: Math.round((memUsage / memLimit) * 100 * 100) / 100,
},
network: { rx: netRx, tx: netTx }
}
network: { rx: netRx, tx: netTx },
},
});
}, 'stats-container'));

View File

@@ -7,116 +7,116 @@ module.exports = function(ctx) {
// GET /config — Get notification configuration (sensitive data redacted)
router.get('/config', ctx.asyncHandler(async (req, res) => {
const notificationConfig = ctx.notification.getConfig();
// Return config without sensitive data
const safeConfig = {
enabled: notificationConfig.enabled,
providers: {
discord: {
enabled: notificationConfig.providers.discord?.enabled || false,
configured: !!notificationConfig.providers.discord?.webhookUrl
},
telegram: {
enabled: notificationConfig.providers.telegram?.enabled || false,
configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId)
},
ntfy: {
enabled: notificationConfig.providers.ntfy?.enabled || false,
configured: !!notificationConfig.providers.ntfy?.topic,
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh'
}
const notificationConfig = ctx.notification.getConfig();
// Return config without sensitive data
const safeConfig = {
enabled: notificationConfig.enabled,
providers: {
discord: {
enabled: notificationConfig.providers.discord?.enabled || false,
configured: !!notificationConfig.providers.discord?.webhookUrl,
},
events: notificationConfig.events,
healthCheck: notificationConfig.healthCheck
};
res.json({ success: true, config: safeConfig });
telegram: {
enabled: notificationConfig.providers.telegram?.enabled || false,
configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId),
},
ntfy: {
enabled: notificationConfig.providers.ntfy?.enabled || false,
configured: !!notificationConfig.providers.ntfy?.topic,
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh',
},
},
events: notificationConfig.events,
healthCheck: notificationConfig.healthCheck,
};
res.json({ success: true, config: safeConfig });
}, 'notifications-config-get'));
// POST /config — Update notification configuration
router.post('/config', ctx.asyncHandler(async (req, res) => {
const { enabled, providers, events, healthCheck } = req.body;
const notificationConfig = ctx.notification.getConfig();
const { enabled, providers, events, healthCheck } = req.body;
const notificationConfig = ctx.notification.getConfig();
// Validate provider webhook URLs and tokens
if (providers) {
if (providers.discord?.webhookUrl) {
try {
validateURL(providers.discord.webhookUrl);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL');
}
}
if (providers.telegram?.botToken) {
try {
validateToken(providers.telegram.botToken);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format');
}
}
if (providers.ntfy?.serverUrl) {
try {
validateURL(providers.ntfy.serverUrl);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid ntfy server URL');
}
}
if (providers.ntfy?.topic) {
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
if (!topicRegex.test(providers.ntfy.topic)) {
return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
}
// Validate provider webhook URLs and tokens
if (providers) {
if (providers.discord?.webhookUrl) {
try {
validateURL(providers.discord.webhookUrl);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL');
}
}
// Update enabled state
if (typeof enabled === 'boolean') {
notificationConfig.enabled = enabled;
}
// Update providers (only update provided fields)
if (providers) {
if (providers.discord) {
notificationConfig.providers.discord = {
...notificationConfig.providers.discord,
...providers.discord
};
}
if (providers.telegram) {
notificationConfig.providers.telegram = {
...notificationConfig.providers.telegram,
...providers.telegram
};
}
if (providers.ntfy) {
notificationConfig.providers.ntfy = {
...notificationConfig.providers.ntfy,
...providers.ntfy
};
if (providers.telegram?.botToken) {
try {
validateToken(providers.telegram.botToken);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format');
}
}
// Update events
if (events) {
notificationConfig.events = { ...notificationConfig.events, ...events };
}
// Update health check settings
if (healthCheck) {
const wasEnabled = notificationConfig.healthCheck?.enabled;
notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck };
// Restart daemon if settings changed
if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) {
if (notificationConfig.healthCheck.enabled) {
ctx.notification.startHealthDaemon();
} else {
ctx.notification.stopHealthDaemon();
}
if (providers.ntfy?.serverUrl) {
try {
validateURL(providers.ntfy.serverUrl);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid ntfy server URL');
}
}
if (providers.ntfy?.topic) {
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
if (!topicRegex.test(providers.ntfy.topic)) {
return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
}
}
}
await ctx.notification.saveConfig();
res.json({ success: true, message: 'Notification config updated' });
// Update enabled state
if (typeof enabled === 'boolean') {
notificationConfig.enabled = enabled;
}
// Update providers (only update provided fields)
if (providers) {
if (providers.discord) {
notificationConfig.providers.discord = {
...notificationConfig.providers.discord,
...providers.discord,
};
}
if (providers.telegram) {
notificationConfig.providers.telegram = {
...notificationConfig.providers.telegram,
...providers.telegram,
};
}
if (providers.ntfy) {
notificationConfig.providers.ntfy = {
...notificationConfig.providers.ntfy,
...providers.ntfy,
};
}
}
// Update events
if (events) {
notificationConfig.events = { ...notificationConfig.events, ...events };
}
// Update health check settings
if (healthCheck) {
const wasEnabled = notificationConfig.healthCheck?.enabled;
notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck };
// Restart daemon if settings changed
if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) {
if (notificationConfig.healthCheck.enabled) {
ctx.notification.startHealthDaemon();
} else {
ctx.notification.stopHealthDaemon();
}
}
}
await ctx.notification.saveConfig();
res.json({ success: true, message: 'Notification config updated' });
}, 'notifications-config-update'));
// POST /test — Test notification delivery
@@ -159,7 +159,7 @@ module.exports = function(ctx) {
res.json({
success: true,
history: notificationHistory.slice(0, limit),
total: notificationHistory.length
total: notificationHistory.length,
});
}
}, 'notifications-history'));
@@ -177,7 +177,7 @@ module.exports = function(ctx) {
res.json({
success: true,
lastCheck: notificationConfig.healthCheck.lastCheck,
containersMonitored: Object.keys(ctx.notification.getHealthState()).length
containersMonitored: Object.keys(ctx.notification.getHealthState()).length,
});
}, 'notifications-health-check'));

View File

@@ -42,7 +42,7 @@ module.exports = function(ctx) {
await ctx.docker.client.createNetwork({
Name: networkName,
Driver: recipe.network.driver || 'bridge',
Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId }
Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId },
});
ctx.log.info('recipe', 'Created Docker network', { networkName });
} catch (e) {
@@ -62,18 +62,18 @@ module.exports = function(ctx) {
try {
ctx.log.info('recipe', `Deploying component: ${component.id}`, {
role: component.role,
internal: component.internal || false
internal: component.internal || false,
});
const result = await deployComponent(component, recipe, config, generatedPasswords, networkName);
deployedComponents.push(result);
ctx.log.info('recipe', `Component deployed: ${component.id}`, {
containerId: result.containerId?.substring(0, 12)
containerId: result.containerId?.substring(0, 12),
});
} catch (componentError) {
ctx.log.error('recipe', `Component failed: ${component.id}`, {
error: componentError.message
error: componentError.message,
});
errors.push({ componentId: component.id, role: component.role, error: componentError.message });
// Continue deploying other components — partial success is better than total failure
@@ -96,7 +96,7 @@ module.exports = function(ctx) {
recipeId: recipeId,
recipeRole: deployed.role,
tailscaleOnly: config.sharedConfig?.tailscaleOnly || false,
deployedAt: new Date().toISOString()
deployedAt: new Date().toISOString(),
});
}
}
@@ -119,18 +119,18 @@ module.exports = function(ctx) {
role: c.role,
containerId: c.containerId?.substring(0, 12),
url: c.url,
internal: c.internal
internal: c.internal,
})),
errors: errors.length > 0 ? errors : undefined,
message: errors.length > 0
? `${recipe.name} partially deployed (${deployedComponents.length}/${componentsToDeploy.length} components)`
: `${recipe.name} deployed successfully!`,
setupInstructions: recipe.setupInstructions
setupInstructions: recipe.setupInstructions,
};
ctx.notification.send('deploymentSuccess', 'Recipe Deployed',
`**${recipe.name}** recipe deployed (${deployedComponents.length} components).`,
'success'
'success',
);
res.json(response);
@@ -146,7 +146,7 @@ module.exports = function(ctx) {
}
} catch (cleanupError) {
ctx.log.warn('recipe', 'Cleanup failed for component', {
componentId: deployed.id, error: cleanupError.message
componentId: deployed.id, error: cleanupError.message,
});
}
}
@@ -162,7 +162,7 @@ module.exports = function(ctx) {
}
ctx.notification.send('deploymentFailed', 'Recipe Failed',
`Failed to deploy **${recipe.name}**: ${error.message}`, 'error'
`Failed to deploy **${recipe.name}**: ${error.message}`, 'error',
);
ctx.errorResponse(res, 500, error.message);
@@ -254,7 +254,7 @@ module.exports = function(ctx) {
HostConfig: {
PortBindings: {},
Binds: dockerConfig.volumes || [],
RestartPolicy: { Name: 'unless-stopped' }
RestartPolicy: { Name: 'unless-stopped' },
},
Env: Object.entries(dockerConfig.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: {
@@ -264,8 +264,8 @@ module.exports = function(ctx) {
'sami.recipe.component': component.id,
'sami.recipe.role': component.role,
'sami.subdomain': subdomain,
'sami.deployed': new Date().toISOString()
}
'sami.deployed': new Date().toISOString(),
},
};
// Configure ports
@@ -288,7 +288,7 @@ module.exports = function(ctx) {
} catch (e) {
ctx.log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`);
const images = await ctx.docker.client.listImages({
filters: { reference: [dockerConfig.image] }
filters: { reference: [dockerConfig.image] },
});
if (images.length === 0) throw new Error(`Image not found: ${dockerConfig.image}`);
}
@@ -324,7 +324,7 @@ module.exports = function(ctx) {
const primaryPort = port || dockerConfig.ports[0].split(/[:/]/)[0];
const caddyConfig = ctx.caddy.generateConfig(
subdomain, hostIp, primaryPort,
{ tailscaleOnly: sharedConfig.tailscaleOnly || false }
{ tailscaleOnly: sharedConfig.tailscaleOnly || false },
);
try {
const helpers = require('../apps/helpers')(ctx);
@@ -344,7 +344,7 @@ module.exports = function(ctx) {
internal: component.internal || false,
templateRef: component.templateRef,
logo,
url
url,
};
}

View File

@@ -29,9 +29,9 @@ module.exports = function(ctx) {
required: c.required,
internal: c.internal || false,
templateRef: c.templateRef || null,
note: c.note || null
note: c.note || null,
})),
setupInstructions: recipe.setupInstructions
setupInstructions: recipe.setupInstructions,
}));
res.json({ success: true, templates, categories: RECIPE_CATEGORIES });

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
if (!recipeGroups[service.recipeId]) {
recipeGroups[service.recipeId] = {
recipeId: service.recipeId,
components: []
components: [],
};
}
recipeGroups[service.recipeId].components.push({
@@ -25,7 +25,7 @@ module.exports = function(ctx) {
logo: service.logo,
containerId: service.containerId,
recipeRole: service.recipeRole,
deployedAt: service.deployedAt
deployedAt: service.deployedAt,
});
}
@@ -48,7 +48,7 @@ module.exports = function(ctx) {
// Check if this container is already listed (by containerId)
const existing = recipeGroups[recipeId].components.find(
c => c.containerId === containerInfo.Id
c => c.containerId === containerInfo.Id,
);
if (existing) continue;
@@ -59,7 +59,7 @@ module.exports = function(ctx) {
recipeRole: labels['sami.recipe.role'] || 'Unknown',
internal: true,
state: containerInfo.State,
status: containerInfo.Status
status: containerInfo.Status,
});
}
} catch (e) {
@@ -242,7 +242,7 @@ module.exports = function(ctx) {
ctx.notification.send('recipeRemoved', 'Recipe Removed',
`Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`,
'info'
'info',
);
ctx.log.info('recipe', 'Recipe removed', { recipeId, results });
@@ -271,7 +271,7 @@ module.exports = function(ctx) {
Id: c.Id,
component: c.Labels['sami.recipe.component'] || c.Names[0]?.replace('/', ''),
role: c.Labels['sami.recipe.role'] || 'Unknown',
state: c.State
state: c.State,
}));
}
@@ -293,7 +293,7 @@ module.exports = function(ctx) {
*/
async function removeCaddyBlock(subdomain) {
const domain = ctx.buildDomain(subdomain);
let content = await ctx.caddy.read();
const content = await ctx.caddy.read();
// Find and remove the block for this domain
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

View File

@@ -99,7 +99,7 @@ module.exports = function(ctx) {
isUp: false,
statusCode: 502,
responseTime,
error: error.message
error: error.message,
};
}
@@ -108,7 +108,7 @@ module.exports = function(ctx) {
isUp: isServiceUp(statusCode),
statusCode,
responseTime,
url
url,
};
}
@@ -169,7 +169,7 @@ module.exports = function(ctx) {
success: true,
hasApiKey: !!(arrKey || svcKey),
hasBasicAuth: !!username,
username: username || null
username: username || null,
});
} catch (error) {
res.json({ success: true, hasApiKey: false, hasBasicAuth: false });
@@ -249,7 +249,7 @@ module.exports = function(ctx) {
services.forEach(service => addId(service.id));
const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) =>
probeServiceStatus(id, serviceMap.get(id))
probeServiceStatus(id, serviceMap.get(id)),
);
const statuses = {};
@@ -261,7 +261,7 @@ module.exports = function(ctx) {
res.json({
success: true,
checkedAt: new Date().toISOString(),
statuses
statuses,
});
}, 'services-status'));
@@ -343,7 +343,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: `Successfully imported ${services.length} services`,
count: services.length
count: services.length,
});
}, 'services-import'));
@@ -396,12 +396,12 @@ module.exports = function(ctx) {
const oldDomain = ctx.buildDomain(oldSubdomain);
const newDomain = ctx.buildDomain(newSubdomain);
let content = await ctx.caddy.read();
const content = await ctx.caddy.read();
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(
`${escapedOldDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`,
's'
's',
);
const oldBlockMatch = content.match(siteBlockRegex);
@@ -414,7 +414,7 @@ module.exports = function(ctx) {
const finalPort = port || existingPort;
const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, {
tailscaleOnly: tailscaleOnly || false
tailscaleOnly: tailscaleOnly || false,
});
const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig));
@@ -445,7 +445,7 @@ module.exports = function(ctx) {
id: newSubdomain,
port: port || services[serviceIndex].port,
ip: ip || services[serviceIndex].ip,
tailscaleOnly: tailscaleOnly || false
tailscaleOnly: tailscaleOnly || false,
};
results.services = 'updated';
} else {
@@ -459,7 +459,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,
results
results,
});
}, 'services-update'));

View File

@@ -25,7 +25,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: caddyfileContent
body: caddyfileContent,
});
if (!response.ok) {
@@ -39,80 +39,80 @@ module.exports = function(ctx) {
// Get Certificate Authorities from Caddyfile
router.get('/caddy/cas', ctx.asyncHandler(async (req, res) => {
const content = await ctx.caddy.read();
const cas = [];
const content = await ctx.caddy.read();
const cas = [];
const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
let pkiMatch;
while ((pkiMatch = pkiRegex.exec(content)) !== null) {
const pkiBlock = pkiMatch[1];
let caMatch;
const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) {
const caName = caMatch[1];
const caBlock = caMatch[2];
const ca = { id: caName, name: caName, root: {}, intermediate: {} };
const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
let pkiMatch;
while ((pkiMatch = pkiRegex.exec(content)) !== null) {
const pkiBlock = pkiMatch[1];
let caMatch;
const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) {
const caName = caMatch[1];
const caBlock = caMatch[2];
const ca = { id: caName, name: caName, root: {}, intermediate: {} };
const nameMatch = /name\s+"([^"]+)"/.exec(caBlock);
if (nameMatch) ca.name = nameMatch[1];
const nameMatch = /name\s+"([^"]+)"/.exec(caBlock);
if (nameMatch) ca.name = nameMatch[1];
const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock);
const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock);
if (rootCnMatch) ca.root_cn = rootCnMatch[1];
if (intCnMatch) ca.intermediate_cn = intCnMatch[1];
const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock);
const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock);
if (rootCnMatch) ca.root_cn = rootCnMatch[1];
if (intCnMatch) ca.intermediate_cn = intCnMatch[1];
const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock);
if (rootMatch) {
const rootBlock = rootMatch[1];
const certMatch = /cert\s+(\S+)/.exec(rootBlock);
const keyMatch = /key\s+(\S+)/.exec(rootBlock);
if (certMatch) ca.root.cert = certMatch[1];
if (keyMatch) ca.root.key = keyMatch[1];
}
const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock);
if (intMatch) {
const intBlock = intMatch[1];
const certMatch = /cert\s+(\S+)/.exec(intBlock);
const keyMatch = /key\s+(\S+)/.exec(intBlock);
if (certMatch) ca.intermediate.cert = certMatch[1];
if (keyMatch) ca.intermediate.key = keyMatch[1];
}
cas.push(ca);
const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock);
if (rootMatch) {
const rootBlock = rootMatch[1];
const certMatch = /cert\s+(\S+)/.exec(rootBlock);
const keyMatch = /key\s+(\S+)/.exec(rootBlock);
if (certMatch) ca.root.cert = certMatch[1];
if (keyMatch) ca.root.key = keyMatch[1];
}
}
const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g;
let tlsMatch;
while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) {
cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' });
}
const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || [];
const tlsInternalCAs = new Set();
for (const block of siteBlocks) {
const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block);
if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]);
if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) {
tlsInternalCAs.add('local');
const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock);
if (intMatch) {
const intBlock = intMatch[1];
const certMatch = /cert\s+(\S+)/.exec(intBlock);
const keyMatch = /key\s+(\S+)/.exec(intBlock);
if (certMatch) ca.intermediate.cert = certMatch[1];
if (keyMatch) ca.intermediate.key = keyMatch[1];
}
}
for (const caName of tlsInternalCAs) {
if (!cas.find(c => c.name === caName)) {
cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' });
}
}
if (cas.length === 0 && /tls\s+internal/.test(content)) {
cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' });
}
const caList = cas.map(ca => ({
id: ca.id || ca.name,
name: ca.name,
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name
}));
res.json({ status: 'success', data: { cas: caList } });
cas.push(ca);
}
}
const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g;
let tlsMatch;
while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) {
cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' });
}
const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || [];
const tlsInternalCAs = new Set();
for (const block of siteBlocks) {
const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block);
if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]);
if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) {
tlsInternalCAs.add('local');
}
}
for (const caName of tlsInternalCAs) {
if (!cas.find(c => c.name === caName)) {
cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' });
}
}
if (cas.length === 0 && /tls\s+internal/.test(content)) {
cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' });
}
const caList = cas.map(ca => ({
id: ca.id || ca.name,
name: ca.name,
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name,
}));
res.json({ status: 'success', data: { cas: caList } });
}, 'caddy-get-cas'));
// Remove a site from Caddyfile
@@ -123,7 +123,7 @@ module.exports = function(ctx) {
const result = await ctx.caddy.modify((content) => {
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(
`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g'
`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g',
);
const modified = content.replace(siteBlockRegex, '\n');
if (modified.length === content.length) return null;
@@ -149,7 +149,7 @@ module.exports = function(ctx) {
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
if (!upstreamRegex.test(upstream)) return ctx.errorResponse(res, 400, 'Invalid upstream format. Use host:port');
let content = await ctx.caddy.read();
const content = await ctx.caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g');
if (siteBlockRegex.test(content)) {
@@ -200,7 +200,7 @@ module.exports = function(ctx) {
}
const sslConfig = sslType === 'letsencrypt' ? '' : 'tls internal';
const hostHeader = preserveHost ? `\n header_up Host {upstream_hostport}` : '';
const hostHeader = preserveHost ? '\n header_up Host {upstream_hostport}' : '';
const urlObj = new URL(externalUrl);
@@ -238,7 +238,7 @@ module.exports = function(ctx) {
await ctx.addServiceToConfig({
id: subdomain, name: serviceName, logo,
isExternal: true, externalUrl,
deployedAt: new Date().toISOString()
deployedAt: new Date().toISOString(),
});
ctx.log.info('deploy', 'Service added to dashboard', { subdomain });
} catch (serviceError) {
@@ -248,7 +248,7 @@ module.exports = function(ctx) {
const response = {
success: true,
message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`
message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`,
};
if (dnsWarning) response.warning = dnsWarning;
res.json(response);

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
success: true,
installed: false,
connected: false,
message: 'Tailscale not available or not running'
message: 'Tailscale not available or not running',
});
}
@@ -30,7 +30,7 @@ module.exports = function(ctx) {
os: peer.OS,
online: peer.Online,
lastSeen: peer.LastSeen,
user: peer.UserID
user: peer.UserID,
});
}
}
@@ -44,11 +44,11 @@ module.exports = function(ctx) {
hostname: status.Self?.HostName,
ip: localIP,
tailnetName: status.MagicDNSSuffix,
online: status.Self?.Online
online: status.Self?.Online,
},
config: ctx.tailscale.config,
devices,
deviceCount: devices.length
deviceCount: devices.length,
});
}, 'tailscale-status'));
@@ -65,7 +65,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'Tailscale configuration updated',
config: ctx.tailscale.config
config: ctx.tailscale.config,
});
}, 'tailscale-config'));
@@ -83,7 +83,7 @@ module.exports = function(ctx) {
isTailscale,
clientIP,
forwardedFor: forwardedFor || null,
realIP: realIP || null
realIP: realIP || null,
});
}, 'tailscale-check'));
@@ -102,7 +102,7 @@ module.exports = function(ctx) {
hostname: peer.HostName,
ip: peer.TailscaleIPs?.[0],
os: peer.OS,
user: peer.UserID
user: peer.UserID,
});
}
}
@@ -114,7 +114,7 @@ module.exports = function(ctx) {
ip: status.Self.TailscaleIPs?.[0],
os: status.Self.OS,
user: status.Self.UserID,
isSelf: true
isSelf: true,
});
}
@@ -129,7 +129,7 @@ module.exports = function(ctx) {
return ctx.errorResponse(res, 400, 'subdomain is required');
}
let content = await ctx.caddy.read();
const content = await ctx.caddy.read();
const domain = ctx.buildDomain(subdomain);
const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's');
@@ -149,7 +149,7 @@ module.exports = function(ctx) {
const newConfig = ctx.caddy.generateConfig(subdomain, ip, port || '80', {
tailscaleOnly: tailscaleOnly !== false,
allowedIPs: allowedIPs || []
allowedIPs: allowedIPs || [],
});
const caddyResult = await ctx.caddy.modify(c => c.replace(blockRegex, newConfig));
@@ -170,7 +170,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`,
tailscaleOnly: tailscaleOnly !== false
tailscaleOnly: tailscaleOnly !== false,
});
}, 'tailscale-protect'));
@@ -188,7 +188,7 @@ module.exports = function(ctx) {
const tokenRes = await fetch(TAILSCALE.OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`
body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`,
});
if (!tokenRes.ok) {
@@ -199,7 +199,7 @@ module.exports = function(ctx) {
// Test with the device list to verify scopes
const testRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, {
headers: { Authorization: `Bearer ${tokenData.access_token}` }
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!testRes.ok) {
@@ -259,7 +259,7 @@ module.exports = function(ctx) {
res.json({
success: true,
devices: ctx.tailscale.config.devices || [],
lastSync: ctx.tailscale.config.lastSync
lastSync: ctx.tailscale.config.lastSync,
});
}, 'tailscale-api-devices'));
@@ -274,7 +274,7 @@ module.exports = function(ctx) {
res.json({
success: true,
devices: devices || [],
lastSync: ctx.tailscale.config.lastSync
lastSync: ctx.tailscale.config.lastSync,
});
}, 'tailscale-sync'));
@@ -287,7 +287,7 @@ module.exports = function(ctx) {
}
const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!aclRes.ok) {
return ctx.errorResponse(res, aclRes.status, `ACL fetch failed: HTTP ${aclRes.status}`);
@@ -299,7 +299,7 @@ module.exports = function(ctx) {
groups: Object.keys(acl.groups || {}),
tagOwners: Object.keys(acl.tagOwners || {}),
aclRuleCount: (acl.acls || []).length,
sshRuleCount: (acl.ssh || []).length
sshRuleCount: (acl.ssh || []).length,
};
res.json({ success: true, acl, summary });

View File

@@ -46,15 +46,15 @@ module.exports = function(ctx) {
const themeData = { name, ...colors };
if (lightBg) themeData.lightBg = true;
fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(themeData, null, 2), 'utf8');
fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(themeData, null, 2), 'utf8');
res.json({ success: true, message: name + ' theme saved' });
res.json({ success: true, message: `${name } theme saved` });
});
// Delete a theme
router.delete('/themes/:slug', (req, res) => {
const { slug } = req.params;
const filePath = path.join(THEMES_DIR, slug + '.json');
const filePath = path.join(THEMES_DIR, `${slug }.json`);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ success: false, error: 'Theme not found' });
@@ -64,7 +64,7 @@ module.exports = function(ctx) {
const name = data.name || slug;
fs.unlinkSync(filePath);
res.json({ success: true, message: name + ' theme deleted' });
res.json({ success: true, message: `${name } theme deleted` });
});
return router;