Add subdirectory routing mode for public domain deployments
Apps can now be served at domain.com/appname/ instead of requiring subdomain DNS records (appname.domain.com). Supports three subpath modes per template: native (URL base env var), strip (handle_path), and none (incompatible warning). Tested on Linux with deploy/removal lifecycle verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -451,13 +451,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label-accent">
|
||||
🔀 App Routing Mode
|
||||
</label>
|
||||
<div style="display: grid; gap: 10px; margin-top: 8px;">
|
||||
<label style="display: flex; align-items: start; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
|
||||
<input type="radio" name="routing-mode" value="subdirectory" checked style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong>Subdirectory</strong> <span style="color: var(--muted);">(example.com/app)</span>
|
||||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 2px;">
|
||||
Only needs a single DNS record. Recommended for most setups.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label style="display: flex; align-items: start; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
|
||||
<input type="radio" name="routing-mode" value="subdomain" style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong>Subdomain</strong> <span style="color: var(--muted);">(app.example.com)</span>
|
||||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 2px;">
|
||||
Requires wildcard DNS (*.example.com) or per-app DNS records
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 8px; border: 1px solid var(--accent);">
|
||||
<h4 class="heading-accent-md">⚠️ Requirements:</h4>
|
||||
<ul style="margin: 0; padding-left: 20px; font-size: 0.9rem; line-height: 1.6;">
|
||||
<li>Port 80 and 443 must be open to the internet</li>
|
||||
<li>Domain DNS must point to this server's public IP</li>
|
||||
<li>Server must be reachable from the internet</li>
|
||||
<li>You'll need to configure DNS manually for each subdomain</li>
|
||||
<li id="dns-requirement-note">Only one DNS record needed (for the main domain)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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 -->
|
||||
@@ -407,6 +408,25 @@
|
||||
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;">⚠ <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);">ⓘ ' + 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');
|
||||
@@ -622,7 +642,10 @@
|
||||
const sslType = document.querySelector('input[name="ssl-type"]:checked').value;
|
||||
|
||||
let url = '';
|
||||
if (dnsType === 'private') {
|
||||
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') {
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function serviceUrl(id) { return `https://${buildDomain(id)}`; }
|
||||
function serviceUrl(id) { return buildServiceUrl(id); }
|
||||
function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; }
|
||||
|
||||
function buildGrid() {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`;
|
||||
|
||||
const urlPreview = document.getElementById('url-preview');
|
||||
if (urlPreview) urlPreview.textContent = `https://${buildDomain(subdomain)}`;
|
||||
if (urlPreview) urlPreview.textContent = buildServiceUrl(subdomain);
|
||||
|
||||
const config = {
|
||||
subdomain, port, ip, sslType, caName, existingCa,
|
||||
@@ -534,7 +534,7 @@
|
||||
`Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`,
|
||||
`Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}`
|
||||
];
|
||||
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 https://${buildDomain(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
|
||||
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 ${buildServiceUrl(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
|
||||
|
||||
closeAddServiceModal();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
document.getElementById('service-edit-title').textContent = `Edit ${service.name}`;
|
||||
document.getElementById('edit-service-name-display').textContent = service.name;
|
||||
document.getElementById('edit-service-url-display').textContent = `https://${buildDomain(service.id)}`;
|
||||
document.getElementById('edit-service-url-display').textContent = service.url || buildServiceUrl(service.id);
|
||||
document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`;
|
||||
document.getElementById('edit-subdomain').value = service.id;
|
||||
document.getElementById('edit-port').value = service.port || '';
|
||||
|
||||
@@ -33,7 +33,8 @@ const SITE = {
|
||||
dnsServers: (_cachedCfg && _cachedCfg.dnsServers) || {},
|
||||
configurationType: (_cachedCfg && _cachedCfg.configurationType) || 'homelab',
|
||||
domain: (_cachedCfg && _cachedCfg.domain) || '',
|
||||
defaults: (_cachedCfg && _cachedCfg.defaults) || {}
|
||||
defaults: (_cachedCfg && _cachedCfg.defaults) || {},
|
||||
routingMode: (_cachedCfg && _cachedCfg.routingMode) || 'subdomain'
|
||||
};
|
||||
(async function loadSiteConfig() {
|
||||
try {
|
||||
@@ -51,10 +52,12 @@ const SITE = {
|
||||
if (c.configurationType) SITE.configurationType = c.configurationType;
|
||||
if (c.domain) SITE.domain = c.domain;
|
||||
if (c.defaults) SITE.defaults = c.defaults;
|
||||
if (c.routingMode) SITE.routingMode = c.routingMode;
|
||||
// Cache config so next page load uses correct TLD even if API is slow
|
||||
localStorage.setItem('dashcaddy_site_config', JSON.stringify({
|
||||
tld: SITE.tld, dnsIp: SITE.dnsIp, dnsPort: SITE.dnsPort, dnsServers: SITE.dnsServers,
|
||||
configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults
|
||||
configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults,
|
||||
routingMode: SITE.routingMode
|
||||
}));
|
||||
// Render DNS cards dynamically based on configured servers
|
||||
renderDnsCards();
|
||||
@@ -68,6 +71,11 @@ const SITE = {
|
||||
if (proxyIpInput && SITE.dnsIp) { proxyIpInput.value = SITE.dnsIp; proxyIpInput.placeholder = SITE.dnsIp; }
|
||||
})();
|
||||
function buildDomain(sub) { return sub + SITE.tld; }
|
||||
function buildServiceUrl(sub) {
|
||||
if (SITE.routingMode === 'subdirectory' && SITE.domain) return 'https://' + SITE.domain + '/' + sub;
|
||||
if (SITE.configurationType === 'public' && SITE.domain) return 'https://' + sub + '.' + SITE.domain;
|
||||
return 'https://' + buildDomain(sub);
|
||||
}
|
||||
function getDnsServerAddr(dnsId) {
|
||||
const s = SITE.dnsServers[dnsId];
|
||||
return s ? `${s.ip}:${s.port}` : buildDomain(dnsId);
|
||||
|
||||
@@ -104,6 +104,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
} else if (currentConfigType === 'public') {
|
||||
const domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
|
||||
const email = document.getElementById('setup-public-email')?.value?.trim() || '';
|
||||
const routingMode = document.querySelector('input[name="routing-mode"]:checked')?.value || 'subdirectory';
|
||||
const exampleUrls = routingMode === 'subdirectory'
|
||||
? `https://${domain}/sonarr, https://${domain}/grafana`
|
||||
: `https://sonarr.${domain}, https://grafana.${domain}`;
|
||||
const routingLabel = routingMode === 'subdirectory' ? 'Subdirectory (domain.com/app)' : 'Subdomain (app.domain.com)';
|
||||
|
||||
html += `
|
||||
<div>
|
||||
@@ -112,7 +117,8 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
<div><strong>Domain:</strong> ${domain}</div>
|
||||
<div><strong>SSL:</strong> Let's Encrypt</div>
|
||||
<div><strong>Email:</strong> ${email}</div>
|
||||
<div><strong>Example URLs:</strong> https://app.${domain}, https://cloud.${domain}</div>
|
||||
<div><strong>Routing:</strong> ${routingLabel}</div>
|
||||
<div><strong>Example URLs:</strong> ${exampleUrls}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -186,8 +192,9 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
} else if (currentConfigType === 'public') {
|
||||
config.domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
|
||||
config.email = document.getElementById('setup-public-email')?.value?.trim() || '';
|
||||
config.routingMode = document.querySelector('input[name="routing-mode"]:checked')?.value || 'subdirectory';
|
||||
config.defaults = {
|
||||
dnsType: 'public',
|
||||
dnsType: config.routingMode === 'subdirectory' ? 'none' : 'public',
|
||||
sslType: 'letsencrypt',
|
||||
targetIP: 'localhost'
|
||||
};
|
||||
@@ -311,6 +318,18 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
};
|
||||
}
|
||||
|
||||
// Public routing mode toggle — update requirement text
|
||||
document.querySelectorAll('input[name="routing-mode"]').forEach(function(radio) {
|
||||
radio.onchange = function() {
|
||||
var note = document.getElementById('dns-requirement-note');
|
||||
if (note) {
|
||||
note.textContent = this.value === 'subdirectory'
|
||||
? 'Only one DNS record needed (for the main domain)'
|
||||
: 'You\'ll need to configure DNS manually for each subdomain';
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Public navigation
|
||||
const publicBack = document.getElementById('setup-public-back');
|
||||
if (publicBack) {
|
||||
|
||||
Reference in New Issue
Block a user