diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js index dc6d268..a897ac7 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -1098,7 +1098,33 @@ class AppsManager { return cfgKey; } } - + + // Last resort: a key carrying a generated-value SLOT suffix. Passwords whose + // value LibrePortal generates are stored as CFG___ + // (CFG_STOAT_ADMIN_PASSWORD_1), and none of the rules above see through that + // trailing _1 — ADMIN_PASSWORD_1 neither equals ADMIN_PASSWORD nor ends with + // '_ADMIN_PASSWORD'. So a generic mapping matched an app's admin EMAIL and + // silently missed its admin PASSWORD, and the field never rendered: the one + // credential a user most needs to read or change was absent from the page + // unless someone had hand-written a per-app mapping for it. + // + // The backend already resolves the bare name to the slotted key (see + // scripts/app/auth_adapter.sh); this mirrors that. Tried only after every + // exact and whole-word match has failed, so a mapping that already binds + // precisely is never redirected, and the lowest slot wins so the choice is + // deterministic when an app declares several. + const slotRe = new RegExp('(?:^|_)' + fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '_(\\d+)$'); + let bestKey = null; + let bestSlot = Infinity; + for (const cfgKey of keys) { + const m = cfgKey.replace('CFG_', '').match(slotRe); + if (m && Number(m[1]) < bestSlot) { + bestSlot = Number(m[1]); + bestKey = cfgKey; + } + } + if (bestKey) return bestKey; + return null; } diff --git a/containers/libreportal/frontend/components/apps/port-manager/js/port-manager.js b/containers/libreportal/frontend/components/apps/port-manager/js/port-manager.js index 0afa5b2..2656f02 100755 --- a/containers/libreportal/frontend/components/apps/port-manager/js/port-manager.js +++ b/containers/libreportal/frontend/components/apps/port-manager/js/port-manager.js @@ -34,6 +34,10 @@ class PortManager { const isTenCol = parts.length >= 10; const isNineCol = parts.length >= 9; + // subdomain is column 11, so it exists from ELEVEN fields on. Requiring + // twelve meant the canonical 11-column descriptor every web app ships + // never surfaced its subdomain — the bash parser reads it from nine. + const hasSubdomain = parts.length >= 11; const isTwelveCol = parts.length >= 12; // Recommended defaults to the webui flag when not stored on the row — // matches the panel's "primary list" expectation for apps that haven't @@ -51,7 +55,7 @@ class PortManager { button_enabled: buttonEnabled, button_text: isNineCol ? (parts[8] || '') : (parts[7] || ''), url_path: isTenCol ? (parts[9] || '') : '', - subdomain: isTwelveCol ? (parts[10] || '') : '', + subdomain: hasSubdomain ? (parts[10] || '') : '', recommended: isTwelveCol ? (parts[11] === 'true') : buttonEnabled }); } @@ -672,7 +676,15 @@ class PortManager { this.ports.forEach((port, index) => { const login = port.login_required ? 'true' : 'false'; - const portConfig = `${port.service}|${port.name}|${port.external}:${port.internal}|${port.access}|${port.protocol}|${login}|${port.traefik_managed}|${port.button_enabled}|${port.button_text}|${port.url_path || ''}`; + // All twelve columns, matching buildPortConfig above. This wrote only ten + // and silently dropped subdomain and recommended, so saving ANY port on an + // app quietly discarded that app's Traefik subdomain: the descriptor came + // back a column short and the router fell back to the app-name default. + // Stoat lost its "stoat" subdomain exactly this way — the template had it, + // the live config did not. + const subdomain = port.subdomain || ''; + const recommended = port.recommended ? 'true' : 'false'; + const portConfig = `${port.service}|${port.name}|${port.external}:${port.internal}|${port.access}|${port.protocol}|${login}|${port.traefik_managed}|${port.button_enabled}|${port.button_text}|${port.url_path || ''}|${subdomain}|${recommended}`; const fieldName = `CFG_${appName.toUpperCase()}_PORT_${index + 1}`; // Find and update the individual PORT_X field diff --git a/containers/libreportal/frontend/components/apps/routing/js/routing-manager.js b/containers/libreportal/frontend/components/apps/routing/js/routing-manager.js index 3087b36..f9c7a6f 100644 --- a/containers/libreportal/frontend/components/apps/routing/js/routing-manager.js +++ b/containers/libreportal/frontend/components/apps/routing/js/routing-manager.js @@ -45,6 +45,8 @@ class RoutingManager { const parts = String(raw).split('|'); if (parts.length < 8) continue; const isNine = parts.length >= 9; + // Column 11 exists from eleven fields on; only `recommended` needs twelve. + const hasSubdomain = parts.length >= 11; const isTwelve = parts.length >= 12; const webui = isNine ? parts[7] === 'true' : parts[6] === 'true'; const port = { @@ -59,7 +61,7 @@ class RoutingManager { protocol: parts[4] || 'tcp', traefik: isNine ? parts[6] === 'true' : parts[5] === 'true', webui, - subdomain: isTwelve ? (parts[10] || '') : '', + subdomain: hasSubdomain ? (parts[10] || '') : '', recommended: isTwelve ? parts[11] === 'true' : webui, description: isNine ? (parts[8] || '') : (parts[7] || '') }; diff --git a/scripts/network/variables/variables_init_app.sh b/scripts/network/variables/variables_init_app.sh index 695ef5a..27be595 100755 --- a/scripts/network/variables/variables_init_app.sh +++ b/scripts/network/variables/variables_init_app.sh @@ -70,7 +70,18 @@ initializeAppVariables() # 9-col: parent|name|ext:int|access|proto|login|traefik|webui|label # Legacy 8-col: parent|name|ext:int|access|proto|traefik|webui|label (login defaults to false) # Legacy 7-col: name|ext:int|access|proto|traefik|webui|label (no parent, login defaults to false) - local parts=(${port_config_value//|/ }) + # IFS-split, NOT ${v//|/ } with word-splitting. That idiom broke the + # format in two ways at once: a label containing a space became + # several fields ("Web Interface" -> label "Web", url_path + # "Interface"), and an EMPTY column collapsed instead of being kept, + # shifting every field after it. Stoat's LiveKit row parsed as + # label "LiveKit", url_path "voice/video", subdomain "(TCP", + # recommended "fallback)" — and Rocket.Chat's subdomain only landed + # correctly because the extra label word and the collapsed empty + # column happened to cancel out. The column COUNT was wrong too, so + # the 9/8/7-col branch below was chosen from an inflated number. + local parts=() + IFS='|' read -ra parts <<< "$port_config_value" if [[ ${#parts[@]} -ge 9 ]]; then local external_port="${parts[2]%%:*}" local internal_port="${parts[2]##*:}" diff --git a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh index b5a720a..ee9de30 100755 --- a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh +++ b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh @@ -474,6 +474,12 @@ PORTEOF "type": "text", "tooltip": "Username of the admin account the credentials card shows" }, + "ADMIN_USERNAME": { + "category": "general", + "label": "Admin Username", + "type": "text", + "tooltip": "Username of the admin account the credentials card shows" + }, "ADGUARD_ADMIN_PASSWORD_1": { "category": "general", "label": "AdGuard Admin Password",