feat(marketplace): the marketplace server ships as a dev-mode LibrePortal app

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/<channel>/ 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 <slug>' —
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_<APP>_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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-07-03 21:40:33 +01:00
parent 9b5a4d276d
commit 897f514735
8 changed files with 410 additions and 0 deletions

View File

@ -310,6 +310,19 @@ class AppsManager {
return !(app.config || {})[`CFG_${slug.toUpperCase()}_INSTANCE_OF`];
});
// Developer-mode-only apps (CFG_<APP>_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') {

View File

@ -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

View File

@ -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"

View File

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="mkbg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6366f1"/>
<stop offset="1" stop-color="#22d3ee"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#mkbg)"/>
<!-- storefront awning -->
<path d="M14 22 L18 13 h28 l4 9 z" fill="#ffffff" opacity="0.95"/>
<path d="M14 22 h9 v4a4.5 4.5 0 0 1 -9 0 z" fill="#e0e7ff"/>
<path d="M23 22 h9 v4a4.5 4.5 0 0 1 -9 0 z" fill="#ffffff"/>
<path d="M32 22 h9 v4a4.5 4.5 0 0 1 -9 0 z" fill="#e0e7ff"/>
<path d="M41 22 h9 v4a4.5 4.5 0 0 1 -9 0 z" fill="#ffffff"/>
<!-- shop body -->
<rect x="17" y="30" width="30" height="21" rx="2" fill="#ffffff" opacity="0.95"/>
<!-- door -->
<rect x="22" y="36" width="8" height="15" rx="1" fill="#4f46e5"/>
<!-- window with check (signed) -->
<rect x="34" y="36" width="9" height="8" rx="1" fill="#a5f3fc"/>
<path d="M35.8 40 l1.7 1.7 l3 -3.4" stroke="#0e7490" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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; }
}

View File

@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LibrePortal Marketplace</title>
<link rel="icon" type="image/svg+xml" href="marketplace.svg">
<style>
/* Self-contained by design: no external fonts, scripts or images — the same
no-third-party rule as the LibrePortal WebUI. */
:root {
--bg1:#0b1e3a; --bg2:#0e3a5c; --card:rgba(255,255,255,.06);
--border:rgba(255,255,255,.14); --text:#e8f0fb; --dim:#9db4cc;
--indigo:#6366f1; --indigo-soft:rgba(99,102,241,.28);
--teal:#22d3ee; --teal-soft:rgba(34,211,238,.20);
}
* { box-sizing:border-box; margin:0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color:var(--text); min-height:100vh;
background: radial-gradient(1200px 700px at 15% -10%, #1c4d7a 0%, transparent 60%),
linear-gradient(150deg, var(--bg1), var(--bg2));
}
.wrap { max-width:1080px; margin:0 auto; padding:32px 20px 64px; }
header { display:flex; align-items:center; gap:14px; margin-bottom:6px; }
header img { width:44px; height:44px; }
h1 { font-size:1.7rem; letter-spacing:.3px; }
.tagline { color:var(--dim); margin:4px 0 26px; }
.bar { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:18px; }
.bar input {
flex:1; min-width:220px; padding:10px 14px; border-radius:10px;
border:1px solid var(--border); background:var(--card); color:var(--text);
font-size:.95rem; outline:none;
}
.bar input:focus { border-color:var(--indigo); }
.chips { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:22px; }
.chip {
padding:5px 12px; border-radius:999px; font-size:.8rem; cursor:pointer;
border:1px solid var(--border); background:var(--card); color:var(--dim);
}
.chip.on { background:var(--indigo-soft); border-color:var(--indigo); color:#c7d2fe; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:16px; }
.card {
background:var(--card); border:1px solid var(--border); border-radius:14px;
padding:18px; display:flex; flex-direction:column; gap:10px;
}
.card .top { display:flex; gap:12px; align-items:center; }
.card .icon {
width:44px; height:44px; border-radius:10px; background:rgba(255,255,255,.09);
display:flex; align-items:center; justify-content:center; overflow:hidden;
font-weight:700; font-size:1.2rem; color:#c7d2fe; flex-shrink:0;
}
.card .icon img { width:100%; height:100%; object-fit:contain; }
.card h2 { font-size:1.05rem; }
.tags { display:flex; gap:6px; flex-wrap:wrap; }
.tag { font-size:.72rem; padding:2px 9px; border-radius:999px; border:1px solid var(--border); color:var(--dim); }
.tag.official { background:var(--teal-soft); border-color:rgba(34,211,238,.5); color:#a5f3fc; }
.tag.official::before { content:"✓ "; }
.tag.community { background:rgba(245,158,11,.2); border-color:rgba(245,158,11,.5); color:#fcd34d; }
.desc { color:var(--dim); font-size:.88rem; line-height:1.45; flex:1; }
.addline { display:flex; gap:8px; align-items:center; }
.addline code {
flex:1; font-size:.78rem; padding:8px 10px; border-radius:8px; overflow-x:auto;
background:rgba(0,0,0,.30); border:1px solid var(--border); white-space:nowrap;
}
.addline button {
padding:8px 14px; border-radius:8px; border:none; cursor:pointer;
background:var(--indigo); color:#fff; font-weight:600; font-size:.82rem;
}
.addline button:hover { background:#4f46e5; }
.empty, .meta { color:var(--dim); font-size:.9rem; }
.meta { margin:14px 2px 0; }
footer { margin-top:44px; padding-top:18px; border-top:1px solid var(--border); color:var(--dim); font-size:.85rem; line-height:1.6; }
footer code { background:rgba(0,0,0,.3); padding:1px 6px; border-radius:6px; }
a { color:#93c5fd; }
</style>
</head>
<body>
<div class="wrap">
<header>
<img src="marketplace.svg" alt="">
<h1>LibrePortal Marketplace</h1>
</header>
<p class="tagline">A signed, self-hostable app catalog — browse here, install from your own LibrePortal.</p>
<div class="bar"><input id="q" type="search" placeholder="Search apps…" autocomplete="off"></div>
<div class="chips" id="chips"></div>
<div class="grid" id="grid"></div>
<p class="empty" id="empty" hidden>Nothing published on this channel yet.</p>
<p class="meta" id="meta"></p>
<footer>
<p><strong>Add an app:</strong> 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.</p>
<p><strong>Use this marketplace from your box:</strong> set <code>CFG_RELEASE_BASE_URL</code> to this
site's address. <strong>Run your own:</strong> this whole site is the open-source
<code>marketplace</code> app that ships with LibrePortal (Developer&nbsp;Mode) — publish with
<code>make_app.sh</code> and serve the same signed files. Boxes only ever trust the minisign
signature on the catalog, never this website.</p>
</footer>
</div>
<script>
(function () {
'use strict';
var CHANNELS = ['stable', 'edge'];
var state = { apps: [], cat: 'all', q: '' };
var grid = document.getElementById('grid');
var chips = document.getElementById('chips');
var empty = document.getElementById('empty');
var meta = document.getElementById('meta');
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function render() {
var q = state.q.toLowerCase();
var shown = state.apps.filter(function (a) {
if (state.cat !== 'all' && a.category !== state.cat) return false;
if (q && (a.title + ' ' + a.description + ' ' + a.long_description + ' ' + a.slug).toLowerCase().indexOf(q) < 0) return false;
return true;
});
grid.innerHTML = shown.map(function (a) {
var iconInner = a.icon
? '<img src="' + esc(a.icon) + '" alt="" onerror="this.parentNode.textContent=' + "'" + esc((a.title || '?')[0].toUpperCase()) + "'" + '">'
: esc((a.title || '?')[0].toUpperCase());
var trust = a.trust === 'official'
? '<span class="tag official">Official</span>'
: '<span class="tag community">' + esc(a.trust) + '</span>';
return '<div class="card">' +
'<div class="top"><div class="icon">' + iconInner + '</div><div>' +
'<h2>' + esc(a.title) + '</h2>' +
'<div class="tags"><span class="tag">' + esc(a.category || 'app') + '</span>' + trust +
'<span class="tag">v' + esc(a.version) + '</span></div>' +
'</div></div>' +
'<p class="desc">' + esc(a.long_description || a.description) + '</p>' +
'<div class="addline"><code>libreportal app add ' + esc(a.slug) + '</code>' +
'<button data-slug="' + esc(a.slug) + '">Copy</button></div>' +
'</div>';
}).join('');
empty.hidden = shown.length > 0;
}
function renderChips(cats) {
var all = ['all'].concat(cats);
chips.innerHTML = all.map(function (c) {
return '<span class="chip' + (c === state.cat ? ' on' : '') + '" data-cat="' + esc(c) + '">' + esc(c) + '</span>';
}).join('');
}
function load(i) {
i = i || 0;
if (i >= CHANNELS.length) { empty.hidden = false; return; }
var ch = CHANNELS[i];
fetch(ch + '/index.json', { cache: 'no-store' })
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (idx) {
state.apps = (idx.artifacts || []).filter(function (a) {
return a && a.type === 'app' && a.payload && a.payload.kind === 'bundle' &&
a.applies_when && a.applies_when.app;
}).map(function (a) {
var m = a.meta || {};
return {
slug: a.applies_when.app, title: a.title || a.applies_when.app,
description: m.description || a.why || '',
long_description: m.long_description || '',
category: (m.category || '').toLowerCase(),
trust: a.trust || 'official', version: a.version || 1,
icon: m.icon ? ch + '/payloads/icons/' + a.applies_when.app + '.' + m.icon.split('.').pop() : null
};
});
var cats = [];
state.apps.forEach(function (a) { if (a.category && cats.indexOf(a.category) < 0) cats.push(a.category); });
renderChips(cats.sort());
meta.textContent = state.apps.length + ' app(s) · channel: ' + ch + ' · catalog serial ' + (idx.index_serial || '?') +
' · published ' + (idx.generated_at || '?');
render();
})
.catch(function () { load(i + 1); });
}
chips.addEventListener('click', function (e) {
var c = e.target.getAttribute && e.target.getAttribute('data-cat');
if (!c) return;
state.cat = c;
renderChips(state.apps.reduce(function (acc, a) {
if (a.category && acc.indexOf(a.category) < 0) acc.push(a.category);
return acc;
}, []).sort());
render();
});
grid.addEventListener('click', function (e) {
var slug = e.target.getAttribute && e.target.getAttribute('data-slug');
if (!slug) return;
var txt = 'libreportal app add ' + slug;
(navigator.clipboard ? navigator.clipboard.writeText(txt) : Promise.reject())
.then(function () { e.target.textContent = 'Copied!'; })
.catch(function () { e.target.textContent = txt; })
.finally ? null : null;
setTimeout(function () { e.target.textContent = 'Copy'; }, 1600);
});
document.getElementById('q').addEventListener('input', function (e) {
state.q = e.target.value; render();
});
load();
})();
</script>
</body>
</html>

View File

@ -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/<channel>/ $dest/<channel>/"
}

View File

@ -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 "$@"; }