librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

647 lines
24 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Domain Management - Handles domain-related functionality
class DomainManager {
constructor() {
this.domains = [];
}
async checkTraefikInstallation() {
try {
const response = await fetch('/api/traefik/status');
if (response.ok) {
const data = await response.json();
return data.installed || false;
}
return false;
} catch (error) {
//console.log('DomainManager: Traefik status check failed, assuming not installed:', error.message);
return false;
}
}
async renderDomainsSection(configItems, displaySubcategory, subcategoryDescription) {
const traefikStatus = await this.checkTraefikInstallation();
let html = `
<div class="config-category">
<div class="domains-wrapper">
<div class="domains-header">
<div>
<h3>${displaySubcategory}</h3>
<p class="category-description">${subcategoryDescription}</p>
</div>
</div>
<div class="domains-divider"></div>
`;
if (!traefikStatus.installed) {
html += `
<div class="traefik-warning-banner">
<div class="traefik-warning-content">
<span class="traefik-warning-icon">⚠️</span>
<div class="traefik-warning-text">
<strong>Traefik Not Installed</strong> - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later.
</div>
</div>
</div>
`;
}
html += `<div class="domain-building-blocks">`;
// Only show domains that have content (non-empty values)
const allDomainKeys = configItems.map(item => item.key).filter(key => key.startsWith('CFG_DOMAIN_'));
const domainKeysWithContent = allDomainKeys.filter(key => {
const configItem = configItems.find(item => item.key === 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 = configItems.find(item => item.key === key) || {};
const value = configItem.value || '';
const title = configItem.title || this.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;
html += `
<div class="domain-building-block">
<div class="domain-header">
${this.generateField(fieldId, key, value, title, '', {
placeholder: 'example.com',
className: 'domain-input'
})}
<button type="button" class="delete-domain-btn ${!canDelete ? 'disabled' : ''}"
onclick="window.deleteDomain('${key}', this)"
title="${canDelete ? 'Delete domain' : domainNum === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'}"
${!canDelete ? 'disabled' : ''}>
<span class="delete-icon">×</span>
</button>
</div>
<div class="setup-dns-status" data-dns-for="${fieldId}"></div>
</div>
`;
});
setTimeout(() => this.attachDnsChecks(), 0);
html += `
</div>
<div class="domain-actions">
<button type="button" id="add-domain-btn" class="btn ${isMaxDomains ? 'btn-secondary' : 'btn-primary'}"
onclick="window.addDomain()"
${isMaxDomains ? 'disabled' : ''}>
<span class="add-icon">${isMaxDomains ? '✓' : '+'}</span>
${isMaxDomains ? 'Maximum Domains Reached' : 'Add Domain'}
</button>
</div>
</div>
<div class="spacer spacer-lg"></div>
</div>
`;
return html;
}
attachDnsChecks() {
const inputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
inputs.forEach((input) => {
if (input.dataset.dnsBound === '1') return;
input.dataset.dnsBound = '1';
const status = document.querySelector(`[data-dns-for="${input.id}"]`);
if (!status) return;
let timer = null;
let ctrl = null;
const run = () => {
if (timer) clearTimeout(timer);
if (ctrl) ctrl.abort();
const domain = input.value.trim().toLowerCase();
if (!domain) { status.textContent = ''; status.className = 'setup-dns-status'; return; }
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(domain)) {
status.textContent = ''; status.className = 'setup-dns-status';
return;
}
status.textContent = 'Checking DNS…';
status.className = 'setup-dns-status checking';
timer = setTimeout(async () => {
ctrl = new AbortController();
try {
const res = await fetch(`/api/setup/dns-check?domain=${encodeURIComponent(domain)}`, { signal: ctrl.signal });
const data = await res.json();
if (data.matches) {
status.textContent = `${domain}${data.server_ip} (this server)`;
status.className = 'setup-dns-status ok';
} else {
const detail = data.domain_ip
? `points to ${data.domain_ip}, this server is ${data.server_ip}`
: `does not resolve (server: ${data.server_ip || 'unknown'})`;
status.textContent = `${domain} ${detail}. Traefik may not route this yet.`;
status.className = 'setup-dns-status warn';
}
} catch (err) {
if (err.name !== 'AbortError') {
status.textContent = 'DNS check failed.';
status.className = 'setup-dns-status warn';
}
}
}, 500);
};
input.addEventListener('input', run);
input.addEventListener('blur', run);
if (input.value) run();
});
}
generateField(fieldId, key, value, title, description, options = {}) {
// Use the same field generation as ConfigShared
if (typeof ConfigShared !== 'undefined') {
return ConfigShared.generateField(fieldId, key, value, title, description, options);
}
// Fallback field generation
const placeholder = key.startsWith('CFG_DOMAIN_') ? 'example.com' : 'https://example.com';
return `
<input type="url" id="${fieldId}" name="${key}" value="${value}" class="form-control" placeholder="${placeholder}">
`;
}
formatConfigLabel(key) {
// Special handling for domain configuration
if (key.startsWith('CFG_DOMAIN_')) {
const domainNum = key.replace('CFG_DOMAIN_', '');
return `Domain ${domainNum}`;
}
// Use ConfigShared if available
if (typeof ConfigShared !== 'undefined') {
return ConfigShared.formatConfigLabel(key);
}
// Fallback formatting
return key.replace(/^CFG_/, '').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
}
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 (count only existing domains, not empty slots)
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 = `
<div class="domain-building-block">
<div class="domain-header">
${this.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { placeholder: 'example.com' })}
<button type="button" class="delete-domain-btn"
onclick="window.configManager.deleteDomain('${newDomainKey}', this)">
<span class="delete-icon">×</span>
</button>
</div>
<div class="setup-dns-status" data-dns-for="${newFieldId}"></div>
</div>
`;
// 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
this.updateDomainDeleteButtons();
this.attachDnsChecks();
}
} catch (error) {
console.error('Error adding new domain:', error);
}
}
deleteDomain(domainKey, buttonElement) {
if (typeof window.DomainManager === 'undefined' || !window.DomainManager.deleteDomain) {
console.error('DomainManager not available for deleteDomain');
return;
}
//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 delete button states
this.updateDomainDeleteButtons();
}
} catch (error) {
console.error('Error deleting domain:', error);
}
}
updateDomainDeleteButtons() {
// Find all domain building blocks
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
// Find all domain numbers from existing blocks
const allDomainNumbers = [];
allDomainBlocks.forEach(block => {
const input = block.querySelector('input');
if (input) {
const inputName = input.name;
const match = inputName.match(/CFG_DOMAIN_(\d+)/);
if (match) {
allDomainNumbers.push(parseInt(match[1]));
}
}
});
// Update delete button states (only highest numbered domain can be deleted, but NEVER Domain 1)
allDomainBlocks.forEach((block, index) => {
const deleteBtn = block.querySelector('.delete-domain-btn');
if (deleteBtn) {
const input = block.querySelector('input');
if (input) {
const inputName = 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 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' : domainNumber === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain';
}
}
});
}
}
// Standalone domain management functions - immediately available
window.addDomain = function() {
//console.log('Add Domain button clicked!');
try {
// Before adding new domain, validate that all existing domains have valid format
if (!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 (!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;
}
}
return;
}
// 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 = `
<div class="domain-building-block">
<div class="domain-header">
${typeof ConfigShared !== 'undefined' ? ConfigShared.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', {
placeholder: 'example.com',
className: 'domain-input',
onchange: 'validateDomainFormat(this, true)',
oninput: 'validateDomainFormat(this, true)',
onblur: 'validateDomainFormat(this, true)'
}) : `<input type="url" id="${newFieldId}" name="${newDomainKey}" value="" class="form-control domain-input" placeholder="example.com" onchange="validateDomainFormat(this, true)" oninput="validateDomainFormat(this, true)" onblur="validateDomainFormat(this, true)">`}
<button type="button" class="delete-domain-btn"
onclick="window.deleteDomain('${newDomainKey}', this)">
<span class="delete-icon">×</span>
</button>
</div>
</div>
`;
// 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
updateDomainDeleteButtons();
updateAddDomainButton();
}
} catch (error) {
console.error('Error adding new domain:', error);
}
};
window.deleteDomain = function(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)
updateDomainDeleteButtons();
updateAddDomainButton();
}
} catch (error) {
console.error('Error deleting domain:', error);
}
};
// Helper function to update delete button states
function updateDomainDeleteButtons() {
// Find all domain building blocks
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
// Find all domain numbers from existing blocks
const allDomainNumbers = [];
allDomainBlocks.forEach(block => {
const input = block.querySelector('input');
if (input) {
const inputName = input.name;
const match = inputName.match(/CFG_DOMAIN_(\d+)/);
if (match) {
allDomainNumbers.push(parseInt(match[1]));
}
}
});
// Update delete button states (only highest numbered domain can be deleted, but NEVER Domain 1)
allDomainBlocks.forEach((block, index) => {
const deleteBtn = block.querySelector('.delete-domain-btn');
if (deleteBtn) {
const input = block.querySelector('input');
if (input) {
const inputName = 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 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' : domainNumber === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain';
}
}
});
}
// Helper function to update add domain button state
function updateAddDomainButton() {
const addDomainBtn = document.getElementById('add-domain-btn');
if (addDomainBtn) {
// Find all domain building blocks
const allDomainBlocks = document.querySelectorAll('.domain-building-block');
// Find all domain numbers from existing blocks
const allDomainNumbers = [];
allDomainBlocks.forEach(block => {
const input = block.querySelector('input');
if (input) {
const inputName = input.name;
const match = inputName.match(/CFG_DOMAIN_(\d+)/);
if (match) {
allDomainNumbers.push(parseInt(match[1]));
}
}
});
// Check if we've reached the maximum of 9 domains
const isMaxDomains = allDomainNumbers.length >= 9;
// Update button state
addDomainBtn.disabled = isMaxDomains;
addDomainBtn.className = `btn ${isMaxDomains ? 'btn-secondary' : 'btn-primary'}`;
addDomainBtn.innerHTML = `<span class="add-icon">${isMaxDomains ? '✓' : '+'}</span>${isMaxDomains ? 'Maximum Domains Reached' : 'Add Domain'}`;
}
}
// Validate domain format when user tries to add a new domain
function validateDomainFormat(input, showNotifications = true) {
const value = input.value.trim();
//console.log('validateDomainFormat called with:', value, 'showNotifications:', showNotifications);
//console.log('window.notificationSystem available:', !!window.notificationSystem);
if (!value) {
// Clear styling for empty fields
input.style.borderColor = '#dc3545';
input.title = 'Domain cannot be empty';
if (showNotifications && window.notificationSystem) {
//console.log('Attempting to show empty domain notification');
window.notificationSystem.error('Domain cannot be empty');
} else {
console.error('Domain cannot be empty - notification system not available');
}
return false; // Empty fields are not valid for adding new domains
}
// 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 =>
otherInput !== input && otherInput.value.trim() === value
);
const hasDuplicate = duplicates.length > 0;
if (!isValidFormat) {
input.style.borderColor = '#dc3545';
input.title = 'Invalid domain format (e.g., example.com)';
if (showNotifications && window.notificationSystem) {
//console.log('Attempting to show invalid format notification');
window.notificationSystem.error('Invalid domain format: "' + value + '". Please use a valid domain like example.com');
} else {
console.error('Invalid domain format - notification system not available');
}
return false;
} else if (hasDuplicate) {
input.style.borderColor = '#dc3545';
input.title = 'Domain already exists';
if (showNotifications && window.notificationSystem) {
//console.log('Attempting to show duplicate notification');
window.notificationSystem.error('Domain "' + value + '" already exists');
} else {
console.error('Domain already exists - notification system not available');
}
return false;
} else {
input.style.borderColor = '';
input.title = '';
return true;
}
}
// Check if all domains are valid before allowing new domain addition
function canAddNewDomain() {
const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
for (const input of allDomainInputs) {
const domainValue = input.value.trim();
// Check if domain is empty
if (!domainValue) {
// Flash the empty input field
input.style.animation = 'flash 0.5s ease-in-out 2';
input.focus();
setTimeout(() => {
input.style.animation = '';
}, 1000);
// Show notification with safety check
if (window.notificationSystem) {
window.notificationSystem.error('Domain cannot be empty');
} else {
console.error('Domain cannot be empty');
}
return false; // Empty domain - don't allow adding new domain
}
// Check for duplicates with notification (always show)
if (checkForDuplicateDomain(input, domainValue)) {
return false;
}
// Check domain format with notification (always show)
if (checkForInvalidDomainFormat(input, domainValue)) {
return false;
}
}
return true; // All domains are valid and non-empty
}
// Separate function to check for invalid format that always shows notifications
function checkForInvalidDomainFormat(input, domainValue) {
if (!domainValue) return false;
// 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(domainValue);
if (!isValidFormat) {
input.style.borderColor = '#dc3545';
input.title = 'Invalid domain format (e.g., example.com)';
if (window.notificationSystem) {
//console.log('Showing invalid domain format notification');
window.notificationSystem.error('Invalid domain format: "' + domainValue + '". Please use a valid domain like example.com');
} else {
console.error('Invalid domain format - notification system not available');
}
return true;
}
return false;
}
// Separate function to check for duplicates that always shows notifications
function checkForDuplicateDomain(input, domainValue) {
if (!domainValue) return false;
const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]');
const duplicates = Array.from(allDomainInputs).filter(otherInput =>
otherInput !== input && otherInput.value.trim() === domainValue
);
const hasDuplicate = duplicates.length > 0;
if (hasDuplicate) {
input.style.borderColor = '#dc3545';
input.title = 'Domain already exists';
if (window.notificationSystem) {
//console.log('Showing duplicate domain notification');
window.notificationSystem.error('Domain "' + domainValue + '" already exists');
} else {
console.error('Domain already exists - notification system not available');
}
return true;
}
return false;
}