diff --git a/containers/libreportal/backend/package-lock.json b/containers/libreportal/backend/package-lock.json index 73125ca..a120657 100755 --- a/containers/libreportal/backend/package-lock.json +++ b/containers/libreportal/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "bcryptjs": "^2.4.3", + "compression": "^1.8.1", "cookie-parser": "^1.4.6", "express": "^4.17.1", "jsonwebtoken": "^9.0.2", @@ -130,6 +131,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -647,6 +684,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/containers/libreportal/backend/package.json b/containers/libreportal/backend/package.json index 81d76e2..c6f015e 100755 --- a/containers/libreportal/backend/package.json +++ b/containers/libreportal/backend/package.json @@ -4,6 +4,7 @@ "main": "server.js", "dependencies": { "bcryptjs": "^2.4.3", + "compression": "^1.8.1", "cookie-parser": "^1.4.6", "express": "^4.17.1", "jsonwebtoken": "^9.0.2", diff --git a/containers/libreportal/backend/utils/middleware.js b/containers/libreportal/backend/utils/middleware.js index 6dd511a..350891e 100755 --- a/containers/libreportal/backend/utils/middleware.js +++ b/containers/libreportal/backend/utils/middleware.js @@ -4,6 +4,15 @@ const cookieParser = require('cookie-parser'); const config = require('./config.js'); const { verifyToken } = require('./auth.js'); +// compression is a new dependency (added to package.json). The Docker image +// bakes node_modules at build time and routes/utils/server.js are bind-mounted +// in compose.yml — but node_modules is NOT bind-mounted, so a "quick" deploy +// (cp + restart) hits the old image without compression installed. We require +// it defensively: present after the next image rebuild → ~70 % wire-size +// reduction; absent → degrade silently to the previous uncompressed behaviour. +let compression = null; +try { compression = require('compression'); } catch (_) {} + function requireAuth(req, res, next) { const token = req.cookies?.libreportal_token; if (!token) return res.status(401).json({ error: 'Unauthorized' }); @@ -20,7 +29,32 @@ function noStore(req, res, next) { next(); } +// Static-asset options: +// - 1h maxAge + ETag on JS/CSS/icons so repeated nav skips a network +// round-trip per file. ~25 script tags × ~5ms RTT each adds up otherwise. +// - HTML files get Cache-Control: no-cache (still uses ETag, so revalidation +// is cheap, but new deploys land immediately without waiting for cache +// expiry — the SPA shell is the file most likely to change between deploys). +// - dotfiles='ignore' so .auth.json is never served. +const staticOptions = { + maxAge: '1h', + etag: true, + dotfiles: 'ignore', + setHeaders: (res, filePath) => { + if (filePath.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache'); + } + } +}; + function setup(app) { + // Gzip-compress responses. JS/CSS/HTML/JSON typically shrink ~70 %, so the + // 1.7 MB of static assets the SPA loads on a cold cache drop to ~500 KB on + // the wire. compression defaults skip already-compressed types (images, + // gzipped tarballs) and small responses (<1 KB). Defensive — no-op if the + // module isn't installed (image not yet rebuilt with the new dep). + if (compression) app.use(compression()); + app.use(express.json()); app.use(cookieParser()); @@ -31,12 +65,13 @@ function setup(app) { }); // /data/* requires auth. express.static doesn't generate directory listings, - // so the only way to read anything is to know an exact path. + // so the only way to read anything is to know an exact path. noStore wins + // over staticOptions' maxAge for this prefix — auth-sensitive content + // should never be cached. app.use('/data', requireAuth, noStore, express.static(path.join(config.FRONTEND_PATH, 'data'))); // All other static assets (js, css, icons, html partials, index.html) remain public. - // dotfiles='ignore' by default so .auth.json is never served. - app.use(express.static(config.FRONTEND_PATH)); + app.use(express.static(config.FRONTEND_PATH, staticOptions)); } module.exports = { setup, requireAuth }; diff --git a/containers/libreportal/frontend/js/components/config/config-manager-old.js b/containers/libreportal/frontend/js/components/config/config-manager-old.js deleted file mode 100755 index c328c98..0000000 --- a/containers/libreportal/frontend/js/components/config/config-manager-old.js +++ /dev/null @@ -1,1645 +0,0 @@ -// Simple Config Manager - Direct approach without complex dependencies -class ConfigManager { - constructor() { - this.cache = new Map(); - } - - getRandomLoadingMessage() { - const messages = [ - "Preparing your configuration settings...", - "Gathering the finest configuration options...", - "Tuning up your system preferences...", - "Organizing your configuration categories...", - "Loading the perfect settings for you...", - "Crafting your personalized configuration...", - "Aligning your configuration stars...", - "Brewing the ideal configuration blend...", - "Setting up your configuration masterpiece...", - "Polishing your configuration preferences...", - "Configuring things just right for you...", - "Preparing your digital control panel...", - "Gathering your system's best settings...", - "Optimizing your configuration experience...", - "Loading your configuration superpowers..." - ]; - - return messages[Math.floor(Math.random() * messages.length)]; - } - - async loadConfig(category) { - //console.log(`ConfigManager: Loading ${category} config...`); - - // Check cache first - if (this.cache.has('unified')) { - //console.log(`ConfigManager: Using cached unified config`); - const unifiedData = this.cache.get('unified'); - return this.filterConfigByCategory(unifiedData, category); - } - - try { - // Load unified config data - const response = await fetch('/data/config/generated/configs.json'); - if (!response.ok) { - throw new Error(`Failed to load configs.json: ${response.status}`); - } - - const configData = await response.json(); - //console.log(`ConfigManager: Loaded unified config:`, configData); - - // Cache the result - this.cache.set('unified', configData); - - // Filter by requested category - const categoryData = this.filterConfigByCategory(configData, category); - //console.log(`ConfigManager: Filtered ${category} config:`, categoryData); - - return categoryData; - - } catch (error) { - console.error(`ConfigManager: Error loading ${category} config:`, error); - return { config: {}, description: 'Failed to load configuration' }; - } - } - - filterConfigByCategory(unifiedData, category) { - if (!unifiedData || !unifiedData.config) { - return { config: {}, categories: {} }; - } - - // Filter config items by category - const filteredConfig = {}; - Object.entries(unifiedData.config).forEach(([key, value]) => { - if (value.category === category) { - filteredConfig[key] = value; - } - }); - - return { - config: filteredConfig, - categories: unifiedData.categories || {}, - subcategories: unifiedData.subcategories || {}, - configType: unifiedData.configType, - name: unifiedData.name - }; - } - - async renderConfig(category) { - //console.log(`ConfigManager: Rendering ${category} config...`); - - const configSection = document.getElementById('config-section'); - if (!configSection) { - console.error('ConfigManager: config-section element not found'); - return; - } - - // Show loading with enhanced visual - configSection.innerHTML = ` -
-
-
-
- Loading configuration... -
-
- ${this.getRandomLoadingMessage()} -
-
- -
- `; - - // Update loading bar if available - if (typeof router !== 'undefined' && router.updateProgress) { - router.updateProgress(60); - } - - try { - // Load config data - const configData = await this.loadConfig(category); - const config = configData.config || {}; - - // Update loading bar - if (typeof router !== 'undefined' && router.updateProgress) { - router.updateProgress(70); - } - - if (Object.keys(config).length === 0) { - configSection.innerHTML = '
No configuration available
'; - return; - } - - // Use the original ConfigShared system for beautiful rendering - if (typeof ConfigShared !== 'undefined') { - await this.renderWithOriginalStyling(category, configData); - } else { - // Fallback: load ConfigShared and then render - if (typeof router !== 'undefined' && router.updateProgress) { - router.updateProgress(75); - } - await this.loadScript('/js/components/config/config-options.js'); - await this.loadScript('/js/components/config/config-shared.js'); - await this.renderWithOriginalStyling(category, configData); - } - - // Final progress update - if (typeof router !== 'undefined' && router.updateProgress) { - router.updateProgress(80); - } - - //console.log(`ConfigManager: Successfully rendered ${category} config`); - - // Initialize git field visibility after rendering - if (typeof initializeGitFieldVisibility === 'function') { - setTimeout(() => { - initializeGitFieldVisibility(); - }, 100); - } - - } catch (error) { - console.error(`ConfigManager: Error rendering ${category} config:`, error); - configSection.innerHTML = `
Failed to load ${category} configuration: ${error.message}
`; - } - } - - async renderWithOriginalStyling(category, configData) { - //console.log(`renderWithOriginalStyling: category=${category}, CFG_INSTALL_MODE=${configData.config?.CFG_INSTALL_MODE?.value}`); - const configSection = document.getElementById('config-section'); - const config = configData.config || {}; - const subcategories = configData.subcategories || {}; - - // Check if we have subcategories data available - const hasSubcategories = Object.keys(subcategories).length > 0; - - // Use shared categorization functionality - const categorized = ConfigShared.categorizeConfigs(config); - const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized; - - // Render using the original system's approach with advanced/unused sections - let formHTML = ` -
-

${this.formatCategoryName(category)} Configuration

-

${configData.description || 'Configure settings for ' + category}

-
-
-
- `; - - // Add requirements warning for requirements category - if (category === 'requirements') { - formHTML += ConfigShared.generateRequirementsWarning(); - } - - // Add danger zone warning for features category - if (category === 'features') { - formHTML += ` -
-
- ⚠️ -
- Danger Zone - These options are for advanced users and may affect system stability -
-
-
- `; - } - - // Render using subcategories structure if available, otherwise fall back to original - if (hasSubcategories) { - // Render using new subcategories structure - const regularSubcategories = []; - const advancedSubcategories = []; - const unusedSubcategories = []; - - // Filter subcategories by category and separate into regular, advanced, and unused - for (const [subcategoryName, subcategoryData] of Object.entries(subcategories)) { - if (subcategoryData.category === category) { - if (subcategoryData.description.includes('**ADVANCED**')) { - advancedSubcategories.push(subcategoryName); - } else if (subcategoryData.description.includes('**UNUSED**')) { - unusedSubcategories.push(subcategoryName); - } else { - regularSubcategories.push(subcategoryName); - } - } - } - - // Render regular subcategories with proper sectioning - for (const subcategoryName of regularSubcategories) { - const subcategoryData = subcategories[subcategoryName]; - const rawSubcategoryTitle = subcategoryData.title || ConfigShared.formatCategoryName(subcategoryName); - const displaySubcategory = ConfigShared.stripCategoryPrefix(rawSubcategoryTitle, category); - const subcategoryDescription = subcategoryData.description || 'Subcategory configuration'; - - // Find config items for this subcategory - const configItems = Object.entries(config) - .filter(([key, value]) => value.subcategory === subcategoryName) - .map(([key, value]) => ({ key, ...value })); - - if (configItems.length > 0) { - // Check for master toggle in this subcategory - const masterKey = configItems.find(item => item.master === true); - - // Find any ENABLED options and use universal toggle renderer - const enabledKey = configItems.find(item => item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE'); - //console.log('ConfigManager: Checking for toggle - subcategoryName:', subcategoryName, 'enabledKey found:', !!enabledKey, enabledKey ? enabledKey.key : null); - - // Special handling for domains section - const isDomains = subcategoryName.includes('domains'); - - if (enabledKey) { - // Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE - formHTML += ToggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription, config); - } else if (masterKey) { - // Render with master toggle - formHTML += this.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription); - } else if (isDomains) { - // Render domains section with special handling - formHTML += await this.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription); - } else { - // Render regular subcategory with proper sectioning - formHTML += this.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config); - } - } - } - - // Render advanced subcategories (hidden by default) - if (advancedSubcategories.length > 0) { - // Add danger zone toggle for advanced sections - formHTML += ConfigShared.generateToggleControls(true, false); - - // Generate advanced sections using shared functionality - const advancedGroupedConfigs = {}; - advancedSubcategories.forEach(subcategoryName => { - const configItems = Object.entries(config) - .filter(([key, value]) => value.subcategory === subcategoryName) - .map(([key, value]) => ({ key, ...value })); - - if (configItems.length > 0) { - advancedGroupedConfigs[subcategoryName] = configItems.map(item => item.key); - } - }); - - formHTML += await ConfigShared.generateAdvancedSections( - advancedSubcategories, - advancedGroupedConfigs, - config, - (category) => this.cleanDescription(subcategories[category]?.description || 'Advanced settings') - ); - } - - // Render unused subcategories (hidden by default) - if (unusedSubcategories.length > 0) { - // Add unused section toggle - formHTML += ConfigShared.generateToggleControls(false, true); - - // Wrap unused sections in hidden container - formHTML += ` - - `; - } - } else { - // Fall back to original categorization system - const categorized = ConfigShared.categorizeConfigs(config); - const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized; - - // Render regular categories (always visible) - for (const cat of regularCategories) { - const keys = groupedConfigs[cat]; - if (keys && keys.length > 0 && cat !== 'Hidden/Unused Options') { - const displayCategory = ConfigShared.formatCategoryName(cat); - const categoryDescription = await ConfigShared.getCategoryDescription(cat); - - // Check if this category has a master toggle (any key with master: true) - const masterKey = keys.find(key => { - const configItem = config[key] || {}; - return configItem.master === true; - }); - - if (masterKey) { - // Dynamic master toggle handling - const masterValue = config[masterKey]?.value || 'false'; - const isMasterEnabled = masterValue === 'true'; - const masterTitle = config[masterKey]?.title || ConfigShared.formatConfigLabel(masterKey); - const masterDescription = config[masterKey]?.description || ''; - const sectionId = `${cat.toLowerCase().replace(/[^a-z0-9]/g, '-')}`; - const toggleId = `${masterKey.toLowerCase()}-toggle`; - - formHTML += ` -
-
-
-
-

${displayCategory}

-

${categoryDescription}

-
-
-
-
-
- -
-
-
-
- `; - - // Add all other fields (excluding the master toggle) - keys.filter(key => key !== masterKey).forEach(key => { - const configItem = config[key] || {}; - const value = configItem.value || ''; - const title = configItem.title || ConfigShared.formatConfigLabel(key); - const description = configItem.description || ''; - const options = configItem.options || ''; - const fieldId = `config-${key}`; - formHTML += ConfigShared.generateField(fieldId, key, value, title, description, options, config); - }); - - formHTML += ` -
-
-
-
-
- `; - } else if (cat === 'DOMAINS') { - // Check if Traefik is installed - const traefikInstalled = await this.checkTraefikInstallation(); - - // Always show the domains section, but add a warning banner if Traefik is not installed - formHTML += ` -
-
-
-

${displayCategory}

-

${categoryDescription}

-
-
- `; - - if (!traefikInstalled) { - // Show smaller warning banner - formHTML += ` -
-
- ⚠️ -
- Traefik Not Installed - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later. -
-
-
- `; - } - - formHTML += `
`; - - // Only show domains that have content (non-empty values) - const allDomainKeys = keys.filter(key => key.startsWith('CFG_DOMAIN_')); - const domainKeysWithContent = allDomainKeys.filter(key => { - const configItem = config[key] || {}; - const value = configItem.value || ''; - return value.trim() !== ''; - }); - - // Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots) - const isMaxDomains = domainKeysWithContent.length >= 9; - - domainKeysWithContent.forEach(key => { - const configItem = config[key] || {}; - const value = configItem.value || ''; - const title = configItem.title || ConfigShared.formatConfigLabel(key); - const fieldId = `config-${key}`; - - // Extract domain number - const domainNum = parseInt(key.match(/CFG_DOMAIN_(\d+)/)[1]); - const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k => - parseInt(k.match(/CFG_DOMAIN_(\d+)/)[1]) - )); - - // Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted - const canDelete = isHighestDomain && domainNum !== 1; - - formHTML += ` -
-
- ${ConfigShared.generateField(fieldId, key, value, title, '', { - placeholder: 'example.com', - className: 'domain-input' - })} - -
-
- `; - }); - - formHTML += ` -
-
- -
-
-
- `; - } else { - // Regular category handling (no master toggle) - formHTML += ` -
-

${displayCategory}

-

${categoryDescription}

-
- ${ConfigShared.generateFieldsForCategory(keys, cat, config, (fieldId, key, value, title, description, options, config) => ConfigShared.generateField(fieldId, key, value, title, description, options, config))} -
-
- `; - } - } - } - } - - // Add danger zone before advanced/unused sections (so content appears below) - formHTML += ConfigShared.generateToggleControls( - advancedCategories.length > 0, - unusedCategories.length > 0 - ); - - // Add advanced and unused sections using shared functionality - formHTML += await ConfigShared.generateAdvancedSections( - advancedCategories, - groupedConfigs, - config, - (category) => ConfigShared.getCategoryDescription(category) - ); - - formHTML += await ConfigShared.generateUnusedSections( - unusedCategories, - groupedConfigs, - config, - (category) => ConfigShared.getCategoryDescription(category) - ); - - formHTML += ` -
-
- - -
-
- `; - - configSection.innerHTML = formHTML; - - // Initialize all master toggles dynamically - setTimeout(() => { - // Find all master toggle checkboxes (any input with id ending in "-toggle" where the name ends with "_ENABLED") - const masterToggles = document.querySelectorAll('input[id$="-toggle"][name$="_ENABLED"]'); - - masterToggles.forEach(toggle => { - const sectionId = toggle.id.replace('-toggle', ''); - const section = document.getElementById(`section-content-${sectionId}`); - - if (section && typeof ConfigShared.toggleSectionVisibility === 'function') { - // Initialize the section state based on the toggle - ConfigShared.toggleSectionVisibility(`section-content-${sectionId}`, toggle.checked); - } - }); - }, 100); - } - - // Check if Traefik is installed - async checkTraefikInstallation() { - try { - // Use the generic app installation checker - return await DataLoader.isAppInstalled('traefik'); - } catch (error) { - //console.log('Traefik check failed:', error.message); - return false; - } - } - - // Domain management functions - addNewDomain() { - //console.log('Add Domain button clicked!'); - - try { - // Find the highest existing domain number - const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]'); - const domainNumbers = []; - - domainInputs.forEach(input => { - const match = input.name.match(/CFG_DOMAIN_(\d+)/); - if (match) { - domainNumbers.push(parseInt(match[1])); - } - }); - - // Check if we've reached the maximum of 9 domains - if (domainNumbers.length >= 9) { - //console.log('Maximum of 9 domains reached'); - return; - } - - const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1; - const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`; - const newFieldId = `config-${newDomainKey}`; - - // Create new domain building block HTML - const newDomainHTML = ` -
-
- ${ConfigShared.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { placeholder: 'example.com' })} - -
-
- `; - - // Find the domain-building-blocks container and add the new block - const domainContainer = document.querySelector('.domain-building-blocks'); - if (domainContainer) { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = newDomainHTML; - const newBlock = tempDiv.firstElementChild; - domainContainer.appendChild(newBlock); - - // Focus on the new input field - const newInput = newBlock.querySelector('input'); - if (newInput) { - newInput.focus(); - } - - // Update add domain button state - const addBtn = document.getElementById('add-domain-btn'); - if (addBtn) { - const totalDomains = document.querySelectorAll('.domain-building-block').length; - if (totalDomains >= 9) { - addBtn.disabled = true; - addBtn.className = 'btn btn-secondary'; - addBtn.innerHTML = 'Maximum Domains Reached'; - } - } - - // Update delete button states (only highest numbered domain should be deletable) - const allDomainBlocks = document.querySelectorAll('.domain-building-block'); - allDomainBlocks.forEach((block, index) => { - const deleteBtn = block.querySelector('.delete-domain-btn'); - if (deleteBtn) { - const input = block.querySelector('input'); - const inputName = input ? input.name : ''; - const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); - const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; - - // Find the highest domain number among all visible blocks - const allDomainNumbers = Array.from(allDomainBlocks).map(b => { - const inp = b.querySelector('input'); - const name = inp ? inp.name : ''; - const match = name.match(/CFG_DOMAIN_(\d+)/); - return match ? parseInt(match[1]) : 0; - }); - const highestDomainNumber = Math.max(...allDomainNumbers); - - // Only highest numbered domain can be deleted, but NEVER Domain 1 - const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; - - deleteBtn.disabled = !canDelete; - deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; - deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain'; - } - }); - } - } catch (error) { - console.error('Error adding new domain:', error); - } - } - - deleteDomain(domainKey, buttonElement) { - //console.log(`Delete domain button clicked for: ${domainKey}`); - - try { - // Find the domain-building-block and remove it - const domainBlock = buttonElement.closest('.domain-building-block'); - if (domainBlock) { - // Clear the input value first - const input = domainBlock.querySelector('input'); - if (input) { - input.value = ''; - } - // Remove the entire building block - domainBlock.remove(); - - // Update add domain button state (re-enable if we're below 9 domains) - const addBtn = document.getElementById('add-domain-btn'); - if (addBtn) { - const totalDomains = document.querySelectorAll('.domain-building-block').length; - if (totalDomains < 9) { - addBtn.disabled = false; - addBtn.className = 'btn btn-primary'; - addBtn.innerHTML = '+Add Domain'; - } - } - - // Update delete button states (only highest numbered domain should be deletable) - const allDomainBlocks = document.querySelectorAll('.domain-building-block'); - allDomainBlocks.forEach((block, index) => { - const deleteBtn = block.querySelector('.delete-domain-btn'); - if (deleteBtn) { - const input = block.querySelector('input'); - const inputName = input ? input.name : ''; - const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); - const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; - - // Find the highest domain number among all visible blocks - const allDomainNumbers = Array.from(allDomainBlocks).map(b => { - const inp = b.querySelector('input'); - const name = inp ? inp.name : ''; - const match = name.match(/CFG_DOMAIN_(\d+)/); - return match ? parseInt(match[1]) : 0; - }); - const highestDomainNumber = Math.max(...allDomainNumbers); - - // Only highest numbered domain can be deleted, but NEVER Domain 1 - const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; - - deleteBtn.disabled = !canDelete; - deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; - deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain'; - } - }); - } - } catch (error) { - console.error('Error deleting domain:', error); - } - } - - async loadScript(src) { - const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); - const existingScript = document.getElementById(scriptId); - - if (existingScript && src.includes('config-shared.js')) { - existingScript.remove(); - } else if (existingScript) { - return; - } - - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = src; - script.id = scriptId; - script.onload = resolve; - script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); - document.head.appendChild(script); - }); - } - - formatCategoryName(category) { - return category - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' '); - } - - static async saveConfig(category) { - const form = document.getElementById(`config-form-${category}`); - if (!form) return; - - const formData = new FormData(form); - const config = {}; - - for (const [key, value] of formData.entries()) { - const checkbox = form.querySelector(`input[name="${key}"][type="checkbox"]`); - if (checkbox) { - config[key] = checkbox.checked; - } else { - config[key] = value; - } - } - } - - static async resetConfig(category) { - if (confirm('Are you sure you want to reset all settings to their default values?')) { - window.location.reload(); - } - } - - // Helper method to render subcategory with master toggle - renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription, isAdvanced = false) { - const masterValue = masterKey.value || 'false'; - const isMasterEnabled = masterValue === 'true'; - const masterTitle = masterKey.title || ConfigShared.formatConfigLabel(masterKey.key); - const masterDescription = masterKey.description || ''; - const sectionId = `${displaySubcategory.toLowerCase().replace(/[^a-z0-9]/g, '-')}`; - const toggleId = `${masterKey.key.toLowerCase()}-toggle`; - - let html = ` -
-
-
-
-

${displaySubcategory}

-

${subcategoryDescription}

-
-
-
-
-
- -
-
-
-
- `; - - // Add all other fields (excluding the master toggle) - configItems.filter(item => item.key !== masterKey.key).forEach(item => { - const fieldId = `config-${item.key}`; - html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); - }); - - html += ` -
-
-
-
-
- `; - - return html; - } - - // Helper method to render domains section with special handling - async renderDomainsSection(configItems, displaySubcategory, subcategoryDescription) { - // Check if Traefik is installed - const traefikInstalled = await this.checkTraefikInstallation(); - - let html = ` -
-
-
-

${displaySubcategory}

-

${subcategoryDescription}

-
-
- `; - - if (!traefikInstalled) { - // Show smaller warning banner - html += ` -
-
- ⚠️ -
- Traefik Not Installed - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later. -
-
-
- `; - } - - html += `
`; - - // Only show domains that have content (non-empty values) - const allDomainKeys = configItems.filter(item => item.key.startsWith('CFG_DOMAIN_')); - const domainKeysWithContent = allDomainKeys.filter(item => { - const value = item.value || ''; - return value.trim() !== ''; - }); - - // Only render domains that have content - domainKeysWithContent.forEach(item => { - const value = item.value || ''; - const title = item.title || ConfigShared.formatConfigLabel(item.key); - const fieldId = `config-${item.key}`; - - // Extract domain number - const domainNum = parseInt(item.key.match(/CFG_DOMAIN_(\d+)/)[1]); - const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k => - parseInt(k.key.match(/CFG_DOMAIN_(\d+)/)[1]) - )); - - // Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted - const canDelete = isHighestDomain && domainNum !== 1; - - html += ` -
-
- ${ConfigShared.generateField(fieldId, item.key, value, title, '', { - placeholder: 'example.com', - className: 'domain-input', - onchange: 'window.configManager.validateDomainFormat(this, true)', - oninput: 'window.configManager.validateDomainFormat(this, true)', - onblur: 'window.configManager.validateDomainFormat(this, true)' - })} - -
-
- `; - }); - - // Add "Add Domain" button outside the grid - const isMaxDomains = domainKeysWithContent.length >= 9; - html += ` -
-
- -
-
-
-
- `; - - return html; - } - - // Helper method to render remote backup section with toggle - renderRemoteBackupSection(backupKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { - const isEnabled = backupKey.value === 'true'; - const sectionId = `backup-${backupKey.key}`; - const toggleId = `${backupKey.key.toLowerCase()}-toggle`; - - let html = ` -
-
-
-
-

${displaySubcategory}

-

${subcategoryDescription}

-
-
-
-
-
- -
-
-
-
- `; - - // Add all other fields (excluding the ENABLED toggle) - configItems.filter(item => item.key !== backupKey.key).forEach(item => { - const fieldId = `config-${item.key}`; - html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); - }); - - html += ` -
-
-
-
-
- `; - - return html; - } - - // Helper method to render mail section with toggle - renderMailSection(mailKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { - const isEnabled = mailKey.value === 'true'; - const sectionId = `mail-${mailKey.key}`; - const toggleId = `${mailKey.key.toLowerCase()}-toggle`; - - let html = ` -
-
-
-
-

${displaySubcategory}

-

${subcategoryDescription}

-
-
-
-
-
- -
-
-
-
- `; - - // Add all other fields (excluding the ENABLED toggle) - configItems.filter(item => item.key !== mailKey.key).forEach(item => { - const fieldId = `config-${item.key}`; - html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); - }); - - // Add test connection button after all mail fields - html += ` -
- - -
-
-
-
-
-
- `; - - return html; - } - - // Test mail server connection - async testMailConnection(mailKey) { - const resultDiv = document.getElementById('mail-test-result'); - const button = event.target.closest('button'); - - // Show loading state - button.disabled = true; - button.innerHTML = 'Testing...'; - resultDiv.style.display = 'block'; - resultDiv.className = 'test-result testing'; - resultDiv.innerHTML = 'Testing mail server connection...'; - - // Initialize mailConfig outside try block for catch block access - let mailConfig = {}; - - try { - // Get current mail configuration values from the form - mailConfig = { - host: document.querySelector('input[name="CFG_MAIL_HOST"]')?.value || '', - port: document.querySelector('input[name="CFG_MAIL_PORT"]')?.value || '', - secure: document.querySelector('select[name="CFG_MAIL_SECURE"]')?.value || '', - username: document.querySelector('input[name="CFG_MAIL_USERNAME"]')?.value || '', - password: document.querySelector('input[name="CFG_MAIL_PASSWORD"]')?.value || '', - from: document.querySelector('input[name="CFG_MAIL_FROM"]')?.value || '' - }; - - // Validate required fields - if (!mailConfig.host || !mailConfig.port || !mailConfig.username || !mailConfig.password) { - throw new Error('Please fill in all required mail server fields (host, port, username, password)'); - } - - // Call backend test script - const response = await fetch('/api/test-mail-connection', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(mailConfig) - }); - - const result = await response.json(); - - if (result.success) { - resultDiv.className = 'test-result success'; - resultDiv.innerHTML = `✅ ${result.message || 'Mail server connection successful!'}${result.details ? `
${result.details}` : ''}`; - } else { - resultDiv.className = 'test-result error'; - let errorHtml = `❌ ${result.message || 'Mail server connection failed'}`; - - // Add detailed error information directly underneath - if (result.details || result.error || result.config) { - errorHtml += ` -
- Error Details:
- ${result.details || result.error || 'No additional details available'} - ${result.stack ? `

Stack Trace:
${result.stack}` : ''} - ${result.config ? `

Connection Config:
${JSON.stringify(result.config, null, 2)}` : ''} -
- `; - } - - resultDiv.innerHTML = errorHtml; - } - - } catch (error) { - resultDiv.className = 'test-result error'; - resultDiv.innerHTML = ` - ❌ ${error.message || 'Failed to test mail connection'} -
- Error Details:
- ${error.message || 'Unknown error'} - ${error.stack ? `

Stack Trace:
${error.stack}` : ''} - ${error.response ? `

Response:
${JSON.stringify(error.response, null, 2)}` : ''} - ${mailConfig ? `

Mail Config:
${JSON.stringify({...mailConfig, password: mailConfig.password ? '[REDACTED]' : undefined}, null, 2)}` : ''} -
- `; - } finally { - // Restore button state - button.disabled = false; - button.innerHTML = '📧Test Mail Connection'; - } - } - - // Helper method to render Git section with toggle - renderGitSection(gitKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { - // CFG_INSTALL_MODE controls git section: 'git' = enabled, 'local' = disabled - const isEnabled = gitKey.value === 'git'; - const sectionId = `git-${gitKey.key}`; - const toggleId = `${gitKey.key.toLowerCase()}-toggle`; - - let html = ` -
-
-
-
-

${displaySubcategory}

-

${subcategoryDescription}

-
-
-
-
-
- - -
-
-
-
- `; - - // Add all other git fields (excluding the CFG_INSTALL_MODE itself) - configItems.filter(item => item.key !== gitKey.key && item.key.startsWith('CFG_GIT_')).forEach(item => { - const fieldId = `config-${item.key}`; - html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); - }); - - html += ` -
-
-
-
-
- `; - - return html; - } - - // Helper method to clean description text by removing tags - cleanDescription(description) { - return description - .replace(/\*\*ADVANCED\*\*/g, '') - .replace(/\*\*UNUSED\*\*/g, '') - .replace(/^\s+|\s+$/g, '') // Trim whitespace - .replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space - } - - // Update all domain delete button states - updateDomainDeleteButtons() { - const allDomainBlocks = document.querySelectorAll('.domain-building-block'); - - // Find the highest domain number (regardless of content) - const domainNumbers = Array.from(allDomainBlocks).map(block => { - const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); - if (input) { - const match = input.id.match(/CFG_DOMAIN_(\d+)/); - return match ? parseInt(match[1]) : 0; - } - return 0; - }).filter(num => num > 0); - - const highestDomain = Math.max(...domainNumbers, 0); - - // Update delete buttons - allDomainBlocks.forEach(block => { - const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); - const deleteBtn = block.querySelector('.delete-domain-btn'); - - if (input && deleteBtn) { - const match = input.id.match(/CFG_DOMAIN_(\d+)/); - const domainNum = match ? parseInt(match[1]) : 0; - - // SIMPLE RULE: Only highest numbered domain can be deleted (except Domain 1) - const canDelete = domainNum === highestDomain && domainNum !== 1; - - if (canDelete) { - deleteBtn.classList.remove('disabled'); - deleteBtn.disabled = false; - deleteBtn.title = 'Delete domain'; - } else { - deleteBtn.classList.add('disabled'); - deleteBtn.disabled = true; - if (domainNum === 1) { - deleteBtn.title = 'Domain 1 cannot be deleted'; - } else { - deleteBtn.title = 'Can only delete highest numbered domain'; - } - } - } - }); - } - - // Validate domain format when user tries to add a new domain - validateDomainFormat(input, showNotifications = true) { - const value = input.value.trim(); - if (!value) { - return true; // Allow empty for initial state - } - - // Basic domain validation regex - supports subdomains and multiple TLD levels - const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; - const isValidFormat = domainRegex.test(value); - - // Check for duplicates - const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); - const duplicates = Array.from(allDomainInputs).filter(otherInput => { - if (otherInput === input) return false; // Skip self - return otherInput.value.trim().toLowerCase() === value.toLowerCase(); - }); - const hasDuplicate = duplicates.length > 0; - - if (!isValidFormat) { - input.style.borderColor = '#dc3545'; - input.title = 'Invalid domain format (e.g., example.com)'; - if (showNotifications && window.notificationSystem) { - window.notificationSystem.error(`Invalid domain format: "${value}". Please use a valid domain like example.com`); - } - return false; - } else if (hasDuplicate) { - input.style.borderColor = '#ffc107'; - input.title = 'Domain already exists!'; - if (showNotifications && window.notificationSystem) { - window.notificationSystem.warning(`Domain "${value}" already exists. Please use a unique domain.`); - } - return false; - } else { - input.style.borderColor = ''; - input.title = ''; - return true; - } - } - - // Validate email format for mail fields - validateEmailFormat(input, showNotifications = true) { - const value = input.value.trim(); - if (!value) { - return true; // Allow empty for initial state - } - - // Email validation regex - const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; - const isValidFormat = emailRegex.test(value); - - if (!isValidFormat) { - input.style.borderColor = '#dc3545'; - input.title = 'Invalid email format (e.g., user@example.com)'; - if (showNotifications && window.notificationSystem) { - window.notificationSystem.error(`Invalid email format: "${value}". Please use a valid email like user@example.com`); - } - return false; - } else { - input.style.borderColor = ''; - input.title = ''; - return true; - } - } - - // Validate hostname format for mail server - validateHostnameFormat(input, showNotifications = true) { - const value = input.value.trim(); - if (!value) { - return true; // Allow empty for initial state - } - - // Hostname validation regex - allows subdomains and multiple TLD levels - const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; - const isValidFormat = hostnameRegex.test(value); - - if (!isValidFormat) { - input.style.borderColor = '#dc3545'; - input.title = 'Invalid hostname format (e.g., mail.domain.com)'; - if (showNotifications && window.notificationSystem) { - window.notificationSystem.error(`Invalid hostname format: "${value}". Please use a valid hostname like mail.domain.com`); - } - return false; - } else { - input.style.borderColor = ''; - input.title = ''; - return true; - } - } - - // Validate port number for mail server - validatePortNumber(input, showNotifications = true) { - const value = input.value.trim(); - if (!value) { - return true; // Allow empty for initial state - } - - const port = parseInt(value, 10); - const isValidPort = !isNaN(port) && port >= 1 && port <= 65535; - - if (!isValidPort) { - input.style.borderColor = '#dc3545'; - input.title = 'Invalid port number (1-65535)'; - if (showNotifications && window.notificationSystem) { - window.notificationSystem.error(`Invalid port number: "${value}". Please use a valid port between 1 and 65535`); - } - return false; - } else { - input.style.borderColor = ''; - input.title = ''; - return true; - } - } - - // Check if all domains are valid before allowing new domain addition - canAddNewDomain() { - const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); - for (const input of allDomainInputs) { - if (!this.validateDomainFormat(input, true)) { // Don't show notifications during bulk check - return false; // At least one domain has invalid format or duplicate - } - } - return true; // All domains are valid - } - - // Add a new domain field - addDomain(button) { - // Find all existing domain blocks - const allDomainBlocks = document.querySelectorAll('.domain-building-block'); - const domainData = Array.from(allDomainBlocks).map(block => { - const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); - if (input) { - const match = input.id.match(/CFG_DOMAIN_(\d+)/); - return { - num: match ? parseInt(match[1]) : 0, - value: input.value.trim(), - input: input, - block: block - }; - } - return null; - }).filter(item => item !== null); - - // Sort by domain number - domainData.sort((a, b) => a.num - b.num); - - // Find the highest domain number with content - const domainsWithContent = domainData.filter(d => d.value); - const highestDomainWithContent = domainsWithContent.length > 0 ? - Math.max(...domainsWithContent.map(d => d.num)) : 0; - - // Find the highest domain number overall (including empty) - const highestDomainOverall = Math.max(...domainData.map(d => d.num), 0); - - // Check if the highest domain (overall) is empty - const highestDomainData = domainData.find(d => d.num === highestDomainOverall); - if (highestDomainData && !highestDomainData.value) { - // Flash the empty highest domain without validation checks - const emptyInput = document.querySelector(`input[id="config-CFG_DOMAIN_${highestDomainOverall}"]`); - if (emptyInput) { - emptyInput.style.animation = 'flash 0.5s ease-in-out 2'; - emptyInput.focus(); - setTimeout(() => { - emptyInput.style.animation = ''; - }, 1000); - return; - } - - // Remove animation after it completes - setTimeout(() => { - input.style.animation = ''; - }, 1000); - - return; - } - - // Find the next available domain slot (only if highest with content is filled) - const usedNumbers = domainData.map(d => d.num); - let nextDomain = 1; - while (usedNumbers.includes(nextDomain) && nextDomain <= 9) { - nextDomain++; - } - - // Only add if we have domains with content and the highest with content is filled - if (highestDomainWithContent === 0) { - // No domains with content yet, this shouldn't happen but handle it - } else if (nextDomain > 9) { - if (window.notificationSystem) { - window.notificationSystem.warning('Maximum of 9 domains reached!'); - } - return; - } - - // Before adding new domain, validate that all existing domains have valid format - if (!this.canAddNewDomain()) { - // Find the first invalid domain and focus it with flash - const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); - for (const input of allDomainInputs) { - if (!this.validateDomainFormat(input, false)) { // Don't show notification here - input.style.animation = 'flash 0.5s ease-in-out 2'; - input.focus(); - setTimeout(() => { - input.style.animation = ''; - }, 1000); - return; // Just flash and focus, no extra notification - } - } - } - - // Create new domain field with proper structure - const domainKey = `CFG_DOMAIN_${nextDomain}`; - const fieldId = `config-${domainKey}`; - const title = `Domain ${nextDomain}`; - - const newDomainHTML = ` -
-
- ${ConfigShared.generateField(fieldId, domainKey, '', title, '', { - placeholder: 'example.com', - className: 'domain-input', - onchange: 'window.configManager.validateDomainFormat(this, true)', - oninput: 'window.configManager.validateDomainFormat(this, true)', - onblur: 'window.configManager.validateDomainFormat(this, true)' - })} - -
-
- `; - - // Insert inside the domain-building-blocks container before the domain-actions - const domainBlocks = button.closest('.domains-wrapper').querySelector('.domain-building-blocks'); - domainBlocks.insertAdjacentHTML('beforeend', newDomainHTML); - - // Update all delete button states after DOM is ready - setTimeout(() => this.updateDomainDeleteButtons(), 10); - - // Update button state if we're now at max domains - const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length; - if (totalDomains >= 9) { - button.classList.add('disabled'); - button.disabled = true; - const iconSpan = button.querySelector('.add-icon'); - const textSpan = button.querySelector('.add-text'); - iconSpan.textContent = '✓'; - textSpan.textContent = 'Maximum Domains Reached'; - } - } - - // Delete a domain field - deleteDomain(domainKey, button) { - const domainBlock = button.closest('.domain-building-block'); - - // Clear the domain value - const input = document.getElementById(`config-${domainKey}`); - if (input) { - input.value = ''; - } - - // Remove the domain block if it's empty - if (!input || input.value === '') { - domainBlock.remove(); - } - - // Update all delete button states after DOM is ready - setTimeout(() => this.updateDomainDeleteButtons(), 10); - - // Update add button state - const addButton = document.querySelector('.add-domain-btn'); - if (addButton) { - const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length; - const domainsWithContent = Array.from(document.querySelectorAll('[id^="config-CFG_DOMAIN_"]')) - .filter(input => input.value.trim() !== '').length; - - if (totalDomains < 9) { - addButton.classList.remove('disabled'); - addButton.disabled = false; - const iconSpan = addButton.querySelector('.add-icon'); - const textSpan = addButton.querySelector('.add-text'); - iconSpan.textContent = '+'; - textSpan.textContent = 'Add Domain'; - } else { - addButton.classList.add('disabled'); - addButton.disabled = true; - const iconSpan = addButton.querySelector('.add-icon'); - const textSpan = addButton.querySelector('.add-text'); - iconSpan.textContent = '✓'; - textSpan.textContent = 'Maximum Domains Reached'; - } - } - } - - // Helper method to render subcategory with proper sectioning, dividers and headers - renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config = {}) { - //console.log(`renderSubcategorySection: subcategory=${subcategoryDescription}, configKeys=${Object.keys(config)}, CFG_INSTALL_MODE=${config.CFG_INSTALL_MODE?.value}`); - const cleanDescription = this.cleanDescription(subcategoryDescription); - let html = ` -
-

${displaySubcategory}

-

${cleanDescription}

-
-
-
- `; - - // Add all config items using standard layout - configItems.forEach((item, index) => { - const fieldId = `config-${item.key}`; - const cleanItemDescription = this.cleanDescription(item.description || ''); - html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, cleanItemDescription, item.options); - }); - - html += ` -
-
-
-
- `; - - return html; - } - - // Helper method to render regular subcategory - renderRegularSubcategory(configItems, displaySubcategory, subcategoryDescription, config = {}) { - let html = ` -
-

${displaySubcategory}

-

${subcategoryDescription}

-
- `; - - configItems.forEach(item => { - const fieldId = `config-${item.key}`; - html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); - }); - - html += ` -
-
-
- `; - - return html; - } -} - -// Global instance -window.configManager = new ConfigManager();