From 87edd09994cf6c52aefb25b9a4788540e5b0d926 Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 3 Jul 2026 21:17:26 +0100 Subject: [PATCH] feat(webui/registry): catalog scan generator + hotfix-only Improvements stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webuiRegistryCatalogScan (run by updater check, same atomic keep-prior pattern as webuiArtifactScan) writes apps/generated/registry_catalog.json: the type:"app"/kind:"bundle" rows of the signed index annotated with defined/installed, browse metadata from the envelope meta, and icons mirrored into core/icons/apps/registry/ ONLY when their bytes match the sha256 pin in the signed index — the browser stays same-origin; a tampered or oversized icon is skipped, never served. webuiArtifactScan now selects type=="hotfix" so app rows never render as pseudo-hotfixes in the Improvements tab, and counts+logs artifacts of unrecognized type instead of surfacing them (the §8.1 forward-compat firewall on the scan path). Harness vs a locally served registry: 14/14 (catalog row + meta + flags, icon pin verify + tamper skip, hotfix-only stream, unknown-type skip+log, unreachable-registry keeps prior files). Co-Authored-By: Claude Fable 5 Signed-off-by: librelad --- .../commands/updater/cli_updater_commands.sh | 6 + scripts/source/files/arrays/files_webui.sh | 1 + .../source/files/arrays/function_manifest.sh | 3 + .../generators/apps/webui_registry_scan.sh | 124 ++++++++++++++++++ .../generators/updater/webui_artifact_scan.sh | 9 +- 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 scripts/webui/data/generators/apps/webui_registry_scan.sh diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh index 31dc7e5..ce61d55 100644 --- a/scripts/cli/commands/updater/cli_updater_commands.sh +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -52,6 +52,12 @@ cliHandleUpdaterCommands() source "$install_scripts_dir/webui/data/generators/updater/webui_artifact_scan.sh" 2>/dev/null fi declare -F webuiArtifactScan >/dev/null 2>&1 && webuiArtifactScan + # Registry catalog: refresh the App Center's marketplace data + # (the type:"app" rows of the same signed index). + if ! declare -F webuiRegistryCatalogScan >/dev/null 2>&1; then + source "$install_scripts_dir/webui/data/generators/apps/webui_registry_scan.sh" 2>/dev/null + fi + declare -F webuiRegistryCatalogScan >/dev/null 2>&1 && webuiRegistryCatalogScan if ! declare -F artifactApplyAuto >/dev/null 2>&1; then source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null fi diff --git a/scripts/source/files/arrays/files_webui.sh b/scripts/source/files/arrays/files_webui.sh index 812a095..eda5264 100755 --- a/scripts/source/files/arrays/files_webui.sh +++ b/scripts/source/files/arrays/files_webui.sh @@ -8,6 +8,7 @@ webui_scripts=( "webui/data/generators/apps/webui_app_status.sh" "webui/data/generators/apps/webui_config_patch.sh" "webui/data/generators/apps/webui_config.sh" + "webui/data/generators/apps/webui_registry_scan.sh" "webui/data/generators/apps/webui_services.sh" "webui/data/generators/apps/webui_tools.sh" "webui/data/generators/backup/webui_backup_app_status.sh" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 81493fd..48f5831 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -943,6 +943,7 @@ declare -gA LP_FN_MAP=( [webuiPrintInstallCard]="webui/webui_display_logins.sh" [webuiPrintLoginBlock]="webui/webui_display_logins.sh" [_webuiReadServiceTags]="webui/data/generators/apps/webui_config.sh" + [webuiRegistryCatalogScan]="webui/data/generators/apps/webui_registry_scan.sh" [webuiRemoveSetupLock]="webui/data/lock/webui_remove_setup_lock.sh" [webuiRemoveUpdateLock]="webui/data/lock/webui_remove_update_lock.sh" [webuiRunUpdate]="update/check_update.sh" @@ -1908,6 +1909,7 @@ declare -gA LP_FN_ROOT=( [webuiPrintInstallCard]="scripts" [webuiPrintLoginBlock]="scripts" [_webuiReadServiceTags]="scripts" + [webuiRegistryCatalogScan]="scripts" [webuiRemoveSetupLock]="scripts" [webuiRemoveUpdateLock]="scripts" [webuiRunUpdate]="scripts" @@ -2894,6 +2896,7 @@ webuiPatchAppConfigJson() { source "${install_scripts_dir}webui/data/generators/ webuiPrintInstallCard() { source "${install_scripts_dir}webui/webui_display_logins.sh"; webuiPrintInstallCard "$@"; } webuiPrintLoginBlock() { source "${install_scripts_dir}webui/webui_display_logins.sh"; webuiPrintLoginBlock "$@"; } _webuiReadServiceTags() { source "${install_scripts_dir}webui/data/generators/apps/webui_config.sh"; _webuiReadServiceTags "$@"; } +webuiRegistryCatalogScan() { source "${install_scripts_dir}webui/data/generators/apps/webui_registry_scan.sh"; webuiRegistryCatalogScan "$@"; } webuiRemoveSetupLock() { source "${install_scripts_dir}webui/data/lock/webui_remove_setup_lock.sh"; webuiRemoveSetupLock "$@"; } webuiRemoveUpdateLock() { source "${install_scripts_dir}webui/data/lock/webui_remove_update_lock.sh"; webuiRemoveUpdateLock "$@"; } webuiRunUpdate() { source "${install_scripts_dir}update/check_update.sh"; webuiRunUpdate "$@"; } diff --git a/scripts/webui/data/generators/apps/webui_registry_scan.sh b/scripts/webui/data/generators/apps/webui_registry_scan.sh new file mode 100644 index 0000000..ca2c7c0 --- /dev/null +++ b/scripts/webui/data/generators/apps/webui_registry_scan.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# WebUI registry-catalog data generator (the marketplace browse data) +# --------------------------------------------------------------------------- +# Writes the read-only JSON the App Center merges into its grid as +# "Available — Add" cards: +# frontend/data/apps/generated/registry_catalog.json +# +# It fetches + verifies the signed artifact index (lpFetchIndexInto), selects +# the type:"app" / payload.kind:"bundle" envelopes, annotates each with +# `defined` (a definition exists in the install tree) and `installed` (a live +# dir exists), and mirrors each catalog icon into +# frontend/core/icons/apps/registry/. +# only when it matches the sha256 pin in the signed index — the browser is +# never pointed at a remote host (same-origin only). Written ATOMICALLY +# (temp -> validate -> one runFileWrite); on ANY fetch/verify failure the +# prior file is KEPT. Run by `libreportal updater check`. + +webuiRegistryCatalogScan() { + local out_dir="${containers_dir%/}/libreportal/frontend/data/apps/generated" + local out="$out_dir/registry_catalog.json" + local icon_dir="${containers_dir%/}/libreportal/frontend/core/icons/apps/registry" + local icon_web="/core/icons/apps/registry" + local max_icon=262144 + runFileOp mkdir -p "$out_dir" 2>/dev/null || true + + # Lazy-loader gap: ensure the read primitives are present. + if ! declare -F lpFetchIndex >/dev/null 2>&1; then + source "$install_scripts_dir/source/fetch.sh" 2>/dev/null + source "$install_scripts_dir/source/artifacts.sh" 2>/dev/null + fi + if ! command -v jq >/dev/null 2>&1; then + isNotice "webuiRegistryCatalogScan: jq not available — keeping the prior registry_catalog.json." + return 0 + fi + + local index + if ! lpFetchIndexInto index; then + isNotice "webuiRegistryCatalogScan: no verified index available — keeping the prior file." + return 0 + fi + local signed="false"; [[ "$LP_INDEX_SIGSTATE" == "verified" ]] && signed="true" + local serial now + serial="$(_lpJsonNum "$index" index_serial)" + now="$(date -Iseconds 2>/dev/null || date)" + + # Defined apps = definition dirs in the install tree; installed = live dirs. + local defined="[]" installed="[]" d + if [[ -d "$install_containers_dir" ]]; then + defined="$(for d in "$install_containers_dir"/*/; do [[ -d "$d" ]] && basename "$d"; done | jq -R . | jq -cs .)" + fi + if [[ -d "$containers_dir" ]]; then + installed="$(for d in "$containers_dir"/*/; do [[ -d "$d" ]] && basename "$d"; done | jq -R . | jq -cs .)" + fi + + # Mirror the pinned catalog icons locally (slug -> local web path). An icon + # is used only if its bytes match the sha256 the signed index pins — a + # tampered or oversized icon is simply skipped, never served. + local icons_map="{}" base rows + base="$(lpReleaseBaseUrl)" + rows="$(printf '%s' "$index" | jq -r ' + .artifacts[]? | select(.type=="app" and .payload.kind=="bundle") + | select((.meta.icon // "") != "" and (.meta.icon_sha256 // "") != "") + | [(.applies_when.app // ""), .meta.icon, .meta.icon_sha256] | @tsv' 2>/dev/null)" + if [[ -n "$rows" ]]; then + runFileOp mkdir -p "$icon_dir" 2>/dev/null || true + local slug icon pin ext url tmpf dest got size + while IFS=$'\t' read -r slug icon pin; do + [[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]] || continue + ext="${icon##*.}" + case "$ext" in svg|png) : ;; *) continue ;; esac + dest="$icon_dir/$slug.$ext" + if [[ -f "$dest" && "$(_lpSha256 "$dest")" == "$pin" ]]; then + icons_map="$(jq -c --arg s "$slug" --arg p "$icon_web/$slug.$ext" '.[$s]=$p' <<<"$icons_map")" + continue + fi + url="$icon"; case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac + tmpf="$(mktemp)" + if _lpDownload "$url" "$tmpf" 2>/dev/null; then + got="$(_lpSha256 "$tmpf")" + size="$(stat -c %s "$tmpf" 2>/dev/null || echo 0)" + if [[ "$got" == "$pin" ]] && (( size > 0 && size <= max_icon )); then + runFileWrite "$dest" < "$tmpf" + runFileOp chown "$docker_install_user":"$docker_install_user" "$dest" 2>/dev/null || true + icons_map="$(jq -c --arg s "$slug" --arg p "$icon_web/$slug.$ext" '.[$s]=$p' <<<"$icons_map")" + fi + fi + rm -f "$tmpf" + done <<<"$rows" + fi + + local tmp; tmp="$(mktemp)" + printf '%s' "$index" | jq \ + --arg now "$now" --arg signed "$signed" --arg serial "${serial:-0}" \ + --argjson defined "$defined" --argjson installed "$installed" --argjson icons "$icons_map" ' + { generated_at: $now, + signed: ($signed=="true"), + serial: ($serial|tonumber? // 0), + apps: [ .artifacts[]? | select(.type=="app" and .payload.kind=="bundle") + | (.applies_when.app // "") as $slug | select($slug != "") + | { id, + app: $slug, + version: (.version // 1), + title: (.title // $slug), + why: (.why // ""), + publisher: (.publisher // ""), + trust: (.trust // "official"), + category: (.meta.category // ""), + description: (.meta.description // .why // ""), + long_description: (.meta.long_description // ""), + icon: ($icons[$slug] // null), + defined: (($defined | index($slug)) != null), + installed: (($installed | index($slug)) != null) } ] }' > "$tmp" 2>/dev/null + + if ! jq empty "$tmp" 2>/dev/null; then + isNotice "webuiRegistryCatalogScan: generated JSON was invalid — keeping the prior file." + rm -f "$tmp"; return 1 + fi + runFileWrite "$out" < "$tmp" + rm -f "$tmp" + runFileOp chown "$docker_install_user":"$docker_install_user" "$out" 2>/dev/null || true + local n; n="$(jq '.apps | length' "$out" 2>/dev/null || echo '?')" + isSuccessful "Registry catalog refreshed ($n app(s), serial=${serial:-?}, signed=$signed)." +} diff --git a/scripts/webui/data/generators/updater/webui_artifact_scan.sh b/scripts/webui/data/generators/updater/webui_artifact_scan.sh index 84cdc6f..4e28e34 100644 --- a/scripts/webui/data/generators/updater/webui_artifact_scan.sh +++ b/scripts/webui/data/generators/updater/webui_artifact_scan.sh @@ -49,6 +49,13 @@ webuiArtifactScan() { installed="$(for d in "$containers_dir"/*/; do [[ -d "$d" ]] && basename "$d"; done | jq -R . | jq -cs .)" fi + # The Improvements stream is HOTFIX-only. type:"app" rows belong to the + # registry catalog (webuiRegistryCatalogScan); anything else is a newer + # format this build doesn't know — skip + log, never error (§8.1 firewall). + local unknown + unknown="$(printf '%s' "$index" | jq '[.artifacts[]? | (.type // "") | select(. != "hotfix" and . != "app")] | length' 2>/dev/null || echo 0)" + [[ "$unknown" =~ ^[0-9]+$ ]] && (( unknown > 0 )) && isNotice "webuiArtifactScan: skipped $unknown artifact(s) of unrecognized type (newer format?)." + local tmp; tmp="$(mktemp)" printf '%s' "$index" | jq \ --arg now "$now" --arg signed "$signed" --arg serial "${serial:-0}" \ @@ -56,7 +63,7 @@ webuiArtifactScan() { { generated_at: $now, signed: ($signed=="true"), serial: ($serial|tonumber? // 0), - artifacts: [ .artifacts[]? | { + artifacts: [ .artifacts[]? | select(.type=="hotfix") | { id, type, severity: (.severity // "tweak"), auto: (.auto // false),