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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 23:48:51 +01:00
parent 5087a88f65
commit c3b7d6ae35
5 changed files with 106 additions and 5 deletions

View File

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

View File

@ -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 `
<div class="tools-title">
<h3>🧰 Tools</h3>

View File

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

View File

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

View File

@ -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 "$@"; }