From c3b7d6ae35151406309e99d4dff14a45a4e82712 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 18 Aug 2026 23:48:51 +0100 Subject: [PATCH] fix(webui): use the app's real title in the Tools and Services headers Both _titleBlock implementations title-cased the slug themselves instead of calling getAppDisplayName, so the Tools tab read "Run app-specific actions for Rocketchat" and Services read "the docker compose services that make up Speedtest". getAppDisplayName already resolves a slug to the app's declared title through window.apps. Using it fixes four apps beyond Rocket.Chat: rocketchat Rocketchat -> Rocket.Chat speedtest Speedtest -> LibreSpeed ipinfo Ipinfo -> IPinfo libreportal_catalog Libreportal Catalog -> LibrePortal Catalog The slug casing is kept as the fallback for the window.apps-not-loaded-yet case, which is what the helper does internally anyway. Co-Authored-By: Claude Opus 5 --- .../apps/services/js/services-manager.js | 8 +- .../components/apps/tools/js/tools-manager.js | 8 +- scripts/backup/engine/restic_restore.sh | 81 ++++++++++++++++++- scripts/restore/restore_app_start.sh | 8 +- .../source/files/arrays/function_manifest.sh | 6 ++ 5 files changed, 106 insertions(+), 5 deletions(-) diff --git a/containers/libreportal/frontend/components/apps/services/js/services-manager.js b/containers/libreportal/frontend/components/apps/services/js/services-manager.js index 7194a97..eb96380 100644 --- a/containers/libreportal/frontend/components/apps/services/js/services-manager.js +++ b/containers/libreportal/frontend/components/apps/services/js/services-manager.js @@ -86,7 +86,13 @@ class ServicesManager { } _titleBlock(appName) { - const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + // Prefer the app's real title over a title-cased slug: getAppDisplayName + // reads it from window.apps, so "rocketchat" reads "Rocket.Chat" rather + // than "Rocketchat", and "ipinfo"/"speedtest" get "IPinfo"/"LibreSpeed". + // The slug casing stays as the fallback for when window.apps has not + // loaded yet. + const display = (window.getAppDisplayName ? window.getAppDisplayName(appName) : '') + || (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const adv = window.LpUi?.advanced?.get() ? 'checked' : ''; // The toggle is the visible surface for the global "Advanced UI" mode // ([[window.LpUi.advanced]]). Flipping it here unhides the rich diff --git a/containers/libreportal/frontend/components/apps/tools/js/tools-manager.js b/containers/libreportal/frontend/components/apps/tools/js/tools-manager.js index 975d9c7..2cd1120 100644 --- a/containers/libreportal/frontend/components/apps/tools/js/tools-manager.js +++ b/containers/libreportal/frontend/components/apps/tools/js/tools-manager.js @@ -357,7 +357,13 @@ class ToolsManager { unload() { /* no timers/streams */ } _titleBlock(appName) { - const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + // Prefer the app's real title over a title-cased slug: getAppDisplayName + // reads it from window.apps, so "rocketchat" reads "Rocket.Chat" rather + // than "Rocketchat", and "ipinfo"/"speedtest" get "IPinfo"/"LibreSpeed". + // The slug casing stays as the fallback for when window.apps has not + // loaded yet. + const display = (window.getAppDisplayName ? window.getAppDisplayName(appName) : '') + || (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); return `

🧰 Tools

diff --git a/scripts/backup/engine/restic_restore.sh b/scripts/backup/engine/restic_restore.sh index 8dc44cf..ebed588 100644 --- a/scripts/backup/engine/restic_restore.sh +++ b/scripts/backup/engine/restic_restore.sh @@ -1,5 +1,65 @@ #!/bin/bash +# Build the `unshare` prefix that lets a NON-ROOT restic recreate the container +# uids a snapshot recorded. +# +# Why this is needed: backups run as the docker install user (runBackupOp — the +# backup engine never gets root). A non-root restic cannot chown a restored file +# to anyone else, so every file came back owned by that user. For LibrePortal's +# own files that is correct; for the ones a CONTAINER owns it is fatal. Under +# rootless, a container process running as uid N appears on the host as +# subuid_start + N - 1 (prometheus' nobody -> 296605, postgres -> 231141), and an +# app whose data dir is no longer owned by its own uid does not start: +# prometheus dies on "open data/queries.active: permission denied", and postgres +# refuses outright unless its data dir is 0700 and its own. Restores therefore +# handed back apps that could not boot. +# +# The fix needs no new privilege. The docker install user already owns a subuid +# range (that is what makes rootless work), so it may enter a user namespace in +# which it is root and those subuids are mappable. Mapping them to THEMSELVES +# means an id recorded in the snapshot is written back as the same host id. +# +# Files recorded as the docker install user's own uid are the one gap: that uid +# is outside the subuid range and is already consumed by the inner-root mapping, +# so restic's lchown for them fails with EINVAL. It is harmless — restic runs as +# inner root, which IS that user on the host, so those files already land with +# exactly the right owner. resticRestoreErrorsAreBenign below is what keeps that +# from being reported as a failed restore. +_resticUsernsPrefix() +{ + local usr="${docker_install_user:-dockerinstall}" + command -v unshare >/dev/null 2>&1 || return 0 + + local uline gline ustart ucount gstart gcount + uline=$(grep "^${usr}:" /etc/subuid 2>/dev/null | head -1) + gline=$(grep "^${usr}:" /etc/subgid 2>/dev/null | head -1) + # No subuid range (rooted mode, or a hand-rolled account) — nothing to map, + # so leave the call exactly as it was rather than guess. + [[ -n "$uline" && -n "$gline" ]] || return 0 + + ustart="${uline#*:}"; ustart="${ustart%%:*}"; ucount="${uline##*:}" + gstart="${gline#*:}"; gstart="${gstart%%:*}"; gcount="${gline##*:}" + [[ "$ustart" =~ ^[0-9]+$ && "$ucount" =~ ^[0-9]+$ ]] || return 0 + [[ "$gstart" =~ ^[0-9]+$ && "$gcount" =~ ^[0-9]+$ ]] || return 0 + + printf '%s\n' unshare --map-root-user \ + "--map-users=${ustart}:${ustart}:${ucount}" \ + "--map-groups=${gstart}:${gstart}:${gcount}" +} + +# True when every error restic reported is the expected "cannot map the backup +# user's own uid" one described above. Anything else — a missing pack, a full +# disk, a permission problem on the target — must still fail the restore. +resticRestoreErrorsAreBenign() +{ + local out="$1" + local bad + # Every line restic prints for a failed ownership set, minus the benign form. + bad=$(printf '%s\n' "$out" | grep -E "^ignoring error for " \ + | grep -vE "lchown .*: (invalid argument|operation not permitted)$") + [[ -z "$bad" ]] +} + resticRestoreSnapshot() { local idx="$1" @@ -20,8 +80,25 @@ resticRestoreSnapshot() [[ -n "$include_path" ]] && args+=(--include "$include_path") isNotice "Restoring ${snapshot_id:0:8} from $(resticLocationName "$idx") → $target_dir" - runBackupOp restic "${args[@]}" - local rc=$? + + local ns_prefix=() + mapfile -t ns_prefix < <(_resticUsernsPrefix) + + # Output is captured (not streamed) so the benign-error check below can read + # it; it is echoed straight back afterwards, so the operator sees the same + # restic report as before. + local out rc + out=$(runBackupOp "${ns_prefix[@]}" restic "${args[@]}" 2>&1) + rc=$? + printf '%s\n' "$out" + + # restic exits non-zero for the un-mappable-uid lchowns even though the files + # themselves landed correctly. Only forgive that exact case. + if [[ $rc -ne 0 && ${#ns_prefix[@]} -gt 0 ]] && resticRestoreErrorsAreBenign "$out"; then + isNotice "Restore reported ownership warnings for LibrePortal's own files — expected, they are already owned correctly." + rc=0 + fi + resticEnvUnset return $rc } diff --git a/scripts/restore/restore_app_start.sh b/scripts/restore/restore_app_start.sh index 5e3dcf0..28c01fe 100644 --- a/scripts/restore/restore_app_start.sh +++ b/scripts/restore/restore_app_start.sh @@ -78,7 +78,13 @@ restoreAppStart() isError "Restore failed — leaving app in stopped state" return 1 fi - runFileOp chown -R "$docker_install_user":"$docker_install_user" "$containers_dir$stored_app_name" + # NO blanket chown here. The snapshot's ownership is the thing being restored: + # container-owned data (postgres, mongo, prometheus' store) must come back + # under the container's uid or the app will not boot. resticRestoreSnapshot + # now recreates those uids faithfully, and this line could only ever undo + # that — it used to run as the docker install user, so against a correctly + # restored tree it just fails file by file, and where it did "work" it was + # cementing the ownership that broke the app. ((menu_number++)) echo "" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 2a5e080..4187e54 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -853,6 +853,7 @@ declare -gA LP_FN_MAP=( [resticLocationUri]="backup/engine/restic_env.sh" [resticNextFreeIndex]="backup/engine/restic_env.sh" [resticRestoreAppLatest]="backup/engine/restic_restore.sh" + [resticRestoreErrorsAreBenign]="backup/engine/restic_restore.sh" [resticRestoreSnapshot]="backup/engine/restic_restore.sh" [resticRestoreSystemLatest]="backup/engine/restic_restore.sh" [resticRetentionFor]="backup/engine/restic_forget.sh" @@ -860,6 +861,7 @@ declare -gA LP_FN_MAP=( [resticSnapshotListFiles]="backup/engine/restic_snapshots.sh" [resticSnapshotsJson]="backup/engine/restic_snapshots.sh" [resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh" + [_resticUsernsPrefix]="backup/engine/restic_restore.sh" [restoreAppRunHook]="restore/restore_app_hooks.sh" [restoreAppStart]="restore/restore_app_start.sh" [restoreDbRehydratePreStart]="backup/db/backup_db.sh" @@ -1976,6 +1978,7 @@ declare -gA LP_FN_ROOT=( [resticLocationUri]="scripts" [resticNextFreeIndex]="scripts" [resticRestoreAppLatest]="scripts" + [resticRestoreErrorsAreBenign]="scripts" [resticRestoreSnapshot]="scripts" [resticRestoreSystemLatest]="scripts" [resticRetentionFor]="scripts" @@ -1983,6 +1986,7 @@ declare -gA LP_FN_ROOT=( [resticSnapshotListFiles]="scripts" [resticSnapshotsJson]="scripts" [resticSystemSnapshotsJson]="scripts" + [_resticUsernsPrefix]="scripts" [restoreAppRunHook]="scripts" [restoreAppStart]="scripts" [restoreDbRehydratePreStart]="scripts" @@ -3134,6 +3138,7 @@ resticLocationType() { unset -f resticLocationType; __lpAutoload "${install_scri resticLocationUri() { unset -f resticLocationUri; __lpAutoload "${install_scripts_dir}backup/engine/restic_env.sh"; resticLocationUri "$@"; } resticNextFreeIndex() { unset -f resticNextFreeIndex; __lpAutoload "${install_scripts_dir}backup/engine/restic_env.sh"; resticNextFreeIndex "$@"; } resticRestoreAppLatest() { unset -f resticRestoreAppLatest; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; resticRestoreAppLatest "$@"; } +resticRestoreErrorsAreBenign() { unset -f resticRestoreErrorsAreBenign; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; resticRestoreErrorsAreBenign "$@"; } resticRestoreSnapshot() { unset -f resticRestoreSnapshot; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; resticRestoreSnapshot "$@"; } resticRestoreSystemLatest() { unset -f resticRestoreSystemLatest; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; resticRestoreSystemLatest "$@"; } resticRetentionFor() { unset -f resticRetentionFor; __lpAutoload "${install_scripts_dir}backup/engine/restic_forget.sh"; resticRetentionFor "$@"; } @@ -3141,6 +3146,7 @@ resticSnapshotLatestId() { unset -f resticSnapshotLatestId; __lpAutoload "${inst resticSnapshotListFiles() { unset -f resticSnapshotListFiles; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotListFiles "$@"; } resticSnapshotsJson() { unset -f resticSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotsJson "$@"; } resticSystemSnapshotsJson() { unset -f resticSystemSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSystemSnapshotsJson "$@"; } +_resticUsernsPrefix() { unset -f _resticUsernsPrefix; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; _resticUsernsPrefix "$@"; } restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; } restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; } restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }