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:
2026-03-06 03:03:17 -08:00
parent f61e85d9a7
commit 77030931b7
13 changed files with 407 additions and 41 deletions

View File

@@ -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>

View File

@@ -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;">&#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');
@@ -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') {

View File

@@ -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() {

View File

@@ -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();

View File

@@ -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 || '';

View File

@@ -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);

View File

@@ -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) {