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>
146 lines
7.0 KiB
Bash
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
|
|
}
|