LibrePortal/scripts/catalog/catalog_sources.sh
librelad 90a20e9e17 feat(catalog): multiple catalog sources — backend model + multi-source scan + CLI
First slice of multi-catalog ("taps") support. Today the App Center browses one
catalog (get.libreportal.org). This adds an ordered list of catalog sources and
teaches the browse scan to merge them.

- New scripts/catalog/catalog_sources.sh — the source list in its OWN file
  ($configs_dir/catalog/sources.json). Source #1 is ALWAYS the official catalog,
  synthesized live from CFG_RELEASE_BASE_URL/CHANNEL (can't be edited/removed,
  always pinned on top). Extra sources are stored as a small JSON array and are
  UNVERIFIED (trust=community). Helpers: catalogSourcesJson / catalogEnabledSources
  (priority order) / catalogSourceAdd|Remove|Toggle|List / catalogFetchCommunityIndex.
- webui_registry_scan.sh now walks catalogEnabledSources: the OFFICIAL source is
  still signature-verified (lpFetchIndexInto, unchanged trust path); third-party
  sources are fetched unverified. Apps are merged by slug into one card carrying a
  sources[] array in priority order (highest first = default). Trust/verified are
  taken from the SOURCE, never the artifact's self-claim, so a community index
  can't promote itself to "official". Icons still mirror same-origin from the
  official index only. registry_catalog.json gains top-level sources[] + per-app
  sources[]; the old source{} object + signed/serial are kept for back-compat.
- New `libreportal catalog source list|add|remove|enable|disable` + `catalog
  refresh` CLI (dynamic-routed). Mutations go through the task system (cliTaskRun
  "…" "catalog"), never a new mutating API.

Scope firewall: this governs APP BROWSE + ADD only. LibrePortal's own updates and
hotfixes still resolve from the official CFG_RELEASE_BASE_URL alone — lpFetchIndex
is untouched, so a third-party catalog can never become a system-update channel.

Next: `app add --source`, then the WebUI (domains-style block source manager +
Add-dialog source picker with the Official badge / unverified warning).

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:39:21 +01:00

146 lines
7.0 KiB
Bash

#!/bin/bash
#
# Catalog sources — the ordered list of app-catalog URLs the App Center browses.
# ---------------------------------------------------------------------------
# Source #1 is ALWAYS the official LibrePortal catalog, synthesized live from
# CFG_RELEASE_BASE_URL / CFG_RELEASE_CHANNEL so it can't be edited or removed and
# always tracks the release host. Extra (third-party) sources are stored as a
# small JSON array in their OWN file under the live configs dir and are treated
# as UNVERIFIED (the operator added the URL themselves).
#
# Priority = list order: the official catalog is first, then extras in the order
# they were added. When the same app slug is offered by more than one source the
# highest-priority source that has it is the default (the Add dialog can override).
#
# IMPORTANT SCOPE: this list governs APP BROWSE + APP ADD only. LibrePortal's own
# updates and hotfixes still resolve from the official CFG_RELEASE_BASE_URL alone
# (lpFetchIndex is untouched) — a third-party catalog can never become a channel
# for system updates.
# Its own file, next to the other live config (manager-owned control plane).
catalogSourcesFile() { echo "${configs_dir%/}/catalog/sources.json"; }
# The extra (non-official) sources as a JSON array; [] when none/absent/invalid.
catalogExtraSourcesJson() {
local f; f="$(catalogSourcesFile)"
command -v jq >/dev/null 2>&1 || { echo '[]'; return 0; }
if [[ -s "$f" ]] && jq empty "$f" 2>/dev/null; then
jq -c 'if type=="array" then . else [] end' "$f" 2>/dev/null || echo '[]'
else
echo '[]'
fi
}
# The full ordered source list as a JSON array. Each entry:
# { idx, name, base, channel, enabled, official, trust }
# idx 1 = official (synthesized from CFG); 2..N = extras in file order.
catalogSourcesJson() {
local off_base off_channel extras
off_base="$(lpReleaseBaseUrl)"; off_base="${off_base%/}"
off_channel="$(lpReleaseChannel)"
extras="$(catalogExtraSourcesJson)"
jq -cn --arg base "$off_base" --arg channel "$off_channel" --argjson extras "$extras" '
[ { idx: 1, name: "LibrePortal Official", base: $base, channel: $channel,
enabled: true, official: true, trust: "official" } ]
+ ( $extras | to_entries | map(
{ idx: (.key + 2),
name: (.value.name // .value.base),
base: (.value.base // ""),
channel: (.value.channel // "stable"),
enabled: (if (.value | has("enabled")) then (.value.enabled == true) else true end),
official: false,
trust: "community" } ) )'
}
# One ENABLED source per line (compact JSON), in priority order — for `while read`.
catalogEnabledSources() { catalogSourcesJson | jq -c '.[] | select(.enabled)'; }
# Human-readable listing for the CLI.
catalogSourceList() {
catalogSourcesJson | jq -r '.[] |
"[\(.idx)] \(.name) — \(.base)/\(.channel) " +
"(\(if .official then "official ✓" else "unverified" end), " +
"\(if .enabled then "on" else "off" end))"'
}
# Fetch a THIRD-PARTY catalog index. Deliberately does NOT verify against the
# official key and does NOT touch the shared anti-rollback serial — community
# sources are UNVERIFIED (their payloads are still sha256-pinned at add time by
# the pin carried in this very index). Echoes the index JSON; non-zero on failure.
catalogFetchCommunityIndex() {
local base="$1" channel="$2" tmp idx json
base="${base%/}"
[[ -n "$base" && -n "$channel" ]] || return 1
command -v jq >/dev/null 2>&1 || return 1
[[ -n "$(_lpFetchTool)" ]] || return 1
tmp="$(mktemp -d)"; idx="$tmp/index.json"
if ! _lpDownload "$base/$channel/index.json" "$idx" 2>/dev/null; then rm -rf "$tmp"; return 1; fi
json="$(cat "$idx")"; rm -rf "$tmp"
jq empty <<<"$json" 2>/dev/null || return 1
printf '%s' "$json"
}
# --- Mutations (manager-owned file; write via the manager funnel) -----------
# catalogSourceAdd <url> [name] [channel]
catalogSourceAdd() {
local url="$1" name="$2" channel="${3:-stable}"
if [[ ! "$url" =~ ^https?://[^[:space:]/]+ ]]; then
isError "catalog: source URL must be http(s)://…"; return 1
fi
url="${url%/}"
[[ -z "$name" ]] && name="$url"
# Refuse a duplicate base+channel (including the official one).
if catalogSourcesJson | jq -e --arg b "$url" --arg c "$channel" 'any(.[]; .base==$b and .channel==$c)' >/dev/null 2>&1; then
isNotice "catalog: '$url' ($channel) is already a source."; return 0
fi
local f dir extras new
f="$(catalogSourcesFile)"; dir="$(dirname "$f")"
runInstallOp mkdir -p "$dir" 2>/dev/null || true
extras="$(catalogExtraSourcesJson)"
new="$(jq -c --arg n "$name" --arg b "$url" --arg c "$channel" \
'. + [ { name: $n, base: $b, channel: $c, enabled: true } ]' <<<"$extras")" || {
isError "catalog: failed to build the new source list."; return 1; }
printf '%s\n' "$new" | runInstallWrite "$f"
isSuccessful "catalog: added source '$name' ($url/$channel)."
}
# catalogSourceRemove <idx> (idx 1 = official, cannot be removed)
catalogSourceRemove() {
local idx="$1"
[[ "$idx" =~ ^[0-9]+$ ]] || { isError "catalog: <idx> must be a number."; return 1; }
(( idx == 1 )) && { isError "catalog: source 1 is the official catalog and can't be removed."; return 1; }
local f extras count pos new
f="$(catalogSourcesFile)"; extras="$(catalogExtraSourcesJson)"
count="$(jq 'length' <<<"$extras")"
pos=$(( idx - 2 ))
if (( pos < 0 || pos >= count )); then isError "catalog: no source with idx $idx."; return 1; fi
new="$(jq -c --argjson i "$pos" 'del(.[$i])' <<<"$extras")"
printf '%s\n' "$new" | runInstallWrite "$f"
isSuccessful "catalog: removed source $idx."
}
# catalogSourceToggle <idx> <true|false>
catalogSourceToggle() {
local idx="$1" on="$2"
[[ "$idx" =~ ^[0-9]+$ ]] || { isError "catalog: <idx> must be a number."; return 1; }
(( idx == 1 )) && { isError "catalog: the official catalog is always enabled."; return 1; }
[[ "$on" == "true" || "$on" == "false" ]] || { isError "catalog: toggle value must be true|false."; return 1; }
local f extras count pos new
f="$(catalogSourcesFile)"; extras="$(catalogExtraSourcesJson)"
count="$(jq 'length' <<<"$extras")"
pos=$(( idx - 2 ))
if (( pos < 0 || pos >= count )); then isError "catalog: no source with idx $idx."; return 1; fi
new="$(jq -c --argjson i "$pos" --argjson v "$on" '.[$i].enabled=$v' <<<"$extras")"
printf '%s\n' "$new" | runInstallWrite "$f"
isSuccessful "catalog: source $idx $([[ "$on" == "true" ]] && echo enabled || echo disabled)."
}
# Re-run the App Center browse scan (so a source change reflects immediately
# instead of waiting for the next `updater check`).
catalogRefresh() {
declare -F webuiRegistryCatalogScan >/dev/null 2>&1 || \
source "${install_scripts_dir}webui/data/generators/apps/webui_registry_scan.sh" 2>/dev/null
declare -F webuiRegistryCatalogScan >/dev/null 2>&1 && webuiRegistryCatalogScan
}