librelad 2d24a764a8 refactor(storage): route elevation tests and the WebUI tree through paths.sh
Two mechanical sweeps, no behaviour change on a single-root install.

The 14 `[[ "$p" == "$containers_dir"* ]]` prefix tests that decide
manager-vs-container-user elevation become pathIsContainerData, so a file
on a second storage root is no longer misclassified as manager-owned —
which would have written it with the wrong owner and failed later, far
from the cause. The 65 references to the WebUI's own tree become
webuiDir(), which is pinned to the primary root by design.

Two traps found while doing it:

run_privileged.sh is sourced directly by init.sh without paths.sh, so it
needs a fallback. Defining one named pathIsContainerData was wrong:
generate_function_manifest.sh indexes top-level definitions, and the
resulting autoload stub would have shadowed the real multi-root
implementation with the primary-only fallback — silently classifying
every file on a second disk as manager-owned, which is exactly the bug
this sweep exists to prevent. Renamed to _runCfgIsContainerPath, which
delegates when the real one is loaded.

setup_lock.sh built its path in a top-level assignment, so it was
evaluated at source time and needed the file flagged eager. Made it a
function instead: the path resolves on call, and the file drops off
LP_EAGER_FILES entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:04:19 +01:00

253 lines
10 KiB
Bash

#!/bin/bash
# LibrePortal WebUI Config Generator
# Generates apps.json from container configuration files
# Single awk pass extracting SERVICE_TAG_1..SERVICE_TAG_9 from a compose
# file. Was 9 separate tagsManagerGetTagContent forks per app — across
# 31 apps that's 279 awk forks; now 1 per app.
# Output: tab-separated values for slots 1..9 (empty for missing).
_webuiReadServiceTags() {
local file_path="$1"
awk '
BEGIN { for (i = 1; i <= 9; i++) v[i] = "" }
{
for (i = 1; i <= 9; i++) {
if (v[i] != "") continue
marker = "#LIBREPORTAL|SERVICE_TAG_" i "|"
p = index($0, marker)
if (p == 0) continue
rest = substr($0, p + length(marker))
n = match(rest, /[| \t\r]/)
if (n > 0) rest = substr(rest, 1, n - 1)
v[i] = rest
}
}
END {
for (i = 1; i <= 9; i++) printf "%s\t", v[i]
print ""
}
' "$file_path"
}
webuiGenerateLibrePortalConfig() {
local testing_mode="$1"
local specific_app="$2"
local output_file="$(webuiDir)/frontend/data/apps/generated/apps.json"
local first_app=true
local app_count=0
isNotice "Generating LibrePortal apps.json from config files..."
[[ -n "$specific_app" ]] && isNotice "Filtering to specific app: $specific_app"
runFileOp mkdir -p "$(dirname "$output_file")"
local temp_file="$(mktemp)"
cat > "$temp_file" << 'EOF'
{
"apps": [
EOF
# Determine which apps to process
local apps_to_process=()
if [[ -n "$specific_app" ]]; then
if [[ -d "$install_containers_dir/$specific_app" && "$specific_app" != "template" ]]; then
apps_to_process=("$specific_app")
else
isError "App '$specific_app' not found or is template"
fi
else
while IFS= read -r -d '' dir; do
app_name="${dir##*/}"
[[ "$app_name" != "template" ]] && apps_to_process+=("$app_name")
done < <(find "$install_containers_dir" -maxdepth 1 -type d ! -path "$install_containers_dir" -print0 | sort -z)
fi
local app_name dir deployed_config_file config_file
local title description long_description category url config_vars
local host_install host_package
local var_name var_value
local APP_UPPER
for app_name in "${apps_to_process[@]}"; do
dir="$install_containers_dir/$app_name"
deployed_config_file="$containers_dir/$app_name/$app_name.config"
config_file="$dir/$app_name.config"
[[ -f "$deployed_config_file" ]] && config_file="$deployed_config_file"
[[ ! -f "$config_file" ]] && { isNotice "No config file for $app_name"; continue; }
title=""; description=""; long_description=""; category=""; url=""; config_vars=""
host_install=""; host_package=""
APP_UPPER="${app_name^^}"
# Single pass through the config file.
while IFS='=' read -r var_name var_value || [[ -n "$var_name" ]]; do
[[ -z "$var_name" || "$var_name" =~ ^[[:space:]]*# ]] && continue
[[ "$var_name" == CFG_${APP_UPPER}_* ]] || continue
# Strip surrounding quotes + \r, then trim whitespace.
var_value="${var_value#\"}"
var_value="${var_value%\"}"
var_value="${var_value//$'\r'/}"
var_value="${var_value#"${var_value%%[![:space:]]*}"}"
var_value="${var_value%"${var_value##*[![:space:]]}"}"
case "$var_name" in
"CFG_${APP_UPPER}_TITLE") title="$var_value" ;;
"CFG_${APP_UPPER}_DESCRIPTION") description="$var_value" ;;
"CFG_${APP_UPPER}_LONG_DESCRIPTION") long_description="$var_value" ;;
"CFG_${APP_UPPER}_CATEGORY") category="$var_value" ;;
"CFG_${APP_UPPER}_URL")
url="$var_value"
config_vars+="\"$var_name\": \"$var_value\","
;;
"CFG_${APP_UPPER}_HOST_INSTALL")
host_install="$var_value"
config_vars+="\"$var_name\": \"$var_value\","
;;
"CFG_${APP_UPPER}_HOST_PACKAGE")
host_package="$var_value"
config_vars+="\"$var_name\": \"$var_value\","
;;
*)
config_vars+="\"$var_name\": \"$var_value\","
;;
esac
done < "$config_file"
[[ -z "$title" || -z "$category" ]] && { isNotice "Skipping $app_name - missing essential data"; continue; }
local is_installed="false"
if [[ "$host_install" == "true" ]]; then
if [[ -n "$host_package" ]] \
&& dpkg-query -W -f='${Status}' "$host_package" 2>/dev/null | grep -q "install ok installed"; then
is_installed="true"
fi
else
[[ -d "$containers_dir/$app_name" ]] && is_installed="true"
fi
# Categories: comma-list, lowercase + trimmed via bash param expansion
# (was echo|tr|sed — 3 forks per category per app).
local mapped_category="" mapped_categories_json=""
if [[ -n "$category" ]]; then
local _first_cat="" _cats_acc=""
local _cat_arr _c
IFS=',' read -ra _cat_arr <<< "$category"
for _c in "${_cat_arr[@]}"; do
_c="${_c,,}"
_c="${_c#"${_c%%[![:space:]]*}"}"
_c="${_c%"${_c##*[![:space:]]}"}"
[[ -z "$_c" ]] && continue
[[ -z "$_first_cat" ]] && _first_cat="$_c"
[[ -n "$_cats_acc" ]] && _cats_acc+=","
_cats_acc+="\"$_c\""
done
mapped_category="$_first_cat"
mapped_categories_json="[$_cats_acc]"
fi
local full_name="$title"
[[ -n "$description" ]] && full_name="$title - $description"
full_name="${full_name//\\/\\\\}"
full_name="${full_name//\"/\\\"}"
long_description="${long_description//\\/\\\\}"
long_description="${long_description//\"/\\\"}"
local icon_file=""
if [[ -f "$dir/$app_name.svg" ]]; then
icon_file="$app_name.svg"
elif [[ -f "$dir/$app_name.png" ]]; then
icon_file="$app_name.png"
fi
if [[ -n "$icon_file" ]]; then
local icons_apps_dir="$(webuiDir)/frontend/core/icons/apps"
runFileOp mkdir -p "$icons_apps_dir"
runFileWrite "$icons_apps_dir/$icon_file" < "$dir/$icon_file" 2>/dev/null
fi
# Service names — one awk pass instead of 9 grep|awk|head pipelines.
local services_json=""
local compose_file="$dir/docker-compose.yml"
if [[ -f "$compose_file" ]]; then
local _tags_line
_tags_line=$(_webuiReadServiceTags "$compose_file")
local _svc_arr _s
IFS=$'\t' read -ra _svc_arr <<< "$_tags_line"
for _s in "${_svc_arr[@]}"; do
[[ -n "$_s" ]] && services_json+="\"$_s\","
done
services_json="${services_json%,}"
fi
# Live-backup capability: the app can be snapshotted live without
# downtime only if it declares a dumpable database, or is explicitly
# marked live-safe. The app config UI uses this to hide the "live"
# strategy option where it would be unsafe.
local backup_live_capable="false"
if [[ -f "$compose_file" ]]; then
if grep -qE '^[[:space:]]*libreportal\.backup\.(db|files)[[:space:]]*:' "$compose_file" 2>/dev/null \
|| grep -qE '^[[:space:]]*libreportal\.backup\.live[[:space:]]*:[[:space:]]*["'\'']?true' "$compose_file" 2>/dev/null; then
backup_live_capable="true"
fi
fi
if [[ "$first_app" == false ]]; then
echo " }," >> "$temp_file"
fi
first_app=false
app_count=$((app_count + 1))
# Build the app's field list, then emit with correct commas in one
# pass. Eliminates the per-app `sed -i '$ s/,$//'` (31 forks across
# the catalog) that was needed to undo a speculative trailing
# comma when longDescription was absent.
local fields=()
fields+=(" \"name\": \"$full_name\"")
fields+=(" \"command\": \"libreportal app install $app_name\"")
fields+=(" \"category\": \"$mapped_category\"")
[[ -n "$mapped_categories_json" ]] && fields+=(" \"categories\": $mapped_categories_json")
fields+=(" \"installed\": $is_installed")
fields+=(" \"backup_live_capable\": $backup_live_capable")
[[ -n "$url" ]] && fields+=(" \"url\": \"$url\"")
[[ -n "$description" ]] && fields+=(" \"description\": \"$description\"")
[[ -n "$icon_file" ]] && fields+=(" \"icon\": \"/core/icons/apps/$icon_file\"")
[[ -n "$services_json" ]] && fields+=(" \"services\": [$services_json]")
if [[ -n "$config_vars" ]]; then
fields+=(" \"config\": {"$'\n'"${config_vars%,}"$'\n'" }")
fi
[[ -n "$long_description" ]] && fields+=(" \"longDescription\": \"$long_description\"")
{
echo " {"
local _n=${#fields[@]}
local _i
for _i in "${!fields[@]}"; do
if [[ $_i -lt $((_n - 1)) ]]; then
echo "${fields[$_i]},"
else
echo "${fields[$_i]}"
fi
done
} >> "$temp_file"
done
echo " }" >> "$temp_file"
echo " ]" >> "$temp_file"
echo "}" >> "$temp_file"
# Already root via start.sh — drop redundant sudo.
if [ $? -eq 0 ]; then
runFileWrite "$output_file" < "$temp_file"; rm -f "$temp_file"
else
rm -f "$temp_file" 2>/dev/null
fi
if [[ "$testing_mode" == "test" ]]; then
isSuccessful "Generated apps.json (test mode, $app_count apps)"
else
isSuccessful "Generated apps.json ($app_count apps)"
fi
}