Files
dashcaddy/status/js/app-selector.js
Sami 52577b11ed Fix 7 frontend security vulnerabilities (4 critical, 3 high)
- Escape all innerHTML assignments with user/external data across 12 JS files
- Upgrade credential encryption: per-value IV, key moved to sessionStorage
- Fix open redirect in TOTP auth via proper URL hostname validation
- Remove sensitive DNS topology data from localStorage cache
- Add security regression test suite (51 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:29:04 -08:00

1071 lines
46 KiB
JavaScript
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.
// App Selector System
(function () {
injectModal('app-selector-modal', `<div id="app-selector-modal" class="weather-modal">
<div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div>
<div style="text-align: center; margin-top: 24px;">
<button id="app-selector-cancel">Cancel</button>
</div>
</div>
</div>`);
injectModal('app-deploy-modal', `<div id="app-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
<h3 id="app-deploy-title">Deploy Application</h3>
<div style="display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 16px;">
<!-- Subdomain/Domain -->
<div>
<label for="deploy-subdomain" class="form-label-accent-sm">
🌐 Subdomain or Domain
</label>
<input type="text" id="deploy-subdomain" placeholder="uptime"
style="width: 100%; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.95rem;" />
<div class="form-hint-sm">
Your app will be available at: <span id="deploy-url-preview" style="color: var(--accent); font-weight: 500;">uptime.home</span>
</div>
<div id="subpath-compat-warning" style="display: none; padding: 8px 12px; border-radius: 6px; background: color-mix(in srgb, #ff9800 10%, var(--card-bg)); border: 1px solid color-mix(in srgb, #ff9800 30%, transparent); margin-top: 8px; font-size: 0.85rem;"></div>
</div>
<!-- DNS Configuration -->
<div>
<label class="form-label-accent">
🗂️ DNS Configuration
</label>
<div class="flex-col-gap">
<label class="radio-option">
<input type="radio" name="dns-type" value="private" checked style="margin-right: 10px;" />
<div>
<div class="fw-500">Private DNS (Technitium)</div>
<div class="text-hint">Use your local DNS server with custom TLD (.sami, .home, etc.)</div>
</div>
</label>
<label class="radio-option">
<input type="radio" name="dns-type" value="public" style="margin-right: 10px;" />
<div>
<div class="fw-500">Public DNS</div>
<div class="text-hint">Use a real domain (example.com) - requires DNS provider setup</div>
</div>
</label>
<label class="radio-option">
<input type="radio" name="dns-type" value="none" style="margin-right: 10px;" />
<div>
<div class="fw-500">No DNS (IP:Port only)</div>
<div class="text-hint">Access via IP address and port - no domain setup</div>
</div>
</label>
</div>
</div>
<!-- SSL Configuration -->
<div>
<label class="form-label-accent">
🔒 SSL/TLS Certificate
</label>
<div class="flex-col-gap">
<label class="radio-option">
<input type="radio" name="ssl-type" value="internal" checked style="margin-right: 10px;" />
<div>
<div class="fw-500">Internal CA</div>
<div class="text-hint">Use Caddy's internal certificate authority (self-signed)</div>
</div>
</label>
<label class="radio-option">
<input type="radio" name="ssl-type" value="letsencrypt" style="margin-right: 10px;" />
<div>
<div class="fw-500">Let's Encrypt</div>
<div class="text-hint">Free public SSL certificate (requires public domain)</div>
</div>
</label>
<label class="radio-option">
<input type="radio" name="ssl-type" value="none" style="margin-right: 10px;" />
<div>
<div class="fw-500">No SSL (HTTP only)</div>
<div class="text-hint">⚠️ Not recommended - traffic will be unencrypted</div>
</div>
</label>
</div>
</div>
<!-- Media Library Path (shown for media apps) -->
<div id="media-path-section" style="display: none;">
<label class="form-label-accent">
📁 Media Library Location
</label>
<div style="display: flex; flex-direction: column; gap: 10px;">
<!-- Detected mounts from existing media servers -->
<div id="detected-mounts-container" style="display: none;">
<div style="font-size: 0.85rem; color: var(--success); margin-bottom: 6px;">
✓ Detected from existing media servers:
</div>
<div id="detected-mounts-list" style="display: flex; gap: 8px; flex-wrap: wrap;"></div>
</div>
<!-- Path input with browse button -->
<div class="flex-row-gap">
<input type="text" id="deploy-media-path" placeholder="E:/Movies, E:/TVShows"
class="input-flex" style="font-size: 0.95rem;" />
<button type="button" id="browse-media-btn" style="padding: 10px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; white-space: nowrap;">
📂 Browse
</button>
</div>
<div id="media-path-description" class="text-hint">
Select folders from existing servers, browse, or type paths manually. Separate multiple with commas.
</div>
<!-- Selected paths display -->
<div id="selected-media-paths" style="display: none; flex-wrap: wrap; gap: 6px;"></div>
</div>
</div>
<!-- Plex Claim Token (shown only for Plex deployments) -->
<div id="plex-claim-section" style="display: none;">
<label class="form-label-accent">
🔑 Plex Claim Token
</label>
<div class="flex-row-gap">
<input type="text" id="deploy-plex-claim" placeholder="claim-xxxxxxxxxxxxxxxxxxxx"
class="input-flex" style="font-size: 0.95rem;" />
<a href="https://plex.tv/claim" target="_blank" rel="noopener noreferrer"
style="padding: 10px 16px; background: #e5a00d; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; white-space: nowrap; text-decoration: none; display: flex; align-items: center;">
Get Token
</a>
</div>
<div style="font-size: 0.8rem; color: #e5a00d; margin-top: 4px;">
Token expires in 4 minutes! Get it right before clicking Deploy. Leave empty to configure Plex manually later.
</div>
</div>
<!-- Tailscale Security -->
<div id="tailscale-section">
<label class="form-label-accent">
🔐 Tailscale Security
</label>
<div class="flex-col-gap">
<label class="radio-option">
<input type="checkbox" id="deploy-tailscale-only" style="margin-right: 10px; width: 18px; height: 18px;" />
<div>
<div class="fw-500">Tailscale-Only Access</div>
<div class="text-hint">Restrict this service to Tailscale users only (100.x.x.x IPs)</div>
</div>
</label>
<div id="tailscale-status" style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
<span style="color: var(--muted);">Checking Tailscale status...</span>
</div>
</div>
</div>
<!-- Advanced Options (Collapsible) -->
<details>
<summary style="cursor: pointer; color: var(--accent); font-weight: 500; margin-bottom: 8px;">⚙️ Advanced Options</summary>
<div style="margin-top: 12px; display: grid; gap: 12px;">
<div>
<label for="deploy-port" style="display: block; margin-bottom: 6px;">Custom Port (optional)</label>
<input type="number" id="deploy-port" placeholder="Leave empty for default"
class="form-input-card" />
</div>
<div>
<label for="deploy-ip" style="display: block; margin-bottom: 6px;">Target IP Address</label>
<input type="text" id="deploy-ip" value="localhost"
class="form-input-card" />
<div class="form-hint-sm">
Use 'localhost' for same-host containers, or specific IP for remote services
</div>
</div>
<div id="volume-mounts-section" style="display: none;">
<label class="form-label-accent-sm">📂 Volume Mounts</label>
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
Customize where container data is stored on the host. Media volumes are configured above.
</div>
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
</div>
</div>
</details>
</div>
<div class="weather-modal-buttons" style="margin-top: 24px;">
<button id="app-deploy-cancel">Cancel</button>
<button id="app-deploy-confirm" class="btn-accent">
🚀 Deploy
</button>
</div>
</div>
</div>`);
const APPS_KEY = 'custom-apps';
// Cache for API templates
let apiTemplates = null;
let apiCategories = null;
const modal = document.getElementById('app-selector-modal');
const grid = document.getElementById('app-selector-grid');
// Fetch app templates from API
async function fetchAppTemplates() {
try {
const response = await fetch('/api/v1/apps/templates');
const data = await response.json();
if (data.success) {
apiTemplates = data.templates;
apiCategories = data.categories;
return true;
}
} catch (e) {
console.error('Failed to fetch app templates:', e);
}
return false;
}
// Check port availability
async function checkPortAvailability(port) {
try {
const response = await fetch(`/api/v1/apps/ports/${port}/check`);
const data = await response.json();
return data;
} catch (e) {
console.error('Failed to check port:', e);
return { available: true }; // Assume available on error
}
}
// Get suggested available port
async function getSuggestedPort(basePort) {
try {
const response = await fetch(`/api/v1/apps/ports/${basePort}/suggest`);
const data = await response.json();
if (data.success) {
return data.suggestedPort;
}
} catch (e) {
console.error('Failed to get suggested port:', e);
}
return basePort;
}
// Build app selector grid from API templates
async function buildAppSelector() {
grid.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>';
// Fetch templates if not cached
if (!apiTemplates) {
const success = await fetchAppTemplates();
if (!success) {
grid.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';
return;
}
}
grid.innerHTML = '';
// Group templates by category
const byCategory = {};
for (const [appId, template] of Object.entries(apiTemplates)) {
const category = template.category || 'Other';
if (!byCategory[category]) {
byCategory[category] = [];
}
byCategory[category].push({ id: appId, ...template });
}
// Sort categories by the order in apiCategories if available
const categoryOrder = apiCategories ? Object.keys(apiCategories) : Object.keys(byCategory).sort();
for (const category of categoryOrder) {
const apps = byCategory[category];
if (!apps || apps.length === 0) continue;
// Sort apps by popularity (descending)
apps.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
// Category header with icon and color from API
const header = document.createElement('div');
header.className = 'app-category-header';
const categoryInfo = apiCategories?.[category] || {};
header.innerHTML = `${escapeHtml(categoryInfo.icon || '')} ${escapeHtml(category)}`;
if (categoryInfo.color) {
header.style.borderBottomColor = categoryInfo.color;
}
grid.appendChild(header);
// App options
apps.forEach(app => {
const option = document.createElement('div');
option.className = 'app-option';
// Widget enabled badge
const isWidget = app.isDashboardWidget;
const widgetEnabled = isWidget && safeGet('widget-' + app.id + '-enabled') !== 'false';
const widgetBadge = isWidget
? `<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${widgetEnabled ? '#2ecc7130' : '#e74c3c30'}; color: ${widgetEnabled ? '#2ecc71' : '#e74c3c'}; font-weight: 600;">${widgetEnabled ? 'ON' : 'OFF'}</div>`
: '';
// Show difficulty badge (non-widgets only)
const difficultyBadge = !isWidget && app.difficulty ?
`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${
app.difficulty === 'Easy' ? '#2ecc71' :
app.difficulty === 'Intermediate' ? '#f39c12' : '#e74c3c'
}20; color: ${
app.difficulty === 'Easy' ? '#2ecc71' :
app.difficulty === 'Intermediate' ? '#f39c12' : '#e74c3c'
};">${escapeHtml(app.difficulty)}</div>` : '';
option.innerHTML = `
<div class="app-option-icon">${escapeHtml(app.icon || '📦')}</div>
<div class="app-option-name">${escapeHtml(app.name)}</div>
<div class="app-option-desc">${escapeHtml(app.description || '')}</div>
${widgetBadge}${difficultyBadge}
`;
if (isWidget) {
option.onclick = () => toggleDashboardWidget(app, option);
} else {
option.onclick = () => showDeployConfig(app);
}
grid.appendChild(option);
});
}
// Render recipe cards at the end of the grid
if (window.renderRecipeCards) {
await window.renderRecipeCards(grid);
}
}
// Toggle a dashboard widget on/off
function toggleDashboardWidget(app, optionEl) {
const key = 'widget-' + app.id + '-enabled';
const currentlyEnabled = safeGet(key) !== 'false';
const newState = !currentlyEnabled;
safeSet(key, String(newState));
// Update visibility immediately
const selector = app.widgetSelector;
if (selector) {
const el = document.querySelector(selector);
if (el) el.style.display = newState ? '' : 'none';
}
// Update the badge in the app selector card
const badge = optionEl.querySelector('div[style*="border-radius: 4px"]');
if (badge) {
badge.textContent = newState ? 'ON' : 'OFF';
badge.style.background = newState ? '#2ecc7130' : '#e74c3c30';
badge.style.color = newState ? '#2ecc71' : '#e74c3c';
}
showNotification(`${app.name} widget ${newState ? 'enabled' : 'disabled'}`, 'success', 2000);
}
// Show deployment configuration modal
async function showDeployConfig(appTemplate) {
const deployModal = document.getElementById('app-deploy-modal');
const title = document.getElementById('app-deploy-title');
const subdomainInput = document.getElementById('deploy-subdomain');
const urlPreview = document.getElementById('deploy-url-preview');
const ipInput = document.getElementById('deploy-ip');
const portInput = document.getElementById('deploy-port');
const tailscaleCheckbox = document.getElementById('deploy-tailscale-only');
const tailscaleStatus = document.getElementById('tailscale-status');
// Check for existing container with same image
try {
const checkResponse = await secureFetch('/api/v1/apps/check-existing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId: appTemplate.id })
});
const checkResult = await checkResponse.json();
if (checkResult.success && checkResult.exists) {
const container = checkResult.container;
const useExisting = confirm(
`Found existing ${appTemplate.name} container:\n\n` +
`Container: ${container.name}\n` +
`Status: ${container.status}\n` +
`Port: ${container.primaryPort || 'N/A'}\n\n` +
`Would you like to use this existing container?\n\n` +
`Click OK to configure DNS/Caddy for the existing container.\n` +
`Click Cancel to deploy a new container.`
);
if (useExisting) {
// Store existing container info for deployment
appTemplate._useExisting = true;
appTemplate._existingContainer = container;
}
}
} catch (e) {
// Ignore container check errors
}
// Set title with app info
title.textContent = `Deploy ${appTemplate.name}`;
// Pre-fill subdomain from template or app ID
const defaultSubdomain = appTemplate.subdomain || appTemplate.id.replace(/-/g, '');
subdomainInput.value = defaultSubdomain;
// Show subpath compatibility warning in subdirectory mode
const subpathWarning = document.getElementById('subpath-compat-warning');
if (subpathWarning) {
if (SITE.routingMode === 'subdirectory') {
const support = appTemplate.subpathSupport || 'strip';
if (support === 'none') {
subpathWarning.style.display = 'block';
subpathWarning.innerHTML = '<span style="color: #ff9800;">&#9888; <strong>' + appTemplate.name + '</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>';
} else if (support === 'strip') {
subpathWarning.style.display = 'block';
subpathWarning.innerHTML = '<span style="color: var(--muted);">&#9432; ' + appTemplate.name + ' has unverified subdirectory support. It may require additional configuration.</span>';
} else {
subpathWarning.style.display = 'none';
}
} else {
subpathWarning.style.display = 'none';
}
}
// Pre-select DNS/SSL from site config (set during setup wizard)
const cfgDnsType = SITE.defaults.dnsType || (SITE.configurationType === 'public' ? 'public' : 'private');
const cfgSslType = SITE.defaults.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'internal');
const dnsRadio = document.querySelector(`input[name="dns-type"][value="${cfgDnsType}"]`);
const sslRadio = document.querySelector(`input[name="ssl-type"][value="${cfgSslType}"]`);
if (dnsRadio) dnsRadio.checked = true;
else document.querySelector('input[name="dns-type"][value="private"]').checked = true;
if (sslRadio) sslRadio.checked = true;
else document.querySelector('input[name="ssl-type"][value="internal"]').checked = true;
ipInput.value = SITE.defaults.targetIP || 'localhost';
tailscaleCheckbox.checked = false;
// Move DNS/SSL into Advanced section when already configured
const dnsSection = document.querySelector('#app-deploy-modal .flex-col-gap')?.closest('div');
const advancedDetails = document.querySelector('#app-deploy-modal details');
const advancedContent = advancedDetails?.querySelector('div');
if (advancedDetails && advancedContent && (SITE.configurationType === 'public' || SITE.configurationType === 'homelab')) {
// Move DNS and SSL sections inside Advanced
const dnsSectionEl = document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest('div.flex-col-gap')?.parentElement;
const sslSectionEl = document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest('div.flex-col-gap')?.parentElement;
if (dnsSectionEl && !dnsSectionEl.dataset.moved) {
advancedContent.appendChild(dnsSectionEl);
dnsSectionEl.dataset.moved = '1';
}
if (sslSectionEl && !sslSectionEl.dataset.moved) {
advancedContent.appendChild(sslSectionEl);
sslSectionEl.dataset.moved = '1';
}
}
// Handle media path section for media apps
const mediaPathSection = document.getElementById('media-path-section');
const mediaPathInput = document.getElementById('deploy-media-path');
const mediaPathDescription = document.getElementById('media-path-description');
if (appTemplate.mediaMount) {
mediaPathSection.style.display = 'block';
mediaPathInput.value = '';
mediaPathInput.placeholder = 'E:/Movies, E:/TVShows or click Browse';
// Fetch detected mounts from existing media servers
const detectedMountsContainer = document.getElementById('detected-mounts-container');
const detectedMountsList = document.getElementById('detected-mounts-list');
try {
const mountsResponse = await fetch('/api/v1/media/detected-mounts');
const mountsResult = await mountsResponse.json();
if (mountsResult.success && mountsResult.mounts.length > 0) {
detectedMountsContainer.style.display = 'block';
detectedMountsList.innerHTML = '';
// Auto-fill media path with all detected mounts
const autoPaths = [...new Set(mountsResult.mounts.map(m => m.hostPath))];
mediaPathInput.value = autoPaths.join(', ');
mountsResult.mounts.forEach(mount => {
const btn = document.createElement('button');
btn.type = 'button';
const isSelected = autoPaths.includes(mount.hostPath);
btn.style.cssText = `padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${isSelected ? '40%' : '15%'}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`;
btn.innerHTML = `<span style="font-weight: 500;">${escapeHtml(mount.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(mount.sourceImage)}</span>`;
btn.title = `${mount.hostPath} (from ${mount.sourceContainer})`;
btn.onclick = () => {
const currentPaths = mediaPathInput.value.split(',').map(p => p.trim()).filter(p => p);
const idx = currentPaths.indexOf(mount.hostPath);
if (idx >= 0) {
currentPaths.splice(idx, 1);
btn.style.background = 'color-mix(in srgb, var(--success) 15%, var(--card-bg))';
} else {
currentPaths.push(mount.hostPath);
btn.style.background = 'color-mix(in srgb, var(--success) 40%, var(--card-bg))';
}
mediaPathInput.value = currentPaths.join(', ');
};
detectedMountsList.appendChild(btn);
});
} else {
detectedMountsContainer.style.display = 'none';
}
} catch (e) {
detectedMountsContainer.style.display = 'none';
}
// Set up browse button
document.getElementById('browse-media-btn').onclick = () => {
openFolderBrowser(mediaPathInput);
};
} else {
mediaPathSection.style.display = 'none';
mediaPathInput.value = '';
document.getElementById('detected-mounts-container').style.display = 'none';
}
// Show Plex claim token section for Plex deployments
const plexClaimSection = document.getElementById('plex-claim-section');
if (plexClaimSection) {
if (appTemplate.id === 'plex' || appTemplate.claimToken) {
plexClaimSection.style.display = 'block';
document.getElementById('deploy-plex-claim').value = '';
} else {
plexClaimSection.style.display = 'none';
}
}
// Populate volume mounts in Advanced Options
const volumeSection = document.getElementById('volume-mounts-section');
const volumeList = document.getElementById('volume-mounts-list');
volumeList.innerHTML = '';
if (appTemplate.docker?.volumes?.length) {
const mediaContainerPath = appTemplate.mediaMount?.containerPath;
const nonMediaVolumes = appTemplate.docker.volumes.filter(v => !v.includes('{{MEDIA_PATH}}') && !(mediaContainerPath && v.endsWith(':' + mediaContainerPath)));
if (nonMediaVolumes.length > 0) {
volumeSection.style.display = 'block';
nonMediaVolumes.forEach((vol, i) => {
const [hostDefault, containerPath] = vol.split(':');
const row = document.createElement('div');
row.style.cssText = 'display: flex; gap: 6px; align-items: center;';
row.innerHTML = `
<input type="text" class="vol-host-path" data-container-path="${containerPath}" value="${hostDefault}"
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">→ ${containerPath}</span>
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">📂</button>
`;
volumeList.appendChild(row);
row.querySelector('.vol-browse-btn').onclick = () => {
const input = row.querySelector('.vol-host-path');
openFolderBrowser(input);
};
});
} else {
volumeSection.style.display = 'none';
}
} else {
volumeSection.style.display = 'none';
}
// Set default port from template and check availability
const defaultPort = appTemplate.defaultPort || 8080;
portInput.value = '';
portInput.placeholder = `Default: ${defaultPort}`;
// Add port status element if not exists
let portStatus = document.getElementById('deploy-port-status');
if (!portStatus) {
portStatus = document.createElement('div');
portStatus.id = 'deploy-port-status';
portStatus.style.cssText = 'font-size: 0.8rem; margin-top: 4px;';
portInput.parentNode.appendChild(portStatus);
}
// Check default port availability
async function checkAndUpdatePortStatus() {
const portToCheck = portInput.value || defaultPort;
portStatus.innerHTML = '<span style="color: var(--muted);">Checking port...</span>';
const result = await checkPortAvailability(portToCheck);
if (result.available) {
portStatus.innerHTML = `<span style="color: #4caf50;">Port ${portToCheck} is available</span>`;
} else {
const suggestedPort = await getSuggestedPort(defaultPort);
portStatus.innerHTML = `
<span style="color: #e74c3c;">Port ${escapeHtml(portToCheck)} in use by ${escapeHtml(result.conflict?.usedBy || 'unknown')}</span>
`;
const useBtn = document.createElement('button');
useBtn.type = 'button';
useBtn.textContent = `Use ${suggestedPort}`;
useBtn.style.cssText = 'margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;';
useBtn.onclick = () => {
document.getElementById('deploy-port').value = suggestedPort;
portStatus.innerHTML = `<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(suggestedPort))}</span>`;
};
portStatus.appendChild(useBtn);
}
}
// Check port on input change (debounced)
let portCheckTimeout;
portInput.oninput = function() {
clearTimeout(portCheckTimeout);
portCheckTimeout = setTimeout(checkAndUpdatePortStatus, 500);
};
// Initial port check
checkAndUpdatePortStatus();
// Fetch Tailscale status
try {
const response = await fetch('/api/v1/tailscale/status');
const data = await response.json();
if (data.success && data.installed && data.connected) {
tailscaleStatus.innerHTML = `
<span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${data.self?.hostname} (${data.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${data.deviceCount} devices</span>
`;
} else if (data.installed) {
tailscaleStatus.innerHTML = `<span style="color: #ff9800;">Not connected</span>`;
} else {
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Not available</span>`;
tailscaleCheckbox.disabled = true;
}
} catch (e) {
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Could not check status</span>`;
}
// Update URL preview in real-time
function updateUrlPreview() {
const subdomain = subdomainInput.value || 'subdomain';
const dnsType = document.querySelector('input[name="dns-type"]:checked').value;
const sslType = document.querySelector('input[name="ssl-type"]:checked').value;
let url = '';
if (SITE.routingMode === 'subdirectory' && SITE.domain) {
// Subdirectory mode: domain.com/app
url = `https://${SITE.domain}/${subdomain}`;
} else if (dnsType === 'private') {
const protocol = sslType === 'none' ? 'http' : 'https';
url = `${protocol}://${buildDomain(subdomain)}`;
} else if (dnsType === 'public') {
const protocol = sslType === 'none' ? 'http' : 'https';
const domain = SITE.domain || subdomain;
url = SITE.domain ? `${protocol}://${subdomain}.${SITE.domain}` : `${protocol}://${subdomain}`;
} else {
const port = portInput.value || appTemplate.defaultPort || DC.DEFAULTS.SERVICE_PORT;
url = `http://${ipInput.value}:${port}`;
}
urlPreview.textContent = url;
}
// Attach listeners
subdomainInput.oninput = updateUrlPreview;
ipInput.oninput = updateUrlPreview;
portInput.oninput = updateUrlPreview;
document.querySelectorAll('input[name="dns-type"]').forEach(radio => {
radio.onchange = updateUrlPreview;
});
document.querySelectorAll('input[name="ssl-type"]').forEach(radio => {
radio.onchange = updateUrlPreview;
});
updateUrlPreview();
// Close app selector, open deploy config
modal.classList.remove('show');
deployModal.classList.add('show');
// Store app template for deployment
deployModal.dataset.appTemplate = JSON.stringify(appTemplate);
}
// Add app to grid with full Docker deployment
async function addAppToGrid(deployConfig) {
const appTemplate = deployConfig.appTemplate;
const customApps = safeGetJSON(APPS_KEY, []);
// Check if using existing container
const usingExisting = appTemplate._useExisting && appTemplate._existingContainer;
// Check if app already exists - skip if using existing container (user already confirmed)
const existingApp = customApps.find(a => a.id === deployConfig.subdomain);
if (existingApp && !usingExisting) {
const confirmed = confirm(`An app with subdomain "${deployConfig.subdomain}" already exists. Redeploy?`);
if (!confirmed) return;
}
// Remove from localStorage to allow redeployment/update
if (existingApp) {
const index = customApps.indexOf(existingApp);
customApps.splice(index, 1);
safeSet(APPS_KEY, JSON.stringify(customApps));
}
// Check port availability before deployment (skip if using existing container)
if (!usingExisting) {
const portToUse = deployConfig.port || appTemplate.defaultPort || 8080;
showNotification(`Checking port ${portToUse} availability...`, 'info', 0);
const portCheck = await checkPortAvailability(portToUse);
if (!portCheck.available) {
const suggestedPort = await getSuggestedPort(appTemplate.defaultPort || 8080);
const useAlternate = confirm(
`Port ${portToUse} is already in use by ${portCheck.conflict?.usedBy || 'another container'}.\n\n` +
`Would you like to use port ${suggestedPort} instead?`
);
if (useAlternate) {
deployConfig.port = suggestedPort;
} else {
showNotification('Deployment cancelled - port conflict', 'error', 5000);
return;
}
}
} else {
// Use existing container's port
deployConfig.port = appTemplate._existingContainer.primaryPort;
}
showNotification(
usingExisting
? `Configuring ${appTemplate.name} with existing container...`
: `Deploying ${appTemplate.name}...`,
'info', 0
);
try {
// Prepare deployment config from user's choices
const apiDeployConfig = {
appId: appTemplate.id,
config: {
subdomain: deployConfig.subdomain,
ip: deployConfig.ip,
createDns: deployConfig.dnsType === 'private', // Only create DNS for private
port: deployConfig.port || appTemplate.defaultPort || null, // Use custom, template default, or null
sslType: deployConfig.sslType,
dnsType: deployConfig.dnsType,
tailscaleOnly: deployConfig.tailscaleOnly || false, // Tailscale-only access restriction
mediaPath: deployConfig.mediaPath || null, // Media folder path for media apps
plexClaimToken: deployConfig.plexClaimToken || null, // Plex claim token for auto-claim
customVolumes: deployConfig.customVolumes || null // Custom volume mount overrides
}
};
// Add existing container info if using existing
if (usingExisting) {
apiDeployConfig.config.useExisting = true;
apiDeployConfig.config.existingContainerId = appTemplate._existingContainer.id;
apiDeployConfig.config.existingPort = appTemplate._existingContainer.primaryPort;
// Use existing container's port if no custom port specified
if (!deployConfig.port && appTemplate._existingContainer.primaryPort) {
apiDeployConfig.config.port = appTemplate._existingContainer.primaryPort;
}
}
// Call deployment API
const response = await secureFetch('/api/v1/apps/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(apiDeployConfig)
});
const result = await response.json();
if (result.success) {
// Add to saved apps (store IP for later deletion)
const newApp = {
id: deployConfig.subdomain, // Use subdomain as ID
name: appTemplate.name,
logo: `/assets/${appTemplate.id}.png`,
containerId: result.containerId,
url: result.url,
ip: deployConfig.ip, // Store IP for DNS record deletion
appTemplate: appTemplate.id, // Store original template ID
tailscaleOnly: deployConfig.tailscaleOnly || false // Tailscale protection status
};
customApps.push(newApp);
safeSet(APPS_KEY, JSON.stringify(customApps));
// Add to APPS array and rebuild grid
// Access APPS from parent scope via window
if (window.APPS && !window.APPS.some(a => a.id === appTemplate.id)) {
window.APPS.push(newApp);
if (typeof window.buildGrid === 'function') {
window.buildGrid();
}
if (typeof window.refreshAll === 'function') {
setTimeout(() => window.refreshAll(), 500);
}
}
// Show success with URL (and warning if DNS failed)
let message = result.usedExisting
? `${appTemplate.name} configured with existing container!\nURL: ${result.url}`
: `${appTemplate.name} deployed successfully!\nURL: ${result.url}`;
if (result.warning) {
message += `\n\n⚠ Warning: ${result.warning}`;
}
showNotification(message, 'success', 8000);
// Clean up temporary properties
delete appTemplate._useExisting;
delete appTemplate._existingContainer;
// For HTTPS URLs, check SSL certificate status
if (result.url && result.url.startsWith('https://')) {
checkSSLCertificate(result.url, appTemplate.name);
}
// Show setup instructions if available
if (result.setupInstructions && result.setupInstructions.length > 0) {
setTimeout(() => {
const instructions = result.setupInstructions.join('\n');
showNotification(`Setup Instructions for ${appTemplate.name}: ${instructions}`, 'info', 10000);
}, 1000);
}
} else {
throw new Error(result.error || 'Deployment failed');
}
} catch (error) {
console.error('Deployment error:', error);
showNotification(
`Failed to deploy ${appTemplate.name}: ${error.message}`,
'error',
8000
);
}
}
// Check SSL certificate status and notify when ready
async function checkSSLCertificate(url, appName) {
showNotification(`⏳ Generating SSL certificate for ${appName}...`, 'warning', 60000);
let attempts = 0;
const maxAttempts = 12; // 60 seconds total (5 second intervals)
const checkCert = async () => {
attempts++;
try {
// Try to fetch the URL - if SSL works, this will succeed
const response = await fetch(url, {
method: 'HEAD',
mode: 'no-cors' // Avoid CORS issues
});
// If we get here, SSL is working
showNotification(`${appName} is ready! SSL certificate generated.`, 'success', 5000);
return true;
} catch (error) {
// SSL not ready yet
if (attempts < maxAttempts) {
setTimeout(checkCert, 5000); // Check again in 5 seconds
} else {
showNotification(
`⚠️ ${appName} deployed but SSL certificate may still be generating.\nTry refreshing in a moment if you see a certificate error.`,
'warning',
10000
);
}
return false;
}
};
// Start checking after 3 seconds (give Caddy time to start)
setTimeout(checkCert, 3000);
}
// Load custom apps on startup
function loadCustomApps() {
const customApps = safeGetJSON(APPS_KEY, []);
customApps.forEach(app => {
if (!window.APPS.some(a => a.id === app.id)) {
window.APPS.push(app);
}
});
}
// Event listeners
document.getElementById('add-service-btn')?.addEventListener('click', () => {
buildAppSelector();
modal.classList.add('show');
});
wireModal(modal, document.getElementById('app-selector-cancel'));
// Deploy modal event listeners
const deployModal = document.getElementById('app-deploy-modal');
document.getElementById('app-deploy-cancel')?.addEventListener('click', () => {
deployModal.classList.remove('show');
});
document.getElementById('app-deploy-confirm')?.addEventListener('click', () => {
// Get user configuration
const appTemplate = JSON.parse(deployModal.dataset.appTemplate);
const mediaPath = document.getElementById('deploy-media-path').value.trim();
// Collect custom volume overrides
const customVolumes = [];
document.querySelectorAll('#volume-mounts-list .vol-host-path').forEach(input => {
customVolumes.push({ hostPath: input.value.trim(), containerPath: input.dataset.containerPath });
});
const deployConfig = {
appTemplate: appTemplate,
subdomain: document.getElementById('deploy-subdomain').value.trim(),
dnsType: document.querySelector('input[name="dns-type"]:checked').value,
sslType: document.querySelector('input[name="ssl-type"]:checked').value,
ip: document.getElementById('deploy-ip').value.trim(),
port: document.getElementById('deploy-port').value.trim(),
tailscaleOnly: document.getElementById('deploy-tailscale-only').checked,
mediaPath: mediaPath || null,
plexClaimToken: document.getElementById('deploy-plex-claim')?.value.trim() || null,
customVolumes: customVolumes.length > 0 ? customVolumes : null
};
// Validate subdomain
if (!deployConfig.subdomain) {
showNotification('Please enter a subdomain or domain name', 'warning');
return;
}
// Validate media path for media apps
if (appTemplate.mediaMount?.required && !mediaPath) {
showNotification('Please enter a media library path for this application', 'warning');
return;
}
// Close deploy modal
deployModal.classList.remove('show');
// Start deployment
addAppToGrid(deployConfig);
});
wireModal(deployModal);
// ===== FOLDER BROWSER FUNCTIONALITY =====
const folderBrowserModal = document.getElementById('folder-browser-modal');
const folderBrowserPath = document.getElementById('folder-browser-path');
const folderBrowserList = document.getElementById('folder-browser-list');
const folderBrowserSelected = document.getElementById('folder-browser-selected');
const folderBrowserSelectedList = document.getElementById('folder-browser-selected-list');
let currentBrowserPath = '';
let selectedFolders = [];
let targetMediaInput = null;
window.openFolderBrowser = function(mediaInput) {
targetMediaInput = mediaInput;
selectedFolders = mediaInput.value.split(',').map(p => p.trim()).filter(p => p);
currentBrowserPath = '';
updateSelectedFoldersDisplay();
loadFolderContents('');
folderBrowserModal.classList.add('show');
};
async function loadFolderContents(path) {
folderBrowserPath.textContent = path || 'Select a drive...';
folderBrowserList.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';
try {
const response = await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(path)}`);
const result = await response.json();
if (!result.success) {
folderBrowserList.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(result.error)}</div>`;
return;
}
currentBrowserPath = result.path || '';
folderBrowserPath.textContent = currentBrowserPath || 'Select a drive...';
let html = '';
// Add parent folder navigation
if (result.parent && result.parent !== result.path) {
html += `<div class="folder-item" data-path="${escapeHtml(result.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">⬆️</span>
<span style="color: var(--muted);">.. Parent Directory</span>
</div>`;
}
// Add folders
if (result.items.length === 0 && !result.parent) {
html += '<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>';
} else if (result.items.length === 0) {
html += '<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>';
} else {
result.items.forEach(item => {
const icon = item.type === 'drive' ? '💾' : '📁';
const isSelected = selectedFolders.includes(item.path);
const selectedStyle = isSelected ? 'background: color-mix(in srgb, var(--success) 20%, transparent);' : '';
html += `<div class="folder-item" data-path="${escapeHtml(item.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${selectedStyle}">
<span style="font-size: 1.2rem;">${icon}</span>
<span style="flex: 1;">${escapeHtml(item.name)}</span>
${isSelected ? '<span style="color: var(--success);">✓</span>' : ''}
</div>`;
});
}
folderBrowserList.innerHTML = html;
// Add click handlers
folderBrowserList.querySelectorAll('.folder-item').forEach(item => {
item.addEventListener('click', () => {
loadFolderContents(item.dataset.path);
});
item.addEventListener('mouseenter', () => {
item.style.background = 'var(--card-bg)';
});
item.addEventListener('mouseleave', () => {
const isSelected = selectedFolders.includes(item.dataset.path);
item.style.background = isSelected ? 'color-mix(in srgb, var(--success) 20%, transparent)' : '';
});
});
} catch (error) {
folderBrowserList.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(error.message)}</div>`;
}
}
function updateSelectedFoldersDisplay() {
if (selectedFolders.length === 0) {
folderBrowserSelected.style.display = 'none';
return;
}
folderBrowserSelected.style.display = 'block';
folderBrowserSelectedList.innerHTML = selectedFolders.map(path => `
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
${escapeHtml(path)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(path)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">×</button>
</span>
`).join('');
}
window.removeSelectedFolder = function(path) {
selectedFolders = selectedFolders.filter(p => p !== path);
updateSelectedFoldersDisplay();
loadFolderContents(currentBrowserPath); // Refresh to update checkmarks
};
document.getElementById('folder-browser-select-current').addEventListener('click', () => {
if (currentBrowserPath && !selectedFolders.includes(currentBrowserPath)) {
selectedFolders.push(currentBrowserPath);
updateSelectedFoldersDisplay();
loadFolderContents(currentBrowserPath); // Refresh to show checkmark
}
});
wireModal(folderBrowserModal, document.getElementById('folder-browser-cancel'));
document.getElementById('folder-browser-done').addEventListener('click', () => {
if (targetMediaInput) {
targetMediaInput.value = selectedFolders.join(', ');
}
folderBrowserModal.classList.remove('show');
});
// Load custom apps on page load
loadCustomApps();
})();