Compare commits

...

7 Commits

Author SHA1 Message Date
Krystie
a216dd882d Add dashboard version button and self-update UI wiring 2026-05-05 17:52:05 -07:00
Krystie
95b137bf17 Fix DNS2 self-updater path and sync live dashboard version UI 2026-05-05 17:26:42 -07:00
Krystie
f5fe32b999 feat(update): add release policy checks and dashboard version verification 2026-05-04 18:05:00 -07:00
Krystie
0c658a26a8 fix(routes): complete post-refactor dependency wiring cleanup 2026-05-04 16:44:18 -07:00
Krystie
4eebb3ce7a fix(test): remove stale jest setupFilesAfterEnv entry 2026-05-04 16:36:09 -07:00
Krystie
55c405082a fix: use TIMEOUTS constants instead of magic numbers in health and services routes
- health.js: replace magic number 5000 with TIMEOUTS.HTTP_DEFAULT (twice)
- services.js: replace magic number 5000 with TIMEOUTS.HTTP_DEFAULT

Both files already import TIMEOUTS from constants but weren't using it.
2026-05-01 02:36:31 -07:00
Krystie
2f1e2107bc fix: replace console.log/console.error with proper logging in monitoring and themes routes
- monitoring.js: Added log dependency, replaced console.log with log.warn
- themes.js: Added log dependency, replaced console.error with log.error
- src/app.js: Pass log to monitoringRoutes and themesRoutes

This fixes error messages being lost to stdout instead of proper log files.
2026-05-01 02:24:59 -07:00
43 changed files with 955 additions and 439 deletions

View File

@@ -53,5 +53,30 @@ module.exports = {
'max-depth': 'off', 'max-depth': 'off',
}, },
}, },
{
// Browser-side assets (client JS)
files: ['assets/**/*.js', 'frontend/**/*.js'],
env: {
browser: true,
es2021: true,
node: false,
},
globals: {
// Common dashboard globals from status/index.html context
apiUrl: 'readonly',
API_BASE_URL: 'readonly',
CONFIG: 'readonly',
// Client-side dashboard classes (loaded via script tags)
ErrorHandler: 'readonly',
ProgressTracker: 'readonly',
ThemeAdapter: 'readonly',
DnsTemplateSelector: 'readonly',
TourManager: 'readonly',
TooltipDefinitions: 'readonly',
},
rules: {
'no-undef': 'warn',
},
},
], ],
}; };

View File

@@ -14,6 +14,9 @@ const {
} = require('../input-validator'); } = require('../input-validator');
describe('Input Validator', () => { describe('Input Validator', () => {
function fail(message) {
throw new Error(message);
}
describe('ValidationError', () => { describe('ValidationError', () => {
it('has correct name, message, field, and statusCode', () => { it('has correct name, message, field, and statusCode', () => {

View File

@@ -524,9 +524,7 @@ describe('Health Routes', () => {
it('returns error when no url parameter provided', async () => { it('returns error when no url parameter provided', async () => {
const { app } = createApp(); const { app } = createApp();
const res = await request(app).get('/api/health/probe'); const res = await request(app).get('/api/health/probe');
// ValidationError is not imported at module scope, so this throws a ReferenceError expect(res.status).toBe(400);
// which the error handler catches as a 500
expect(res.status).toBe(500);
}); });
}); });

View File

@@ -1,308 +1,308 @@
/** /**
* Theme Adapter * Theme Adapter
* Ensures tooltips match the current dashboard theme * Ensures tooltips match the current dashboard theme
* Integrates with Driver.js to apply theme-specific styling * Integrates with Driver.js to apply theme-specific styling
*/ */
(function(window) { (function(window) {
'use strict'; 'use strict';
/** /**
* Theme configuration mapping for Driver.js * Theme configuration mapping for Driver.js
* Maps dashboard themes to Driver.js styling * Maps dashboard themes to Driver.js styling
*/ */
const THEME_CONFIGS = { const THEME_CONFIGS = {
dark: { dark: {
backgroundColor: 'var(--card-base)', backgroundColor: 'var(--card-base)',
textColor: 'var(--fg)', textColor: 'var(--fg)',
primaryColor: 'var(--accent)', primaryColor: 'var(--accent)',
overlayColor: 'rgba(0, 0, 0, 0.7)', overlayColor: 'rgba(0, 0, 0, 0.7)',
borderColor: 'var(--border)', borderColor: 'var(--border)',
highlightColor: 'var(--accent)', highlightColor: 'var(--accent)',
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
}, },
light: { light: {
backgroundColor: 'var(--card-base)', backgroundColor: 'var(--card-base)',
textColor: 'var(--fg)', textColor: 'var(--fg)',
primaryColor: 'var(--accent-strong)', primaryColor: 'var(--accent-strong)',
overlayColor: 'rgba(0, 0, 0, 0.5)', overlayColor: 'rgba(0, 0, 0, 0.5)',
borderColor: 'var(--border)', borderColor: 'var(--border)',
highlightColor: 'var(--accent-strong)', highlightColor: 'var(--accent-strong)',
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
}, },
blue: { blue: {
backgroundColor: 'var(--card-base)', backgroundColor: 'var(--card-base)',
textColor: 'var(--fg)', textColor: 'var(--fg)',
primaryColor: 'var(--accent)', primaryColor: 'var(--accent)',
overlayColor: 'rgba(25, 8, 172, 0.7)', overlayColor: 'rgba(25, 8, 172, 0.7)',
borderColor: 'var(--border)', borderColor: 'var(--border)',
highlightColor: 'var(--accent)', highlightColor: 'var(--accent)',
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
}, },
nord: { nord: {
backgroundColor: 'var(--card-base)', backgroundColor: 'var(--card-base)',
textColor: 'var(--fg)', textColor: 'var(--fg)',
primaryColor: 'var(--accent)', primaryColor: 'var(--accent)',
overlayColor: 'rgba(46, 52, 64, 0.7)', overlayColor: 'rgba(46, 52, 64, 0.7)',
borderColor: 'var(--border)', borderColor: 'var(--border)',
highlightColor: 'var(--accent)', highlightColor: 'var(--accent)',
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
}, },
dracula: { dracula: {
backgroundColor: 'var(--card-base)', backgroundColor: 'var(--card-base)',
textColor: 'var(--fg)', textColor: 'var(--fg)',
primaryColor: 'var(--accent)', primaryColor: 'var(--accent)',
overlayColor: 'rgba(40, 42, 54, 0.7)', overlayColor: 'rgba(40, 42, 54, 0.7)',
borderColor: 'var(--border)', borderColor: 'var(--border)',
highlightColor: 'var(--accent)', highlightColor: 'var(--accent)',
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
}, },
'solarized-dark': { 'solarized-dark': {
backgroundColor: 'var(--card-base)', backgroundColor: 'var(--card-base)',
textColor: 'var(--fg)', textColor: 'var(--fg)',
primaryColor: 'var(--accent)', primaryColor: 'var(--accent)',
overlayColor: 'rgba(0, 43, 54, 0.7)', overlayColor: 'rgba(0, 43, 54, 0.7)',
borderColor: 'var(--border)', borderColor: 'var(--border)',
highlightColor: 'var(--accent)', highlightColor: 'var(--accent)',
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
}, },
'solarized-light': { 'solarized-light': {
backgroundColor: 'var(--card-base)', backgroundColor: 'var(--card-base)',
textColor: 'var(--fg)', textColor: 'var(--fg)',
primaryColor: 'var(--accent)', primaryColor: 'var(--accent)',
overlayColor: 'rgba(253, 246, 227, 0.7)', overlayColor: 'rgba(253, 246, 227, 0.7)',
borderColor: 'var(--border)', borderColor: 'var(--border)',
highlightColor: 'var(--accent)', highlightColor: 'var(--accent)',
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
} }
}; };
/** /**
* ThemeAdapter class * ThemeAdapter class
* Manages theme integration for the tooltip system * Manages theme integration for the tooltip system
*/ */
class ThemeAdapter { class ThemeAdapter {
constructor() { constructor() {
this.currentTheme = this.getCurrentTheme(); this.currentTheme = this.getCurrentTheme();
this.themeChangeCallbacks = []; this.themeChangeCallbacks = [];
this._setupThemeChangeListener(); this._setupThemeChangeListener();
} }
/** /**
* Get the current theme name from document root class * Get the current theme name from document root class
* @returns {string} Current theme name (e.g., 'dark', 'light', 'blue') * @returns {string} Current theme name (e.g., 'dark', 'light', 'blue')
*/ */
getCurrentTheme() { getCurrentTheme() {
const root = document.documentElement; const root = document.documentElement;
const classList = Array.from(root.classList); const classList = Array.from(root.classList);
// Check for theme classes // Check for theme classes
const themeClasses = ['light', 'blue', 'nord', 'dracula', 'solarized-dark', 'solarized-light']; const themeClasses = ['light', 'blue', 'nord', 'dracula', 'solarized-dark', 'solarized-light'];
const foundTheme = themeClasses.find(theme => classList.includes(theme)); const foundTheme = themeClasses.find(theme => classList.includes(theme));
// Default to 'dark' if no theme class found // Default to 'dark' if no theme class found
return foundTheme || 'dark'; return foundTheme || 'dark';
} }
/** /**
* Get Driver.js theme configuration for current theme * Get Driver.js theme configuration for current theme
* @returns {Object} Theme configuration object * @returns {Object} Theme configuration object
*/ */
getDriverTheme() { getDriverTheme() {
const themeName = this.getCurrentTheme(); const themeName = this.getCurrentTheme();
const config = THEME_CONFIGS[themeName] || THEME_CONFIGS.dark; const config = THEME_CONFIGS[themeName] || THEME_CONFIGS.dark;
// Resolve CSS variables to actual values // Resolve CSS variables to actual values
const resolvedConfig = {}; const resolvedConfig = {};
for (const [key, value] of Object.entries(config)) { for (const [key, value] of Object.entries(config)) {
if (typeof value === 'string' && value.startsWith('var(')) { if (typeof value === 'string' && value.startsWith('var(')) {
// Extract CSS variable name // Extract CSS variable name
const varName = value.match(/var\((--[^)]+)\)/)?.[1]; const varName = value.match(/var\((--[^)]+)\)/)?.[1];
if (varName) { if (varName) {
const computedValue = getComputedStyle(document.documentElement) const computedValue = getComputedStyle(document.documentElement)
.getPropertyValue(varName) .getPropertyValue(varName)
.trim(); .trim();
resolvedConfig[key] = computedValue || value; resolvedConfig[key] = computedValue || value;
} else { } else {
resolvedConfig[key] = value; resolvedConfig[key] = value;
} }
} else { } else {
resolvedConfig[key] = value; resolvedConfig[key] = value;
} }
} }
return resolvedConfig; return resolvedConfig;
} }
/** /**
* Register a callback for theme changes * Register a callback for theme changes
* @param {Function} callback - Function to call when theme changes * @param {Function} callback - Function to call when theme changes
*/ */
onThemeChange(callback) { onThemeChange(callback) {
if (typeof callback === 'function') { if (typeof callback === 'function') {
this.themeChangeCallbacks.push(callback); this.themeChangeCallbacks.push(callback);
} }
} }
/** /**
* Setup theme change listener using MutationObserver * Setup theme change listener using MutationObserver
* @private * @private
*/ */
_setupThemeChangeListener() { _setupThemeChangeListener() {
const root = document.documentElement; const root = document.documentElement;
// Create observer to watch for class changes on root element // Create observer to watch for class changes on root element
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') { if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const newTheme = this.getCurrentTheme(); const newTheme = this.getCurrentTheme();
if (newTheme !== this.currentTheme) { if (newTheme !== this.currentTheme) {
const oldTheme = this.currentTheme; const oldTheme = this.currentTheme;
this.currentTheme = newTheme; this.currentTheme = newTheme;
this._notifyThemeChange(newTheme, oldTheme); this._notifyThemeChange(newTheme, oldTheme);
} }
} }
}); });
}); });
// Start observing // Start observing
observer.observe(root, { observer.observe(root, {
attributes: true, attributes: true,
attributeFilter: ['class'] attributeFilter: ['class']
}); });
console.log('[ThemeAdapter] Theme change listener initialized'); console.log('[ThemeAdapter] Theme change listener initialized');
} }
/** /**
* Notify all registered callbacks of theme change * Notify all registered callbacks of theme change
* @private * @private
* @param {string} newTheme - New theme name * @param {string} newTheme - New theme name
* @param {string} oldTheme - Old theme name * @param {string} oldTheme - Old theme name
*/ */
_notifyThemeChange(newTheme, oldTheme) { _notifyThemeChange(newTheme, oldTheme) {
console.log(`[ThemeAdapter] Theme changed: ${oldTheme}${newTheme}`); console.log(`[ThemeAdapter] Theme changed: ${oldTheme}${newTheme}`);
this.themeChangeCallbacks.forEach(callback => { this.themeChangeCallbacks.forEach(callback => {
try { try {
callback(newTheme, oldTheme); callback(newTheme, oldTheme);
} catch (error) { } catch (error) {
console.error('[ThemeAdapter] Error in theme change callback:', error); console.error('[ThemeAdapter] Error in theme change callback:', error);
} }
}); });
} }
/** /**
* Apply theme to Driver.js instance * Apply theme to Driver.js instance
* @param {Object} driver - Driver.js instance * @param {Object} driver - Driver.js instance
*/ */
applyTheme(driver) { applyTheme(driver) {
if (!driver) { if (!driver) {
console.warn('[ThemeAdapter] No driver instance provided'); console.warn('[ThemeAdapter] No driver instance provided');
return; return;
} }
const themeConfig = this.getDriverTheme(); const themeConfig = this.getDriverTheme();
// Apply theme configuration to driver // Apply theme configuration to driver
// Note: Driver.js v1.0+ uses CSS variables, so we inject a style element // Note: Driver.js v1.0+ uses CSS variables, so we inject a style element
this._injectDriverStyles(themeConfig); this._injectDriverStyles(themeConfig);
console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme); console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme);
} }
/** /**
* Inject custom styles for Driver.js based on theme * Inject custom styles for Driver.js based on theme
* @private * @private
* @param {Object} themeConfig - Theme configuration * @param {Object} themeConfig - Theme configuration
*/ */
_injectDriverStyles(themeConfig) { _injectDriverStyles(themeConfig) {
// Remove existing theme styles // Remove existing theme styles
const existingStyle = document.getElementById('driver-theme-styles'); const existingStyle = document.getElementById('driver-theme-styles');
if (existingStyle) { if (existingStyle) {
existingStyle.remove(); existingStyle.remove();
} }
// Create new style element // Create new style element
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'driver-theme-styles'; style.id = 'driver-theme-styles';
style.textContent = ` style.textContent = `
.driver-popover { .driver-popover {
background: ${themeConfig.backgroundColor} !important; background: ${themeConfig.backgroundColor} !important;
color: ${themeConfig.textColor} !important; color: ${themeConfig.textColor} !important;
border: 1px solid ${themeConfig.borderColor} !important; border: 1px solid ${themeConfig.borderColor} !important;
border-radius: 12px !important; border-radius: 12px !important;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important;
font-family: ${themeConfig.fontFamily} !important; font-family: ${themeConfig.fontFamily} !important;
} }
.driver-popover-title { .driver-popover-title {
color: ${themeConfig.textColor} !important; color: ${themeConfig.textColor} !important;
font-weight: 600 !important; font-weight: 600 !important;
font-family: ${themeConfig.fontFamily} !important; font-family: ${themeConfig.fontFamily} !important;
} }
.driver-popover-description { .driver-popover-description {
color: ${themeConfig.textColor} !important; color: ${themeConfig.textColor} !important;
font-family: ${themeConfig.fontFamily} !important; font-family: ${themeConfig.fontFamily} !important;
} }
.driver-popover-footer button { .driver-popover-footer button {
background: ${themeConfig.primaryColor} !important; background: ${themeConfig.primaryColor} !important;
color: ${themeConfig.backgroundColor} !important; color: ${themeConfig.backgroundColor} !important;
border: none !important; border: none !important;
font-family: ${themeConfig.fontFamily} !important; font-family: ${themeConfig.fontFamily} !important;
font-weight: 500 !important; font-weight: 500 !important;
} }
.driver-popover-footer button:hover { .driver-popover-footer button:hover {
opacity: 0.9 !important; opacity: 0.9 !important;
} }
.driver-popover-close-btn { .driver-popover-close-btn {
color: ${themeConfig.textColor} !important; color: ${themeConfig.textColor} !important;
} }
.driver-overlay { .driver-overlay {
background: ${themeConfig.overlayColor} !important; background: ${themeConfig.overlayColor} !important;
} }
.driver-highlighted-element { .driver-highlighted-element {
outline: 2px solid ${themeConfig.highlightColor} !important; outline: 2px solid ${themeConfig.highlightColor} !important;
outline-offset: 4px !important; outline-offset: 4px !important;
} }
.driver-popover-progress-text { .driver-popover-progress-text {
color: ${themeConfig.textColor} !important; color: ${themeConfig.textColor} !important;
opacity: 0.7 !important; opacity: 0.7 !important;
font-family: ${themeConfig.fontFamily} !important; font-family: ${themeConfig.fontFamily} !important;
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
} }
/** /**
* Get all available theme names * Get all available theme names
* @returns {string[]} Array of theme names * @returns {string[]} Array of theme names
*/ */
getAvailableThemes() { getAvailableThemes() {
return Object.keys(THEME_CONFIGS); return Object.keys(THEME_CONFIGS);
} }
/** /**
* Check if a theme is available * Check if a theme is available
* @param {string} themeName - Theme name to check * @param {string} themeName - Theme name to check
* @returns {boolean} True if theme is available * @returns {boolean} True if theme is available
*/ */
isThemeAvailable(themeName) { isThemeAvailable(themeName) {
return THEME_CONFIGS.hasOwnProperty(themeName); return Object.hasOwn(THEME_CONFIGS, themeName);
} }
} }
// Export to global scope // Export to global scope
window.ThemeAdapter = ThemeAdapter; window.ThemeAdapter = ThemeAdapter;
console.log('[ThemeAdapter] Module loaded'); console.log('[ThemeAdapter] Module loaded');
})(window); })(window);

View File

@@ -75,7 +75,7 @@ class BackupManager extends EventEmitter {
case 'monthly': case 'monthly':
intervalMs = 30 * 24 * 60 * 60 * 1000; intervalMs = 30 * 24 * 60 * 60 * 1000;
break; break;
default: default: {
// Custom interval in minutes // Custom interval in minutes
const minutes = parseInt(backup.schedule, 10); const minutes = parseInt(backup.schedule, 10);
if (!isNaN(minutes) && minutes > 0) { if (!isNaN(minutes) && minutes > 0) {
@@ -84,6 +84,7 @@ class BackupManager extends EventEmitter {
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`); console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
return; return;
} }
}
} }
// Schedule the job // Schedule the job

View File

@@ -20,7 +20,7 @@ const colors = {
magenta: '\x1b[35m' magenta: '\x1b[35m'
}; };
let testResults = { const testResults = {
passed: 0, passed: 0,
failed: 0, failed: 0,
warnings: 0, warnings: 0,

View File

@@ -507,7 +507,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul
const suspiciousPatterns = [ const suspiciousPatterns = [
/\.\./, // .. /\.\./, // ..
/%2e%2e/i, // URL encoded .. /%2e%2e/i, // URL encoded ..
/\.\%2f/i, // .%2F (encoded ./) /\.%2f/i, // .%2F (encoded ./)
/%2e\./i, // %2E. /%2e\./i, // %2E.
/\.\\/, // .\ (Windows) /\.\\/, // .\ (Windows)
/%5c/i // URL encoded backslash /%5c/i // URL encoded backslash

View File

@@ -32,7 +32,6 @@ module.exports = {
statements: 80 statements: 80
} }
}, },
setupFilesAfterEnv: ['<rootDir>/__tests__/jest.setup.js'],
restoreMocks: true, restoreMocks: true,
clearMocks: true clearMocks: true
}; };

View File

@@ -256,7 +256,9 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
const existing = docker.client.getContainer(containerName); const existing = docker.client.getContainer(containerName);
await existing.remove({ force: true }); await existing.remove({ force: true });
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
} catch (_) {} } catch (_) {
// No stale container to remove
}
const container = await docker.client.createContainer(containerConfig); const container = await docker.client.createContainer(containerConfig);
await container.start(); await container.start();

View File

@@ -17,8 +17,7 @@ const platformPaths = require('../../platform-paths');
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @returns {Object} Helper functions * @returns {Object} Helper functions
*/ */
module.exports = function(ctx) { module.exports = function({ docker, caddy, credentialManager, servicesStateManager, fetchT, log, ctx }) {
const { docker, caddy, credentialManager, servicesStateManager, fetchT, log } = ctx;
async function checkPortConflicts(ports, excludeContainerName = null) { async function checkPortConflicts(ports, excludeContainerName = null) {
const conflicts = []; const conflicts = [];

View File

@@ -27,17 +27,21 @@ module.exports = function(ctx) {
log: ctx.log, log: ctx.log,
// Additional context properties needed by routes // Additional context properties needed by routes
APP_TEMPLATES: ctx.APP_TEMPLATES, APP_TEMPLATES: ctx.APP_TEMPLATES,
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
siteConfig: ctx.siteConfig, siteConfig: ctx.siteConfig,
buildDomain: ctx.buildDomain, buildDomain: ctx.buildDomain,
buildServiceUrl: ctx.buildServiceUrl, buildServiceUrl: ctx.buildServiceUrl,
addServiceToConfig: ctx.addServiceToConfig, addServiceToConfig: ctx.addServiceToConfig,
dns: ctx.dns, dns: ctx.dns,
notification: ctx.notification, notification: ctx.notification,
safeErrorMessage: ctx.safeErrorMessage safeErrorMessage: ctx.safeErrorMessage,
SERVICES_FILE: ctx.SERVICES_FILE,
ctx: ctx
}; };
// Initialize helpers with dependencies // Initialize helpers with dependencies (ctx is the Koa context)
const helpers = initHelpers(ctx); const helpers = initHelpers({ ...deps, ctx });
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties // Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
const subCtx = Object.assign({}, ctx, { helpers }); const subCtx = Object.assign({}, ctx, { helpers });

View File

@@ -1,20 +1,40 @@
const express = require('express'); const express = require('express');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const { logError } = require('../../src/utils/logging');
module.exports = function(ctx) { module.exports = function({
const { docker, caddy, servicesStateManager, asyncHandler, errorResponse, log, helpers } = ctx; docker, caddy, servicesStateManager, asyncHandler, log, helpers,
errorResponse, dns, siteConfig, buildDomain, SERVICES_FILE, safeErrorMessage
}) {
const router = express.Router(); const router = express.Router();
/**
* Apps removal routes factory // Ctx shim for backward compatibility with existing route code
* @param {Object} deps - Explicit dependencies const ctx = {
* @param {Object} deps.docker - Docker client wrapper dns,
* @param {Object} deps.caddy - Caddy client siteConfig,
* @param {Object} deps.servicesStateManager - Services state manager buildDomain,
* @param {Function} deps.asyncHandler - Async route handler wrapper SERVICES_FILE,
* @param {Object} deps.log - Logger instance safeErrorMessage
* @param {Object} deps.helpers - Apps helpers module };
* @returns {express.Router}
*/ // Remove deployed app
/**
* Apps removal routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.caddy - Caddy client
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Apps helpers module
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.dns - DNS context
* @param {Object} deps.siteConfig - Site configuration
* @param {Function} deps.buildDomain - Build domain helper
* @param {string} deps.SERVICES_FILE - Services file path
* @param {Function} deps.safeErrorMessage - Safe error message formatter
* @returns {express.Router}
*/
router.delete('/apps/:appId', asyncHandler(async (req, res) => { router.delete('/apps/:appId', asyncHandler(async (req, res) => {
const { appId } = req.params; const { appId } = req.params;
const { containerId, subdomain, ip, deleteContainer } = req.query; const { containerId, subdomain, ip, deleteContainer } = req.query;

View File

@@ -8,14 +8,23 @@ const { DOCKER } = require('../../constants');
* @param {Object} deps.caddy - Caddy client * @param {Object} deps.caddy - Caddy client
* @param {Object} deps.servicesStateManager - Services state manager * @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Apps helpers module * @param {Object} deps.helpers - Apps helpers module
* @param {Object} deps.APP_TEMPLATES - App templates registry
* @param {Object} deps.dns - DNS client
* @param {Function} deps.buildServiceUrl - Service URL builder
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function(ctx) { module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, errorResponse, log, helpers, APP_TEMPLATES, dns, buildServiceUrl }) {
const { docker, caddy, servicesStateManager, asyncHandler, log, helpers } = ctx;
const router = express.Router(); const router = express.Router();
const ctx = {
APP_TEMPLATES,
dns,
buildServiceUrl
};
/** /**
* Restore a single service from its deployment manifest. * Restore a single service from its deployment manifest.
* Pulls image, creates container, starts it, recreates Caddy config. * Pulls image, creates container, starts it, recreates Caddy config.
@@ -125,11 +134,6 @@ module.exports = function(ctx) {
// Static sites: just recreate Caddy config // Static sites: just recreate Caddy config
if (template?.isStaticSite) { if (template?.isStaticSite) {
log.info('restore', `Restoring static site Caddy config: ${service.name}`); log.info('restore', `Restoring static site Caddy config: ${service.name}`);
const caddyOptions = {
tailscaleOnly: manifest.caddy.tailscaleOnly,
allowedIPs: manifest.caddy.allowedIPs,
subpathSupport: manifest.caddy.subpathSupport,
};
// Static site Caddy config would need to be regenerated // Static site Caddy config would need to be regenerated
// For now, just confirm the service entry exists // For now, just confirm the service entry exists
return { return {
@@ -288,7 +292,7 @@ module.exports = function(ctx) {
const svc = services.find(s => s.id === service.id); const svc = services.find(s => s.id === service.id);
if (svc) { if (svc) {
svc.containerId = container.id; svc.containerId = container.id;
svc.url = ctx.buildServiceUrl(manifest.config.subdomain); svc.url = buildServiceUrl(manifest.config.subdomain);
} }
return services; return services;
}); });

View File

@@ -6,14 +6,40 @@ const { exists } = require('../../fs-helpers');
* @param {Object} deps.servicesStateManager - Services state manager * @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.helpers - Apps helpers module * @param {Object} deps.helpers - Apps helpers module
* @param {Object} deps.APP_TEMPLATES - App templates registry
* @param {Object} deps.TEMPLATE_CATEGORIES - Template categories
* @param {Object} deps.DIFFICULTY_LEVELS - Difficulty levels
* @param {Object} deps.docker - Docker client
* @param {Object} deps.caddy - Caddy client
* @param {Object} deps.dns - DNS context
* @param {Object} deps.siteConfig - Site configuration
* @param {Function} deps.buildDomain - Build domain helper
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @param {string} deps.SERVICES_FILE - Services file path
* @returns {express.Router} * @returns {express.Router}
*/ */
const { REGEX } = require('../../constants'); const { REGEX } = require('../../constants');
module.exports = function(ctx) { module.exports = function({
const { servicesStateManager, asyncHandler, helpers, docker, caddy, log, errorResponse } = ctx; servicesStateManager, asyncHandler, helpers,
APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS,
docker, caddy, dns, siteConfig, buildDomain,
errorResponse, log, SERVICES_FILE
}) {
const router = express.Router(); const router = express.Router();
// Ctx shim for backward compatibility with existing route code
const ctx = {
APP_TEMPLATES,
TEMPLATE_CATEGORIES,
DIFFICULTY_LEVELS,
dns,
siteConfig,
buildDomain,
SERVICES_FILE
};
// Get available app templates // Get available app templates
router.get('/apps/templates', asyncHandler(async (req, res) => { router.get('/apps/templates', asyncHandler(async (req, res) => {
res.json({ res.json({
@@ -64,6 +90,8 @@ module.exports = function(ctx) {
// Update subdomain for deployed app // Update subdomain for deployed app
router.post('/apps/update-subdomain', asyncHandler(async (req, res) => { router.post('/apps/update-subdomain', asyncHandler(async (req, res) => {
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body; const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
const { ValidationError } = require('../../errors');
if (!oldSubdomain || typeof oldSubdomain !== 'string') { if (!oldSubdomain || typeof oldSubdomain !== 'string') {
throw new ValidationError('oldSubdomain is required'); throw new ValidationError('oldSubdomain is required');
} }

View File

@@ -28,7 +28,7 @@ module.exports = function(ctx) {
const results = { radarr: null, sonarr: null }; const results = { radarr: null, sonarr: null };
// Step 1: Authenticate with Overseerr via Plex token // 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(); const overseerrSession = await helpers.getOverseerrSession();
if (!overseerrSession) { if (!overseerrSession) {
@@ -227,7 +227,7 @@ module.exports = function(ctx) {
} }
// Normalize URL - remove trailing slash // Normalize URL - remove trailing slash
let baseUrl = url.replace(/\/+$/, ''); const baseUrl = url.replace(/\/+$/, '');
// Build the API endpoint // Build the API endpoint
let apiEndpoint; let apiEndpoint;

View File

@@ -9,12 +9,18 @@ const { APP_PORTS } = require('../../constants');
* @param {Function} deps.errorResponse - Error response helper * @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Arr helpers module * @param {Object} deps.helpers - Arr helpers module
* @param {Object} deps.credentialManager - Credential manager
* @param {Object} deps.servicesStateManager - Services state manager
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function(ctx) { module.exports = function({ fetchT, asyncHandler, errorResponse, log: _log, helpers, credentialManager, servicesStateManager }) {
const { fetchT, asyncHandler, errorResponse, log, helpers } = ctx;
const router = express.Router(); const router = express.Router();
const ctx = {
credentialManager,
servicesStateManager
};
// Plex Libraries endpoint // Plex Libraries endpoint
router.get('/plex/libraries', asyncHandler(async (req, res) => { router.get('/plex/libraries', asyncHandler(async (req, res) => {
// Get Plex token // Get Plex token

View File

@@ -10,12 +10,18 @@ const { APP_PORTS } = require('../../constants');
* @param {Function} deps.errorResponse - Error response helper * @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Arr helpers module * @param {Object} deps.helpers - Arr helpers module
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Object} deps.notification - Notification helper
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function(ctx) { module.exports = function({ credentialManager, servicesStateManager, fetchT, asyncHandler, errorResponse: _errorResponse, log: _log, helpers, notification }) {
const { credentialManager, fetchT, asyncHandler, errorResponse, log, helpers } = ctx;
const router = express.Router(); const router = express.Router();
const ctx = {
servicesStateManager,
notification
};
// Smart Connect: Unified orchestration endpoint // Smart Connect: Unified orchestration endpoint
router.post('/arr/smart-connect', asyncHandler(async (req, res) => { router.post('/arr/smart-connect', asyncHandler(async (req, res) => {
const { services: inputServices, configurePlex, configureProwlarr, configureSeerr, saveCredentials } = req.body; const { services: inputServices, configurePlex, configureProwlarr, configureSeerr, saveCredentials } = req.body;

View File

@@ -1,8 +1,6 @@
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants'); const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
const { createCache, CACHE_CONFIGS } = require('../../cache-config'); const { createCache, CACHE_CONFIGS } = require('../../cache-config');
module.exports = function({ authManager, credentialManager, fetchT, asyncHandler, errorResponse, log }) {
// App session cache for auto-login
/** /**
* Auth session handlers routes factory * Auth session handlers routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -11,8 +9,11 @@ module.exports = function({ authManager, credentialManager, fetchT, asyncHandler
* @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.errorResponse - Error response helper * @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @returns {express.Router} * @param {Function} deps.fetchT - Timeout-wrapped fetch
* @returns {{ getAppSession: Function, appSessionCache: Object }}
*/ */
module.exports = function({ authManager: _authManager, credentialManager: _credentialManager, asyncHandler: _asyncHandler, errorResponse: _errorResponse, log, fetchT }) {
const ctx = { fetchT };
const appSessionCache = createCache(CACHE_CONFIGS.appSessions); const appSessionCache = createCache(CACHE_CONFIGS.appSessions);
async function getAppSession(serviceId, baseUrl, username, password) { async function getAppSession(serviceId, baseUrl, username, password) {

View File

@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
const path = require('path'); const path = require('path');
const { exists, isAccessible } = require('../fs-helpers'); const { exists, isAccessible } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError } = require('../errors'); const { ValidationError, ForbiddenError } = require('../errors');
/** /**
* Browse route factory * Browse route factory

View File

@@ -4,6 +4,7 @@ const fsp = require('fs').promises;
const path = require('path'); const path = require('path');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { ValidationError } = require('../errors');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
module.exports = function(ctx) { module.exports = function(ctx) {

View File

@@ -22,9 +22,9 @@ try {
// Image processing libraries not available — favicon conversion disabled // Image processing libraries not available — favicon conversion disabled
} }
module.exports = function(ctx) { module.exports = function({ servicesStateManager: _servicesStateManager, asyncHandler, log: _log, CONFIG_FILE, readConfig, saveConfig, errorResponse }) {
const { servicesStateManager, asyncHandler, log } = ctx;
const router = express.Router(); const router = express.Router();
const ctx = { CONFIG_FILE, readConfig, saveConfig, errorResponse };
// ===== ASSET UPLOAD ===== // ===== ASSET UPLOAD =====
@@ -47,7 +47,6 @@ module.exports = function(ctx) {
throw new ValidationError('Invalid image data format'); throw new ValidationError('Invalid image data format');
} }
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
const base64Data = matches[2]; const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64'); const buffer = Buffer.from(base64Data, 'base64');
@@ -109,6 +108,7 @@ module.exports = function(ctx) {
// Upload custom logo(s) and/or update position and title // Upload custom logo(s) and/or update position and title
// Supports: dataDark/dataLight (separate variants) or data (single logo for both) // Supports: dataDark/dataLight (separate variants) or data (single logo for both)
// eslint-disable-next-line complexity
router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => { router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => {
const { data, dataDark, dataLight, position, dashboardTitle } = req.body; const { data, dataDark, dataLight, position, dashboardTitle } = req.body;
@@ -231,7 +231,6 @@ module.exports = function(ctx) {
throw new ValidationError('Invalid image data format'); throw new ValidationError('Invalid image data format');
} }
const imageType = matches[1];
const base64Data = matches[2]; const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64'); const buffer = Buffer.from(base64Data, 'base64');

View File

@@ -13,7 +13,10 @@ module.exports = function(ctx) {
configStateManager: ctx.configStateManager, configStateManager: ctx.configStateManager,
servicesStateManager: ctx.servicesStateManager, servicesStateManager: ctx.servicesStateManager,
asyncHandler: ctx.asyncHandler, asyncHandler: ctx.asyncHandler,
log: ctx.log log: ctx.log,
CONFIG_FILE: ctx.CONFIG_FILE,
errorResponse: ctx.errorResponse,
loadSiteConfig: ctx.loadSiteConfig
}; };
// Additional deps for backup route // Additional deps for backup route
@@ -35,8 +38,8 @@ module.exports = function(ctx) {
saveTotpConfig: ctx.saveTotpConfig saveTotpConfig: ctx.saveTotpConfig
}; };
router.use(require('./settings')(ctx)); router.use(require('./settings')(baseDeps));
router.use(require('./assets')(ctx)); router.use(require('./assets')({ ...baseDeps, readConfig: ctx.readConfig, saveConfig: ctx.saveConfig }));
router.use(require('./backup')(backupDeps)); router.use(require('./backup')(backupDeps));
return router; return router;
}; };

View File

@@ -5,13 +5,19 @@ const { ValidationError } = require('../../errors');
/** /**
* Config settings routes factory * Config settings routes factory
* @param {Object} ctx - Application context * @param {Object} deps - Explicit dependencies
* @param {Object} deps.configStateManager - Config state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @param {string} deps.CONFIG_FILE - Config file path
* @param {Function} deps.errorResponse - Error response helper
* @param {Function} deps.loadSiteConfig - Site config reload helper
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function(ctx) { module.exports = function({ configStateManager: _configStateManager, asyncHandler, log, CONFIG_FILE, errorResponse, loadSiteConfig }) {
const { configStateManager, asyncHandler, log } = ctx;
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctx = { CONFIG_FILE, errorResponse, loadSiteConfig };
// ===== DASHCADDY CONFIG ENDPOINTS ===== // ===== DASHCADDY CONFIG ENDPOINTS =====
// Server-side config storage for setup wizard (shared across all browsers/machines) // Server-side config storage for setup wizard (shared across all browsers/machines)
@@ -60,7 +66,9 @@ module.exports = function(ctx) {
config.updatedAt = new Date().toISOString(); config.updatedAt = new Date().toISOString();
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
ctx.loadSiteConfig(); // Refresh in-memory config if (typeof ctx.loadSiteConfig === 'function') {
ctx.loadSiteConfig(); // Refresh in-memory config
}
log.info('config', 'Config saved', { path: ctx.CONFIG_FILE }); log.info('config', 'Config saved', { path: ctx.CONFIG_FILE });
res.json({ success: true, message: 'Configuration saved', config, warnings }); res.json({ success: true, message: 'Configuration saved', config, warnings });

View File

@@ -55,7 +55,9 @@ async function handleExec(ws, containerId, log) {
if (chunks.length > 0 && Buffer.concat(chunks).toString().includes('/bash')) { if (chunks.length > 0 && Buffer.concat(chunks).toString().includes('/bash')) {
shell = '/bin/bash'; shell = '/bin/bash';
} }
} catch (_) {} } catch (_) {
// Fall back to /bin/sh when bash detection fails
}
execInstance = await container.exec({ execInstance = await container.exec({
Cmd: [shell], Cmd: [shell],
@@ -96,21 +98,27 @@ async function handleExec(ws, containerId, log) {
return; return;
} }
} }
} catch (_) {} } catch (_) {
// Treat message as raw terminal input if JSON parsing fails
}
// Regular terminal input // Regular terminal input
execStream.write(data); execStream.write(data);
}); });
ws.on('close', () => { ws.on('close', () => {
if (execStream) { if (execStream) {
try { execStream.destroy(); } catch (_) {} try { execStream.destroy(); } catch (_) {
// Ignore stream teardown errors on socket close
}
} }
}); });
ws.on('error', (err) => { ws.on('error', (err) => {
log.warn('exec', 'WebSocket error', { containerId, error: err.message }); log.warn('exec', 'WebSocket error', { containerId, error: err.message });
if (execStream) { if (execStream) {
try { execStream.destroy(); } catch (_) {} try { execStream.destroy(); } catch (_) {
// Ignore stream teardown errors after websocket errors
}
} }
}); });

View File

@@ -8,6 +8,7 @@ const { paginate, parsePaginationParams } = require('../pagination');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../response-helpers');
const { ValidationError } = require('../errors');
/** /**
* Health routes factory * Health routes factory
@@ -45,7 +46,7 @@ module.exports = function({
// Try HEAD first // Try HEAD first
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), TIMEOUTS.HTTP_DEFAULT);
const response = await fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }); const response = await fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' });
clearTimeout(timeout); clearTimeout(timeout);
return { return {
@@ -54,12 +55,12 @@ module.exports = function({
url, url,
checkedAt: new Date().toISOString() checkedAt: new Date().toISOString()
}; };
} catch (_) {} } catch { /* ignore */ }
// Fallback to GET // Fallback to GET
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), TIMEOUTS.HTTP_DEFAULT);
const response = await fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' }); const response = await fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' });
clearTimeout(timeout); clearTimeout(timeout);
return { return {

View File

@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
const path = require('path'); const path = require('path');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError } = require('../errors'); const { NotFoundError, ValidationError, ForbiddenError } = require('../errors');
/** /**
* Logs route factory * Logs route factory

View File

@@ -7,9 +7,10 @@ const { success } = require('../response-helpers');
* @param {Object} deps.resourceMonitor - Resource monitoring manager * @param {Object} deps.resourceMonitor - Resource monitoring manager
* @param {Object} deps.docker - Docker client wrapper * @param {Object} deps.docker - Docker client wrapper
* @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ resourceMonitor, docker, asyncHandler }) { module.exports = function({ resourceMonitor, docker, asyncHandler, log }) {
const router = express.Router(); const router = express.Router();
// ===== RESOURCE MONITORING ENDPOINTS ===== // ===== RESOURCE MONITORING ENDPOINTS =====
@@ -119,7 +120,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler }) {
}); });
} catch (e) { } catch (e) {
// Skip containers we can't get stats for // Skip containers we can't get stats for
console.log(`Could not get stats for ${containerInfo.Names[0]}:`, e.message); log.warn('monitoring', `Could not get stats for ${containerInfo.Names[0]}`, { error: e.message });
} }
} }

View File

@@ -14,9 +14,9 @@ const { DOCKER } = require('../../constants');
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function(ctx) { module.exports = function({ docker, credentialManager: _credentialManager, servicesStateManager: _servicesStateManager, asyncHandler, errorResponse: _errorResponse, log, addServiceToConfig, notification, APP_TEMPLATES, siteConfig, caddy, buildDomain }) {
const { docker, credentialManager, servicesStateManager, asyncHandler, errorResponse, log } = ctx;
const router = express.Router(); const router = express.Router();
const ctx = { addServiceToConfig, notification, APP_TEMPLATES, siteConfig, caddy, buildDomain };
/** /**
* Deploy a recipe — creates multiple containers as a coordinated stack * Deploy a recipe — creates multiple containers as a coordinated stack
@@ -24,6 +24,7 @@ module.exports = function(ctx) {
* POST /api/recipes/deploy * POST /api/recipes/deploy
* Body: { recipeId, config: { selectedComponents, sharedConfig, componentOverrides } } * Body: { recipeId, config: { selectedComponents, sharedConfig, componentOverrides } }
*/ */
// eslint-disable-next-line complexity
router.post('/deploy', asyncHandler(async (req, res) => { router.post('/deploy', asyncHandler(async (req, res) => {
const { recipeId, config } = req.body; const { recipeId, config } = req.body;
const { RECIPE_TEMPLATES } = require('../../recipe-templates'); const { RECIPE_TEMPLATES } = require('../../recipe-templates');
@@ -44,7 +45,6 @@ module.exports = function(ctx) {
// Generate shared passwords for the recipe (consistent across components) // Generate shared passwords for the recipe (consistent across components)
const generatedPasswords = {}; const generatedPasswords = {};
const passwordKey = `recipe-${recipeId}-${Date.now()}`;
generatedPasswords.default = crypto.randomBytes(24).toString('base64url'); generatedPasswords.default = crypto.randomBytes(24).toString('base64url');
// Create Docker network if defined // Create Docker network if defined
@@ -185,6 +185,7 @@ module.exports = function(ctx) {
/** /**
* Deploy a single component of a recipe * Deploy a single component of a recipe
*/ */
// eslint-disable-next-line complexity
async function deployComponent(component, recipe, config, passwords, networkName) { async function deployComponent(component, recipe, config, passwords, networkName) {
const sharedConfig = config.sharedConfig || {}; const sharedConfig = config.sharedConfig || {};
const overrides = config.componentOverrides?.[component.id] || {}; const overrides = config.componentOverrides?.[component.id] || {};
@@ -364,7 +365,7 @@ module.exports = function(ctx) {
/** /**
* Run auto-connect steps after recipe deployment * Run auto-connect steps after recipe deployment
*/ */
async function runAutoConnect(recipe, deployedComponents, config) { async function runAutoConnect(recipe, _deployedComponents, _config) {
if (!recipe.autoConnect?.steps) return; if (!recipe.autoConnect?.steps) return;
// Wait for services to be fully ready // Wait for services to be fully ready

View File

@@ -17,7 +17,13 @@ module.exports = function(ctx) {
servicesStateManager: ctx.servicesStateManager, servicesStateManager: ctx.servicesStateManager,
asyncHandler: ctx.asyncHandler, asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse, errorResponse: ctx.errorResponse,
log: ctx.log log: ctx.log,
notification: ctx.notification,
buildDomain: ctx.buildDomain,
caddy: ctx.caddy,
addServiceToConfig: ctx.addServiceToConfig,
APP_TEMPLATES: ctx.APP_TEMPLATES,
siteConfig: ctx.siteConfig
}; };
// All recipe routes require premium license // All recipe routes require premium license

View File

@@ -2,9 +2,12 @@ const express = require('express');
const { DOCKER } = require('../../constants'); const { DOCKER } = require('../../constants');
const { NotFoundError } = require('../../errors'); const { NotFoundError } = require('../../errors');
module.exports = function(ctx) { module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) {
const { servicesStateManager, asyncHandler, log } = ctx;
const router = express.Router(); const router = express.Router();
// Ctx shim for backward compatibility
const ctx = { docker, notification, buildDomain, caddy };
/** /**
* Recipes management routes factory * Recipes management routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
@@ -303,7 +306,7 @@ module.exports = function(ctx) {
*/ */
async function removeCaddyBlock(subdomain) { async function removeCaddyBlock(subdomain) {
const domain = ctx.buildDomain(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 // Find and remove the block for this domain
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

View File

@@ -8,9 +8,9 @@ const { APP, REGEX, TIMEOUTS } = require('../constants');
const { validateServiceConfig, isValidPort } = require('../input-validator'); const { validateServiceConfig, isValidPort } = require('../input-validator');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../response-helpers');
const { ConflictError, ValidationError, NotFoundError } = require('../errors');
/** /**
* Services route factory * Services route factory
@@ -113,7 +113,7 @@ module.exports = function({
const headers = {}; const headers = {};
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), TIMEOUTS.HTTP_DEFAULT);
const response = await fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers }); const response = await fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers });
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) return null; if (!response.ok) return null;
@@ -470,7 +470,7 @@ module.exports = function({
const oldDomain = buildDomain(oldSubdomain); const oldDomain = buildDomain(oldSubdomain);
const newDomain = buildDomain(newSubdomain); const newDomain = buildDomain(newSubdomain);
let content = await caddy.read(); const content = await caddy.read();
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp( const siteBlockRegex = new RegExp(

View File

@@ -164,7 +164,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i; const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port'); if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port');
let content = await caddy.read(); const content = await caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g'); const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g');
if (siteBlockRegex.test(content)) { if (siteBlockRegex.test(content)) {
@@ -203,7 +203,6 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
const domain = buildDomain(subdomain); const domain = buildDomain(subdomain);
let dnsWarning = null; let dnsWarning = null;
try {
if (createDns) { if (createDns) {
try { try {
await dns.createRecord(subdomain, siteConfig.dnsServerIp); await dns.createRecord(subdomain, siteConfig.dnsServerIp);
@@ -267,9 +266,6 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
}; };
if (dnsWarning) response.warning = dnsWarning; if (dnsWarning) response.warning = dnsWarning;
res.json(response); res.json(response);
} catch (error) {
throw error;
}
}, 'site-external')); }, 'site-external'));
return router; return router;

View File

@@ -152,7 +152,7 @@ module.exports = function({
throw new ValidationError('subdomain is required'); throw new ValidationError('subdomain is required');
} }
let content = await caddy.read(); const content = await caddy.read();
const domain = buildDomain(subdomain); const domain = buildDomain(subdomain);
const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's'); const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's');

View File

@@ -8,9 +8,10 @@ const { ValidationError, NotFoundError } = require('../errors');
* Themes routes factory * Themes routes factory
* @param {Object} deps - Explicit dependencies * @param {Object} deps - Explicit dependencies
* @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ asyncHandler }) { module.exports = function({ asyncHandler, log }) {
const router = express.Router(); const router = express.Router();
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes'); const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes');
@@ -29,7 +30,7 @@ module.exports = function({ asyncHandler }) {
themes[slug] = data; themes[slug] = data;
} }
} catch (e) { } catch (e) {
console.error('[Themes] Failed to read themes:', e.message); log.error('themes', 'Failed to read themes', { error: e.message });
} }
return themes; return themes;
} }

View File

@@ -14,8 +14,8 @@ const fs = require('fs');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const os = require('os');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const zlib = require('zlib');
const platformPaths = require('./platform-paths'); const platformPaths = require('./platform-paths');
const isWindows = platformPaths.isWindows; const isWindows = platformPaths.isWindows;
@@ -31,6 +31,10 @@ const DEFAULTS = {
MAX_BACKUPS: 3, MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000, HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000, DOWNLOAD_TIMEOUT: 120000,
CHANNEL: 'stable',
INSTANCE_ID_FILE: platformPaths.isWindows
? path.join(platformPaths.caddyBase, 'instance-id')
: '/etc/dashcaddy/instance-id',
}; };
class SelfUpdater extends EventEmitter { class SelfUpdater extends EventEmitter {
@@ -48,12 +52,15 @@ class SelfUpdater extends EventEmitter {
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR, apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR, frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10), maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
instanceIdFile: options.instanceIdFile || process.env.DASHCADDY_INSTANCE_ID_FILE || DEFAULTS.INSTANCE_ID_FILE,
}; };
this.status = 'idle'; // idle | checking | downloading | applying | waiting this.status = 'idle'; // idle | checking | downloading | applying | waiting
this.checkTimer = null; this.checkTimer = null;
this.lastCheckTime = null; this.lastCheckTime = null;
this.lastCheckResult = null; this.lastCheckResult = null;
this.instanceId = this._loadOrCreateInstanceId();
// Ensure directories exist // Ensure directories exist
this._ensureDirs(); this._ensureDirs();
@@ -80,7 +87,7 @@ class SelfUpdater extends EventEmitter {
} }
} }
// ── Version Info ── // ── Version / Identity Info ──
getLocalVersion() { getLocalVersion() {
try { try {
@@ -88,13 +95,25 @@ class SelfUpdater extends EventEmitter {
let commit = null; let commit = null;
try { try {
commit = fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim(); commit = fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim();
} catch (_) {} } catch { /* ignore */ }
return { version: pkg.version, commit }; return { version: pkg.version, commit };
} catch (e) { } catch (e) {
return { version: '0.0.0', commit: null }; return { version: '0.0.0', commit: null };
} }
} }
getInstanceInfo() {
return {
instanceId: this.instanceId,
channel: this.config.channel,
hostname: os.hostname(),
platform: process.platform,
arch: process.arch,
isWindows,
version: this.getLocalVersion(),
};
}
getStatus() { getStatus() {
return this.status; return this.status;
} }
@@ -122,10 +141,18 @@ class SelfUpdater extends EventEmitter {
} }
const local = this.getLocalVersion(); const local = this.getLocalVersion();
const available = this._isNewer(local, remote); const policy = this._evaluateReleasePolicy(local, remote);
const available = policy.eligible && policy.newer;
this.lastCheckTime = Date.now(); this.lastCheckTime = Date.now();
this.lastCheckResult = { available, local, remote, sourceUrl }; this.lastCheckResult = {
available,
local,
remote,
sourceUrl,
policy,
instance: this.getInstanceInfo(),
};
this.status = 'idle'; this.status = 'idle';
if (available) { if (available) {
@@ -149,12 +176,17 @@ class SelfUpdater extends EventEmitter {
} }
const local = this.getLocalVersion(); const local = this.getLocalVersion();
const policy = this._evaluateReleasePolicy(local, remoteInfo);
if (!policy.eligible) {
throw new Error(`Release not eligible for this instance: ${policy.reason}`);
}
const stagingDir = path.join(this.config.updatesDir, 'staging'); const stagingDir = path.join(this.config.updatesDir, 'staging');
try { try {
// 1. Download (try primary, fallback to mirror) // 1. Download (try primary, fallback to mirror)
this.status = 'downloading'; this.status = 'downloading';
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version }); this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy });
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball); const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`; const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
@@ -164,7 +196,7 @@ class SelfUpdater extends EventEmitter {
} catch (dlErr) { } catch (dlErr) {
console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror'); console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror');
// Ensure file is fully cleaned up before mirror attempt // Ensure file is fully cleaned up before mirror attempt
try { fs.unlinkSync(tarballPath); } catch (_) {} try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
await this._downloadFile(mirrorUrl, tarballPath); await this._downloadFile(mirrorUrl, tarballPath);
} }
@@ -207,6 +239,8 @@ class SelfUpdater extends EventEmitter {
stagingDir: hostApiSrc, stagingDir: hostApiSrc,
apiSourceDir: this.config.apiSourceDir, apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
}; };
await fsp.writeFile( await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'), path.join(this.config.updatesDir, 'trigger.json'),
@@ -222,6 +256,8 @@ class SelfUpdater extends EventEmitter {
status: 'pending', status: 'pending',
frontendUpdated: !!frontendSrc, frontendUpdated: !!frontendSrc,
apiUpdated: true, apiUpdated: true,
channel: this.config.channel,
instanceId: this.instanceId,
}); });
} else if (isWindows) { } else if (isWindows) {
// Windows: frontend updated, API needs manual restart // Windows: frontend updated, API needs manual restart
@@ -233,6 +269,8 @@ class SelfUpdater extends EventEmitter {
frontendUpdated: !!frontendSrc, frontendUpdated: !!frontendSrc,
apiUpdated: false, apiUpdated: false,
note: 'API update requires manual container restart on Windows', note: 'API update requires manual container restart on Windows',
channel: this.config.channel,
instanceId: this.instanceId,
}); });
this.status = 'idle'; this.status = 'idle';
} }
@@ -246,6 +284,7 @@ class SelfUpdater extends EventEmitter {
toVersion: remoteInfo.version, toVersion: remoteInfo.version,
frontendUpdated: !!frontendSrc, frontendUpdated: !!frontendSrc,
apiUpdated: !isWindows && !!apiSrc, apiUpdated: !isWindows && !!apiSrc,
policy,
}; };
} catch (e) { } catch (e) {
this.status = 'idle'; this.status = 'idle';
@@ -255,6 +294,8 @@ class SelfUpdater extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: 'failed', status: 'failed',
error: e.message, error: e.message,
channel: this.config.channel,
instanceId: this.instanceId,
}); });
throw e; throw e;
} }
@@ -308,6 +349,8 @@ class SelfUpdater extends EventEmitter {
stagingDir: hostBackupDir, stagingDir: hostBackupDir,
apiSourceDir: this.config.apiSourceDir, apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
}; };
this.status = 'waiting'; this.status = 'waiting';
@@ -322,6 +365,8 @@ class SelfUpdater extends EventEmitter {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: 'pending', status: 'pending',
rollback: true, rollback: true,
channel: this.config.channel,
instanceId: this.instanceId,
}); });
} }
@@ -362,20 +407,143 @@ class SelfUpdater extends EventEmitter {
} }
} }
_evaluateReleasePolicy(local, remote) {
const releaseChannel = remote?.channel || remote?.releaseChannel || 'stable';
const allowedChannels = Array.isArray(remote?.channels)
? remote.channels
: Array.isArray(remote?.eligibleChannels)
? remote.eligibleChannels
: [releaseChannel];
if (!allowedChannels.includes(this.config.channel)) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `channel mismatch (${this.config.channel} not in ${allowedChannels.join(', ')})`,
releaseChannel,
allowedChannels,
};
}
if (remote?.revoked === true) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: 'release revoked',
releaseChannel,
allowedChannels,
};
}
const minUpdaterVersion = remote?.minUpdaterVersion;
if (minUpdaterVersion && this._compareVersions(local.version, minUpdaterVersion) < 0) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `requires updater >= ${minUpdaterVersion}`,
releaseChannel,
allowedChannels,
};
}
const rollout = this._normalizeRollout(remote?.rollout);
if (rollout < 100) {
const bucket = this._getRolloutBucket(this.instanceId);
if (bucket >= rollout) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `outside rollout (${bucket} >= ${rollout})`,
releaseChannel,
allowedChannels,
rollout,
rolloutBucket: bucket,
};
}
}
const targets = remote?.targets;
if (targets && typeof targets === 'object') {
const platformKey = `${process.platform}-${process.arch}`;
const matchedTarget = targets[platformKey] || targets[process.platform] || targets.default;
if (!matchedTarget) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `no target for ${platformKey}`,
releaseChannel,
allowedChannels,
rollout,
};
}
}
return {
eligible: true,
newer: this._isNewer(local, remote),
reason: 'eligible',
releaseChannel,
allowedChannels,
rollout,
rolloutBucket: this._getRolloutBucket(this.instanceId),
};
}
_isNewer(local, remote) { _isNewer(local, remote) {
if (!remote || !remote.version) return false; if (!remote || !remote.version) return false;
// Compare semver: split into [major, minor, patch] const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
const lv = (local.version || '0.0.0').split('.').map(Number); if (versionCompare < 0) return true;
const rv = remote.version.split('.').map(Number); if (versionCompare > 0) return false;
for (let i = 0; i < 3; i++) {
if ((rv[i] || 0) > (lv[i] || 0)) return true;
if ((rv[i] || 0) < (lv[i] || 0)) return false;
}
// Same version — check commit hash // Same version — check commit hash
if (remote.commit && local.commit && remote.commit !== local.commit) return true; if (remote.commit && local.commit && remote.commit !== local.commit) return true;
return false; return false;
} }
_compareVersions(a, b) {
const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const len = Math.max(av.length, bv.length, 3);
for (let i = 0; i < len; i++) {
const left = av[i] || 0;
const right = bv[i] || 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
_normalizeRollout(value) {
if (value == null) return 100;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 100;
return Math.max(0, Math.min(100, Math.floor(parsed)));
}
_getRolloutBucket(instanceId) {
const digest = crypto.createHash('sha256').update(String(instanceId || 'unknown')).digest();
return digest[0] % 100;
}
_loadOrCreateInstanceId() {
try {
if (fs.existsSync(this.config.instanceIdFile)) {
const existing = fs.readFileSync(this.config.instanceIdFile, 'utf8').trim();
if (existing) return existing;
}
} catch (_) {
// Fall through and regenerate
}
const instanceId = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true });
fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8');
} catch (error) {
console.warn('[SelfUpdater] Failed to persist instance ID:', error.message);
}
return instanceId;
}
_addToHistory(entry) { _addToHistory(entry) {
const history = this.getUpdateHistory(); const history = this.getUpdateHistory();
history.unshift(entry); history.unshift(entry);
@@ -473,7 +641,7 @@ class SelfUpdater extends EventEmitter {
const sub = path.join(baseDir, entry, name); const sub = path.join(baseDir, entry, name);
if (fs.existsSync(sub)) return sub; if (fs.existsSync(sub)) return sub;
} }
} catch (_) {} } catch { /* ignore */ }
return null; return null;
} }
@@ -512,7 +680,7 @@ class SelfUpdater extends EventEmitter {
async _cleanDir(dir) { async _cleanDir(dir) {
try { try {
await fsp.rm(dir, { recursive: true, force: true }); await fsp.rm(dir, { recursive: true, force: true });
} catch (_) {} } catch { /* ignore */ }
await fsp.mkdir(dir, { recursive: true }); await fsp.mkdir(dir, { recursive: true });
} }
} }
@@ -527,6 +695,9 @@ const selfUpdater = new SelfUpdater({
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR, hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR, apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
frontendDir: process.env.DASHCADDY_FRONTEND_DIR, frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
channel: process.env.DASHCADDY_UPDATE_CHANNEL,
instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE,
}); });
module.exports = selfUpdater; module.exports = selfUpdater;
module.exports.SelfUpdater = SelfUpdater;

View File

@@ -67,8 +67,8 @@ process.on('uncaughtException', (error) => {
// Optional modules // Optional modules
let dockerMaintenance, logDigest; let dockerMaintenance, logDigest;
try { dockerMaintenance = require('./docker-maintenance'); } catch (_) {} try { dockerMaintenance = require('./docker-maintenance'); } catch { /* optional */ }
try { logDigest = require('./log-digest'); } catch (_) {} try { logDigest = require('./log-digest'); } catch { /* optional */ }
log.info('server', 'Starting feature modules'); log.info('server', 'Starting feature modules');
@@ -188,7 +188,7 @@ process.on('uncaughtException', (error) => {
}); });
// Graceful shutdown // Graceful shutdown
function shutdown(signal) { const shutdown = (signal) => {
log.info('shutdown', `${signal} received, draining connections...`); log.info('shutdown', `${signal} received, draining connections...`);
const resourceMonitor = require('./resource-monitor'); const resourceMonitor = require('./resource-monitor');
@@ -206,12 +206,12 @@ process.on('uncaughtException', (error) => {
try { try {
const dockerMaintenance = require('./docker-maintenance'); const dockerMaintenance = require('./docker-maintenance');
dockerMaintenance.stop(); dockerMaintenance.stop();
} catch (_) {} } catch { /* optional */ }
try { try {
const logDigest = require('./log-digest'); const logDigest = require('./log-digest');
logDigest.stop(); logDigest.stop();
} catch (_) {} } catch { /* optional */ }
server.close(() => { server.close(() => {
log.info('shutdown', 'HTTP server closed'); log.info('shutdown', 'HTTP server closed');
@@ -220,7 +220,7 @@ process.on('uncaughtException', (error) => {
// Force exit after 5s if connections don't drain // Force exit after 5s if connections don't drain
setTimeout(() => process.exit(0), 5000).unref(); setTimeout(() => process.exit(0), 5000).unref();
} };
process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGINT', () => shutdown('SIGINT'));

View File

@@ -36,8 +36,8 @@ const { validateURL } = require('../input-validator');
// Optional modules // Optional modules
let dockerMaintenance, logDigest; let dockerMaintenance, logDigest;
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) {} try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
try { logDigest = require('../log-digest'); } catch (_) {} try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
// Templates // Templates
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates'); const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
@@ -104,8 +104,8 @@ async function createApp() {
log.warn('server', 'CA cert not found — HTTPS calls may fail', { path: CA_CERT_PATH }); log.warn('server', 'CA cert not found — HTTPS calls may fail', { path: CA_CERT_PATH });
} }
// TOTP configuration (defaults, overridden by loadTotpConfig below) // TOTP configuration
let totpConfig = { const totpConfig = {
enabled: false, enabled: false,
sessionDuration: 'never', sessionDuration: 'never',
isSetUp: false isSetUp: false
@@ -124,7 +124,7 @@ async function createApp() {
} }
// Tailscale configuration // Tailscale configuration
let tailscaleConfig = { const tailscaleConfig = {
enabled: false, enabled: false,
requireAuth: false, requireAuth: false,
allowedTailnet: null, allowedTailnet: null,
@@ -137,7 +137,7 @@ async function createApp() {
// Helper functions needed by middleware // Helper functions needed by middleware
function isValidContainerId(id) { function isValidContainerId(id) {
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,127}$/; const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
return typeof id === 'string' && CONTAINER_ID_RE.test(id); return typeof id === 'string' && CONTAINER_ID_RE.test(id);
} }
@@ -359,7 +359,8 @@ async function createApp() {
apiRouter.use(monitoringRoutes({ apiRouter.use(monitoringRoutes({
resourceMonitor: ctx.resourceMonitor, resourceMonitor: ctx.resourceMonitor,
docker: ctx.docker, docker: ctx.docker,
asyncHandler: ctx.asyncHandler asyncHandler: ctx.asyncHandler,
log: ctx.log
})); }));
apiRouter.use(updatesRoutes({ apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager, updateManager: ctx.updateManager,
@@ -420,7 +421,7 @@ async function createApp() {
asyncHandler: ctx.asyncHandler asyncHandler: ctx.asyncHandler
})); }));
apiRouter.use('/recipes', recipesRoutes(ctx)); apiRouter.use('/recipes', recipesRoutes(ctx));
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler })); apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler, log: ctx.log }));
apiRouter.use('/docker', dockerResourcesRoutes({ apiRouter.use('/docker', dockerResourcesRoutes({
docker: ctx.docker, docker: ctx.docker,
asyncHandler: ctx.asyncHandler asyncHandler: ctx.asyncHandler

View File

@@ -6,7 +6,7 @@ const fs = require('fs');
const { validateConfig } = require('../../config-schema'); const { validateConfig } = require('../../config-schema');
const { CADDY } = require('../../constants'); const { CADDY } = require('../../constants');
let siteConfig = { const siteConfig = {
tld: '.home', tld: '.home',
caName: '', caName: '',
dnsServerIp: '', dnsServerIp: '',

View File

@@ -226,7 +226,7 @@ async function requireDnsToken(providedToken, siteConfig, credentialManager, fet
/** /**
* Create DNS record * Create DNS record
*/ */
async function createDnsRecord(subdomain, ip, siteConfig, buildDomain, fetchT, httpsAgent, log) { async function createDnsRecord(subdomain, ip, siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log) {
const tokenResult = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log); const tokenResult = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
if (!tokenResult.success) { if (!tokenResult.success) {
throw new Error(`DNS token not available: ${tokenResult.error}`); throw new Error(`DNS token not available: ${tokenResult.error}`);
@@ -285,7 +285,7 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht
const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log); const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log);
const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role); const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role);
const refresh = (username, password, server) => refreshDnsToken(username, password, server, fetchT, log); const refresh = (username, password, server) => refreshDnsToken(username, password, server, fetchT, log);
const create = (subdomain, ip) => createDnsRecord(subdomain, ip, siteConfig, buildDomain, fetchT, httpsAgent, log); const create = (subdomain, ip) => createDnsRecord(subdomain, ip, siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log);
const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent); const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent);
return { return {

View File

@@ -167,11 +167,103 @@ button:focus-visible {
right: 0; right: 0;
top: 0; top: 0;
padding-top: 10px; padding-top: 10px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.reload-caddy-main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 18px; gap: 18px;
} }
.dashboard-version {
font-size: 0.78rem;
color: var(--muted);
line-height: 1;
padding: 0 2px;
user-select: text;
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease, opacity 0.15s ease;
}
.dashboard-version:hover {
color: var(--fg);
opacity: 0.9;
}
.version-info-modal-content {
min-width: 420px;
max-width: 620px;
}
.version-info-subtitle {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 16px;
}
.version-info-status {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 14px;
}
.version-info-grid {
display: grid;
gap: 10px;
margin-bottom: 18px;
}
.version-info-row {
display: flex;
justify-content: space-between;
gap: 18px;
padding: 10px 12px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border);
}
.version-info-label {
font-weight: 600;
color: var(--fg);
}
.version-info-value {
color: var(--muted);
text-align: right;
word-break: break-word;
}
.version-info-history h4 {
margin: 0 0 10px;
}
.version-history-entry {
padding: 10px 12px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border);
margin-bottom: 8px;
}
.version-history-status {
color: var(--muted);
font-size: 0.82rem;
margin-left: 6px;
}
.version-history-meta {
font-size: 0.78rem;
color: var(--muted);
margin-top: 4px;
}
.license-status-topbar { .license-status-topbar {
display: flex; display: flex;
align-items: center; align-items: center;

File diff suppressed because one or more lines are too long

View File

@@ -83,20 +83,36 @@
<!-- License status + Reload Caddy Button - top right corner --> <!-- License status + Reload Caddy Button - top right corner -->
<div class="reload-caddy-container"> <div class="reload-caddy-container">
<div class="theme-toggle-group"> <div class="reload-caddy-main">
<button id="theme" class="theme-toggle-btn" aria-label="Cycle theme" title="Click to cycle themes"> <div class="theme-toggle-group">
<span id="theme-icon">🎨</span> <span id="theme-label">Dark</span> <button id="theme" class="theme-toggle-btn" aria-label="Cycle theme" title="Click to cycle themes">
<span id="theme-icon">🎨</span> <span id="theme-label">Dark</span>
</button>
<button id="theme-customize-btn" class="theme-customize-link" title="Customize theme colors">Customize Theme</button>
</div>
<div id="license-status-topbar" class="license-status-topbar free" title="Click to manage license">
<span id="license-topbar-icon">&#9734;</span>
<span id="license-topbar-text">FREE TIER</span>
<span id="license-topbar-time"></span>
</div>
<button id="reload-caddy-top" aria-label="Reload Caddy configuration" style="padding: 10px 20px; font-size: 0.95rem; font-weight: 600; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; border-radius: 6px; color: white; cursor: pointer; box-shadow: 0 2px 6px rgba(52, 152, 219, 0.3); transition: all 0.2s ease;">
🔄 Reload Caddy
</button> </button>
<button id="theme-customize-btn" class="theme-customize-link" title="Customize theme colors">Customize Theme</button>
</div> </div>
<div id="license-status-topbar" class="license-status-topbar free" title="Click to manage license"> <button id="dashboard-version" class="dashboard-version" type="button" title="View DashCaddy verification info" aria-label="View DashCaddy verification info">Version —</button>
<span id="license-topbar-icon">&#9734;</span> </div>
<span id="license-topbar-text">FREE TIER</span> </div>
<span id="license-topbar-time"></span>
<div id="version-info-modal" class="weather-modal">
<div class="weather-modal-content version-info-modal-content">
<h3>DashCaddy Verification Info</h3>
<p class="version-info-subtitle">Current version details and updater verification status.</p>
<div id="version-info-status" class="version-info-status">Loading…</div>
<div id="version-info-grid" class="version-info-grid" style="display:none;"></div>
<div id="version-info-history" class="version-info-history" style="display:none;"></div>
<div class="weather-modal-buttons" style="margin-top: 16px;">
<button id="version-info-close">Close</button>
</div> </div>
<button id="reload-caddy-top" aria-label="Reload Caddy configuration" style="padding: 10px 20px; font-size: 0.95rem; font-weight: 600; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; border-radius: 6px; color: white; cursor: pointer; box-shadow: 0 2px 6px rgba(52, 152, 219, 0.3); transition: all 0.2s ease;">
🔄 Reload Caddy
</button>
</div> </div>
</div> </div>
@@ -538,6 +554,117 @@
} }
var yr = document.getElementById('footer-year'); var yr = document.getElementById('footer-year');
if (yr) yr.textContent = new Date().getFullYear(); if (yr) yr.textContent = new Date().getFullYear();
var versionEl = document.getElementById('dashboard-version');
var versionInfoModal = document.getElementById('version-info-modal');
var versionInfoStatus = document.getElementById('version-info-status');
var versionInfoGrid = document.getElementById('version-info-grid');
var versionInfoHistory = document.getElementById('version-info-history');
var versionInfoClose = document.getElementById('version-info-close');
function formatValue(value) {
if (value == null || value === '') return '—';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function renderInfoRow(label, value) {
return '<div class="version-info-row"><span class="version-info-label">' + label + '</span><span class="version-info-value">' + formatValue(value) + '</span></div>';
}
function renderHistory(history) {
if (!Array.isArray(history) || !history.length) {
versionInfoHistory.style.display = 'none';
versionInfoHistory.innerHTML = '';
return;
}
versionInfoHistory.style.display = '';
versionInfoHistory.innerHTML = '<h4>Recent Update History</h4>' + history.slice(0, 5).map(function(entry) {
return '<div class="version-history-entry">'
+ '<div><strong>' + formatValue(entry.version) + '</strong> <span class="version-history-status">' + formatValue(entry.status) + '</span></div>'
+ '<div class="version-history-meta">From ' + formatValue(entry.fromVersion) + ' · ' + formatValue(entry.timestamp) + '</div>'
+ '</div>';
}).join('');
}
function openVersionInfo() {
if (!versionInfoModal) return;
versionInfoModal.classList.add('show');
versionInfoStatus.textContent = 'Loading…';
versionInfoGrid.style.display = 'none';
versionInfoHistory.style.display = 'none';
versionInfoGrid.innerHTML = '';
versionInfoHistory.innerHTML = '';
Promise.all([
fetch('/api/v1/system/version', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Version check failed');
return response.json();
}),
fetch('/api/v1/system/update-status', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Update status failed');
return response.json();
}),
fetch('/api/v1/system/update-history', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Update history failed');
return response.json();
})
]).then(function(results) {
var versionData = results[0] || {};
var statusData = results[1] || {};
var historyData = results[2] || {};
var lastResult = statusData.lastResult || {};
var lastPolicy = lastResult.policy || {};
versionInfoStatus.textContent = 'Verification info loaded.';
versionInfoGrid.style.display = 'grid';
versionInfoGrid.innerHTML = [
renderInfoRow('Version', versionData.version),
renderInfoRow('Commit', versionData.commit),
renderInfoRow('Updater Status', statusData.status),
renderInfoRow('Last Check', statusData.lastCheck ? new Date(statusData.lastCheck).toLocaleString() : 'Never'),
renderInfoRow('Update Available', lastResult.available),
renderInfoRow('Eligible', lastPolicy.eligible),
renderInfoRow('Policy Reason', lastPolicy.reason),
renderInfoRow('Channel', lastPolicy.releaseChannel || (lastResult.instance && lastResult.instance.channel)),
renderInfoRow('Instance ID', lastResult.instance && lastResult.instance.instanceId)
].join('');
renderHistory(historyData.history);
}).catch(function(error) {
versionInfoStatus.textContent = 'Could not load verification info: ' + error.message;
});
}
if (versionEl) {
fetch('/api/v1/system/version', { cache: 'no-store' })
.then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
if (data && data.success && data.version) {
versionEl.textContent = 'Version ' + data.version;
}
})
.catch(function() {
versionEl.textContent = 'Version unavailable';
});
versionEl.addEventListener('click', openVersionInfo);
}
if (versionInfoClose && versionInfoModal) {
versionInfoClose.addEventListener('click', function() {
versionInfoModal.classList.remove('show');
});
versionInfoModal.addEventListener('click', function(event) {
if (event.target === versionInfoModal) {
versionInfoModal.classList.remove('show');
}
});
}
})(); })();
</script> </script>

View File

@@ -378,6 +378,7 @@
} else { } else {
dcUpdateBadge.style.display = 'none'; dcUpdateBadge.style.display = 'none';
dcUpdateDetails.style.display = 'none'; dcUpdateDetails.style.display = 'none';
await dcLoadVersion();
if (!silent) dcShowStatus('You are running the latest version.', 'success'); if (!silent) dcShowStatus('You are running the latest version.', 'success');
} }