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}
-
-
-
-
-
-
-
-
- `;
-
- 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();