The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.
Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.
Three places needed judgement rather than substitution:
db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.
instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.
peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.
Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
10 KiB
Bash
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="$(appDir "$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 "$(appDir "$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
|
|
}
|