From 897f514735c12db073016cb85e7df3ad683289e5 Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 3 Jul 2026 21:40:33 +0100 Subject: [PATCH] feat(marketplace): the marketplace server ships as a dev-mode LibrePortal app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit containers/marketplace — nginx:alpine app (standard drop-in contract: config + tagged compose + icon + install hook) whose docroot serves BOTH halves of the marketplace: the signed catalog channel tree (index.json / payloads, published into data// by the release tools) and a self-contained client-rendered browse site over the same file (search, category chips, trust badges, copyable 'libreportal app add ' — no third-party assets, no backend, no build step). The official marketplace is an instance of this app; self-hosting one = installing it and pointing CFG_RELEASE_BASE_URL at it. Boxes only ever trust the minisign signature on the catalog, never the website. New generic gating convention: CFG__DEV_ONLY=true keeps an app out of the App Center grid unless Developer Mode is on (CFG_DEV_MODE, the same flag the **DEV** config-field filter uses); an installed dev-only app always stays visible. The marketplace app is the first user. Cache policy: catalog/channel manifests no-cache; payloads short revalidating cache (same-id re-publish); version-pinned release artifacts immutable. Co-Authored-By: Claude Fable 5 Signed-off-by: librelad --- .../components/apps/core/js/apps-manager.js | 13 ++ containers/marketplace/docker-compose.yml | 41 ++++ containers/marketplace/marketplace.config | 63 ++++++ containers/marketplace/marketplace.svg | 22 ++ containers/marketplace/nginx.conf | 38 ++++ .../marketplace/resources/site/index.html | 212 ++++++++++++++++++ .../scripts/marketplace_install_hooks.sh | 18 ++ .../source/files/arrays/function_manifest.sh | 3 + 8 files changed, 410 insertions(+) create mode 100644 containers/marketplace/docker-compose.yml create mode 100644 containers/marketplace/marketplace.config create mode 100644 containers/marketplace/marketplace.svg create mode 100644 containers/marketplace/nginx.conf create mode 100644 containers/marketplace/resources/site/index.html create mode 100644 containers/marketplace/scripts/marketplace_install_hooks.sh diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js index 58c2251..8e9809b 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -310,6 +310,19 @@ class AppsManager { return !(app.config || {})[`CFG_${slug.toUpperCase()}_INSTANCE_OF`]; }); + // Developer-mode-only apps (CFG__DEV_ONLY=true — operator-grade + // things like the marketplace server) stay out of the grid unless dev + // mode is on. An installed one always shows: never hide something + // that's running. + const devModeOn = String(window.systemConfigs?.CFG_DEV_MODE) === 'true'; + if (!devModeOn) { + filteredApps = filteredApps.filter(app => { + const slug = (app.command || '').split(' ').pop(); + const devOnly = String((app.config || {})[`CFG_${slug.toUpperCase()}_DEV_ONLY`] || '').toLowerCase() === 'true'; + return !devOnly || app.installed; + }); + } + if (category === 'installed') { filteredApps = filteredApps.filter(app => app.installed); } else if (category !== 'all') { diff --git a/containers/marketplace/docker-compose.yml b/containers/marketplace/docker-compose.yml new file mode 100644 index 0000000..e1b7a1e --- /dev/null +++ b/containers/marketplace/docker-compose.yml @@ -0,0 +1,41 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + marketplace-service: #LIBREPORTAL|SERVICE_TAG_1|marketplace-service + container_name: marketplace-service + image: nginx:alpine + restart: unless-stopped + hostname: marketplace + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + libreportal.backup.files: "data" + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + # TRAEFIK_PORT_1_BEGIN + traefik.http.routers.marketplace-service.entrypoints: web,websecure + traefik.http.routers.marketplace-service.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1 + traefik.http.routers.marketplace-service.tls: true + traefik.http.routers.marketplace-service.tls.certresolver: production + traefik.http.services.marketplace-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.marketplace-service.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1 + # TRAEFIK_PORT_1_END + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./data:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/marketplace/marketplace.config b/containers/marketplace/marketplace.config new file mode 100644 index 0000000..9c6100a --- /dev/null +++ b/containers/marketplace/marketplace.config @@ -0,0 +1,63 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# DEV_ONLY = if true, hidden from the App Center unless Developer Mode is on +# +CFG_MARKETPLACE_APP_NAME=marketplace +CFG_MARKETPLACE_BACKUP=true +CFG_MARKETPLACE_BACKUP_STRATEGY=auto +CFG_MARKETPLACE_COMPOSE_FILE=default +CFG_MARKETPLACE_HEALTHCHECK=false +CFG_MARKETPLACE_AUTHELIA=false +CFG_MARKETPLACE_HEADSCALE=false +CFG_MARKETPLACE_DEV_ONLY=true +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_MARKETPLACE_CATEGORY="system" +CFG_MARKETPLACE_TITLE="LibrePortal Marketplace" +CFG_MARKETPLACE_DESCRIPTION="Host your own app catalog and registry" +CFG_MARKETPLACE_LONG_DESCRIPTION="Serves a signed LibrePortal artifact channel (apps, hotfixes, releases) plus a browsable marketplace website over it. The official marketplace runs this exact app; point any box's CFG_RELEASE_BASE_URL at your instance to use it" +CFG_MARKETPLACE_URL="https://github.com/Webstar/LibrePortal" +CFG_MARKETPLACE_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# WHITELIST = if true only allow whitelisted ips on traefik, if false allow all +# +CFG_MARKETPLACE_DOMAIN=1 +CFG_MARKETPLACE_WHITELIST=false +CFG_MARKETPLACE_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, api, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_MARKETPLACE_PORT_1="marketplace-service|webui|random:80|public|tcp|false|true|true|Marketplace|/|marketplace" diff --git a/containers/marketplace/marketplace.svg b/containers/marketplace/marketplace.svg new file mode 100644 index 0000000..32512b2 --- /dev/null +++ b/containers/marketplace/marketplace.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/marketplace/nginx.conf b/containers/marketplace/nginx.conf new file mode 100644 index 0000000..abbe27d --- /dev/null +++ b/containers/marketplace/nginx.conf @@ -0,0 +1,38 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # The bootstrap installer: served as a shell script, never cached, so + # `curl … | sudo bash` always gets the current one. + location = /install.sh { + default_type text/x-shellscript; + add_header Cache-Control "no-cache, must-revalidate"; + } + + # The signed catalog + channel manifests move on every publish — no cache. + location ~ /index\.json(\.minisig)?$ { + default_type application/json; + add_header Cache-Control "no-cache, must-revalidate"; + } + location ~ /latest\.json$ { + default_type application/json; + add_header Cache-Control "no-cache, must-revalidate"; + } + + # Payloads keep their name across re-publishes (same-id upsert), so they + # are NOT immutable — short revalidating cache; boxes verify the sha256 + # pinned in the fresh index anyway. + location ~ /payloads/ { + add_header Cache-Control "public, max-age=300, must-revalidate"; + } + + # Version-pinned release artifacts never change — cache hard. + location ~ \.tar\.gz$ { default_type application/gzip; add_header Cache-Control "public, max-age=31536000, immutable"; } + location ~ \.sha256$ { default_type text/plain; add_header Cache-Control "public, max-age=31536000, immutable"; } + location ~ \.minisig$ { default_type text/plain; add_header Cache-Control "public, max-age=31536000, immutable"; } + + # The browse UI. + location / { try_files $uri $uri/ =404; } +} diff --git a/containers/marketplace/resources/site/index.html b/containers/marketplace/resources/site/index.html new file mode 100644 index 0000000..54bd2ca --- /dev/null +++ b/containers/marketplace/resources/site/index.html @@ -0,0 +1,212 @@ + + + + + +LibrePortal Marketplace + + + + +
+
+ +

LibrePortal Marketplace

+
+

A signed, self-hostable app catalog — browse here, install from your own LibrePortal.

+ +
+
+
+ +

+ +
+

Add an app: copy its command into your LibrePortal box's terminal, or find it + on your App Center's grid as an “Available” card once your box scans this catalog.

+

Use this marketplace from your box: set CFG_RELEASE_BASE_URL to this + site's address. Run your own: this whole site is the open-source + marketplace app that ships with LibrePortal (Developer Mode) — publish with + make_app.sh and serve the same signed files. Boxes only ever trust the minisign + signature on the catalog, never this website.

+
+
+ + + diff --git a/containers/marketplace/scripts/marketplace_install_hooks.sh b/containers/marketplace/scripts/marketplace_install_hooks.sh new file mode 100644 index 0000000..549185c --- /dev/null +++ b/containers/marketplace/scripts/marketplace_install_hooks.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Marketplace install hooks — seed the served docroot. The generic installApp +# driver handles compose/start; this converges the browse-UI files on every +# (re)install (always refreshed from the definition), while published channel +# data (stable/, edge/ — the operator's signed catalog) is never touched. + +marketplace_install_post_setup() +{ + local app_name="$1" + local src="${install_containers_dir%/}/$app_name/resources/site" + local dest="$containers_dir$app_name/data" + + runFileOp mkdir -p "$dest" || return 1 + runFileOp cp -f "$src/index.html" "$dest/index.html" || return 1 + runFileOp cp -f "${install_containers_dir%/}/$app_name/$app_name.svg" "$dest/marketplace.svg" || return 1 + isSuccessful "Marketplace site seeded. Publish a catalog into it: rsync dist// $dest//" +} diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 48f5831..e5b4503 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -632,6 +632,7 @@ declare -gA LP_FN_MAP=( [manifestReadFromSnapshot]="backup/manifest/manifest_read.sh" [manifestRemove]="backup/manifest/manifest_write.sh" [manifestWrite]="backup/manifest/manifest_write.sh" + [marketplace_install_post_setup]="marketplace/scripts/marketplace_install_hooks.sh" [mattermostToolsMenu]="menu/tools/manage_mattermost.sh" [maybeRegenPoll]="task/crontab_task_processor.sh" [menuContinue]="menu/message/continue.sh" @@ -1598,6 +1599,7 @@ declare -gA LP_FN_ROOT=( [manifestReadFromSnapshot]="scripts" [manifestRemove]="scripts" [manifestWrite]="scripts" + [marketplace_install_post_setup]="containers" [mattermostToolsMenu]="scripts" [maybeRegenPoll]="scripts" [menuContinue]="scripts" @@ -2585,6 +2587,7 @@ manifestReadField() { source "${install_scripts_dir}backup/manifest/manifest_rea manifestReadFromSnapshot() { source "${install_scripts_dir}backup/manifest/manifest_read.sh"; manifestReadFromSnapshot "$@"; } manifestRemove() { source "${install_scripts_dir}backup/manifest/manifest_write.sh"; manifestRemove "$@"; } manifestWrite() { source "${install_scripts_dir}backup/manifest/manifest_write.sh"; manifestWrite "$@"; } +marketplace_install_post_setup() { source "${install_containers_dir}marketplace/scripts/marketplace_install_hooks.sh"; marketplace_install_post_setup "$@"; } mattermostToolsMenu() { source "${install_scripts_dir}menu/tools/manage_mattermost.sh"; mattermostToolsMenu "$@"; } maybeRegenPoll() { source "${install_scripts_dir}task/crontab_task_processor.sh"; maybeRegenPoll "$@"; } menuContinue() { source "${install_scripts_dir}menu/message/continue.sh"; menuContinue "$@"; }