/* RustProxy Package Registry — dependency-free interactive client */ (() => { 'use strict'; const el = (id) => document.getElementById(id); const CHANNEL_LABELS = {linux:'Linux',suse:'SUSE / openSUSE',opnsense:'OPNsense',freebsd:'FreeBSD'}; const INSTALL_NAMES = {deb:'Debian / Ubuntu',rpm:'RHEL / Rocky / Alma / Fedora',suse:'SUSE / openSUSE / Leap',opnsense:'OPNsense',freebsd:'FreeBSD native'}; const SECTION_LABELS = {overview:'Overview',packages:'Packages & versions',install:'Installation',repository:'Repositories'}; const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); const collator = new Intl.Collator('en', {numeric:true,sensitivity:'base'}); const formatNumber = new Intl.NumberFormat('en-GB'); const site = ['linux','opnsense','freebsd'].includes(document.body.dataset.site) ? document.body.dataset.site : 'linux'; const state = { site, catalog:null, section:'overview', installing:site === 'linux' ? 'deb' : site, channel:site === 'linux' ? 'all' : site, format:'all', search:'', sort:'date', rows:25, refreshing:false, }; const formatDate = (value) => { if (!value) return '—'; const time = new Date(value); if (Number.isNaN(time.valueOf())) return '—'; return new Intl.DateTimeFormat('en-GB', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).format(time); }; const formatShortDate = (value) => { if (!value) return '—'; const time = new Date(value); if (Number.isNaN(time.valueOf())) return '—'; return new Intl.DateTimeFormat('en-GB', {day:'2-digit',month:'short',year:'numeric'}).format(time); }; const formatSize = (value) => { const size = Number(value); if (!Number.isFinite(size) || size < 0) return '—'; if (size < 1024) return `${formatNumber.format(size)} B`; if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KB`; return `${(size / 1024 ** 2).toFixed(1)} MB`; }; const safeLink = (url) => { try { const resolved = new URL(url, window.location.href); if (['http:','https:'].includes(resolved.protocol)) return resolved.href; } catch (_) { /* invalid URL */ } return '#'; }; const origin = (url) => typeof url === 'string' ? url.replace(/\/$/, '') : ''; const dateLabel = (kind) => kind === 'build' ? 'Build timestamp recorded by the builder' : 'File modification time (older builds without a build timestamp)'; const setText = (id, value) => {const node = el(id);if (node) node.textContent = value;}; const versionText = (b) => b?.app_version ? `v${b.app_version}` : '—'; let toastTimer; function toast(message) { const node = el('toast'); node.textContent = message; node.hidden = false; window.clearTimeout(toastTimer); toastTimer = window.setTimeout(() => {node.hidden = true;}, 2700); } function setTheme(theme) { const target = ['dark','light','cream'].includes(theme) ? theme : 'dark'; document.documentElement.dataset.theme = target; setText('theme-name', {dark:'Dark',light:'Light',cream:'Cream'}[target]); try {localStorage.setItem('rustproxy-repo-theme', target);} catch (_) { /* optional */ } } function navigate(section) { const next = SECTION_LABELS[section] ? section : 'overview'; state.section = next; document.querySelectorAll('.view').forEach((view) => {view.hidden = view.id !== `view-${next}`;}); document.querySelectorAll('[data-section]').forEach((btn) => { const active = btn.dataset.section === next; btn.classList.toggle('active', active); if (active) btn.setAttribute('aria-current','page'); else btn.removeAttribute('aria-current'); }); setText('crumb-section', SECTION_LABELS[next]); if (window.location.hash !== `#${next}`) history.replaceState(null, '', `${location.pathname}${location.search}#${next}`); window.scrollTo({top:0,behavior:'instant'}); } function buildCard(build, channel) { const live = !!build?.published; const commit = build?.source_commit ? build.source_commit.slice(0,9) : '—'; const subtitle = channel === 'linux' ? 'APT / DNF · amd64 + x86_64' : channel === 'suse' ? 'RPM / Zypper · x86_64' : channel === 'opnsense' ? `PKG · ${build?.opnsense_series || 'FreeBSD'}` : `PKG · ${build?.freebsd_abi || 'Native'}`; const version = build?.package_version || (live ? build?.app_version : '') || '—'; return `
${esc(CHANNEL_LABELS[channel].toUpperCase())} / ${['linux','suse'].includes(channel) ? 'SIGNED' : 'PKG'}${live ? 'PUBLISHED' : 'NOT BUILT'}
${esc(version)}
${esc(subtitle)}
BUILD DATE${esc(formatDate(build?.built_at))}
COMMIT${esc(commit)}
`; } function formatBadge(pkg) { return `${esc(pkg.format)}`; } function tableRow(pkg, compact = false) { const download = `${compact ? 'Download' : 'Download file'} ↗`; const first = `${esc(pkg.name)}${esc(pkg.architecture)}`; const format = `${formatBadge(pkg)}${esc(CHANNEL_LABELS[pkg.channel] || pkg.channel)}`; const date = `${esc(formatDate(pkg.built_at))}`; if (compact) return `${first}${format}${esc(pkg.version)}${date}${download}`; return `${first}${format}${esc(pkg.version)}${date}${esc(formatSize(pkg.size_bytes))}${download}`; } function renderOverview(data) { const main = data.builds?.[state.site]; const heroVersion = versionText(main); setText('hero-version', heroVersion); setText('hero-channel', state.site.toUpperCase()); setText('hero-date', formatDate(main?.built_at)); setText('hero-commit', main?.source_commit ? main.source_commit.slice(0,12) : '—'); el('hero-date').title = dateLabel(main?.date_source); setText('stat-packages', formatNumber.format(data.summary?.packages_count || 0)); setText('stat-channels', `${data.summary?.channels_published || 0} / 4`); setText('stat-date', formatShortDate(data.summary?.latest_build_at)); setText('stat-version', heroVersion); setText('stat-release', main?.package_version ? `Package: ${main.package_version}` : 'No release published yet'); setText('stat-date-sub', 'Latest publication date'); setText('pipeline-updated', formatDate(data.generated_at)); const ready = !!data.ready; el('hero-status').innerHTML = ` ${ready ? 'REPOSITORY ONLINE' : 'WAITING FOR BUILD'}`; el('hero-status').classList.toggle('waiting', !ready); el('sidebar-status').classList.toggle('waiting', !ready); el('sidebar-status').querySelector('span').textContent = ready ? 'REPOSITORY ONLINE' : 'WAITING FOR DATA'; const side = el('side-channels'); side.innerHTML = ['linux','suse','opnsense','freebsd'].map((channel) => `${esc(CHANNEL_LABELS[channel])}${channel === 'linux' ? 'APT / DNF' : channel === 'suse' ? 'RPM / Zypper' : 'PKG'}`).join(''); el('build-grid').innerHTML = ['linux','suse','opnsense','freebsd'].map((channel) => buildCard(data.builds[channel],channel)).join(''); const recent = data.packages.filter((pkg) => state.site === 'linux' || pkg.channel === state.site).slice(0,6); el('recent-rows').innerHTML = recent.length ? recent.map((pkg) => tableRow(pkg,true)).join('') : 'No packages published yet. The catalog will populate after the first build.'; } function filteredPackages() { const arr = state.catalog?.packages || []; const search = state.search.trim().toLocaleLowerCase('en-GB'); const filtered = arr.filter((pkg) => { if (state.channel !== 'all' && pkg.channel !== state.channel) return false; if (state.format !== 'all' && pkg.format !== state.format) return false; if (!search) return true; return [pkg.name,pkg.version,pkg.channel,pkg.format,pkg.architecture].some((field) => String(field ?? '').toLocaleLowerCase('en-GB').includes(search)); }); if (state.sort === 'name') filtered.sort((a,b) => collator.compare(a.name,b.name) || collator.compare(b.version,a.version)); if (state.sort === 'version') filtered.sort((a,b) => collator.compare(b.version,a.version) || collator.compare(a.name,b.name)); if (state.sort === 'size') filtered.sort((a,b) => b.size_bytes - a.size_bytes); if (state.sort === 'date') filtered.sort((a,b) => String(b.built_at).localeCompare(String(a.built_at)) || collator.compare(b.version,a.version)); return filtered; } function renderPackages() { const data = state.catalog; if (!data) return; const all = filteredPackages(); const shown = all.slice(0,state.rows); setText('packages-total', `${formatNumber.format(data.summary.packages_count)} packages`); setText('filter-count', `${formatNumber.format(all.length)} ${all.length === 1 ? 'result' : 'results'}`); setText('result-range', all.length ? `Showing ${shown.length} of ${all.length}` : 'No matching packages'); el('catalog-rows').innerHTML = shown.length ? shown.map((pkg) => tableRow(pkg)).join('') : 'No packages match these filters. Adjust your search or check back after a new release.'; el('show-more').hidden = shown.length >= all.length; } function instructions(kind, data) { const urls = data.repository_urls || {}; const linux = origin(urls.linux); const suse = origin(urls.suse || `${linux}/suse`); const opnsense = origin(urls.opnsense); const freebsd = origin(urls.freebsd); const distribution = data.apt_distribution || 'stable'; const component = data.apt_component || 'main'; if (kind === 'deb') { return { description:'Add the signed APT repository, update the package list, and install RustProxy.', signature:'GPG SIGNED', note:'Supported: Debian / Ubuntu · amd64 architecture.', reference:`${linux}/apt/dists/${encodeURIComponent(distribution)}/InRelease`, agent:'sudo apt install rustproxy-docker-agent', code:[ `curl -fsSL ${linux}/repo-key.gpg \\`, ' | sudo gpg --dearmor -o /usr/share/keyrings/rustproxy.gpg', '', `echo "deb [signed-by=/usr/share/keyrings/rustproxy.gpg] ${linux}/apt ${distribution} ${component}" \\`, ' | sudo tee /etc/apt/sources.list.d/rustproxy.list', '', 'sudo apt update', 'sudo apt install rustproxy', ].join('\n'), }; } if (kind === 'rpm') { return { description:'Add the signed DNF/YUM repository and install the RustProxy server.', signature:'GPG SIGNED', note:'Supported: RHEL / Rocky / Alma / Fedora · x86_64 architecture.', reference:`${linux}/rpm/x86_64/repodata/repomd.xml`, agent:'sudo dnf install rustproxy-docker-agent', code:[ `sudo rpm --import ${linux}/repo-key.gpg`, '', "sudo tee /etc/yum.repos.d/rustproxy.repo > /dev/null <<'REPO'", '[rustproxy]', 'name=RustProxy Repository', `baseurl=${linux}/rpm/$basearch`, 'enabled=1', 'gpgcheck=1', 'repo_gpgcheck=1', `gpgkey=${linux}/repo-key.gpg`, 'REPO', '', 'sudo dnf install rustproxy', ].join('\n'), }; } if (kind === 'suse') { return { description:'Add the signed RPM repository with Zypper on SUSE Linux Enterprise, openSUSE Leap or Tumbleweed.', signature:'GPG SIGNED', note:'x86_64. Minimum glibc: 2.34; intended for SLES 15 SP6+, SLES 16+, Leap 15.6+, and Tumbleweed. Check Python >= 3.10 dependencies.', reference:`${suse}/x86_64/repodata/repomd.xml`, agent:'sudo zypper install rustproxy-docker-agent', code:[ `sudo rpm --import ${linux}/repo-key.gpg`, `sudo zypper addrepo --refresh --gpgcheck ${suse}/x86_64/ rustproxy-suse`, 'sudo zypper refresh rustproxy-suse', 'sudo zypper install rustproxy', ].join('\n'), }; } if (kind === 'opnsense') { return { description:'Fetch the pkg repository configuration and install the os-rustproxy plugin.', signature:'UNSIGNED PKG', note:'The pkg repository index is unsigned. Only use a trusted HTTPS host.', reference:`${opnsense}/RustProxy.conf`, agent:null, code:[ 'mkdir -p /usr/local/etc/pkg/repos', `fetch -o /usr/local/etc/pkg/repos/RustProxy.conf ${opnsense}/RustProxy.conf`, 'pkg update -f', 'pkg install os-rustproxy', ].join('\n'), }; } return { description:'Install the native rustproxy package and enable its rc.d service.', signature:'UNSIGNED PKG', note:'The FreeBSD pkg index is unsigned. Only use a trusted HTTPS source.', reference:`${freebsd}/RustProxy-FreeBSD.conf`, agent:null, code:[ 'mkdir -p /usr/local/etc/pkg/repos', `fetch -o /usr/local/etc/pkg/repos/RustProxy-FreeBSD.conf ${freebsd}/RustProxy-FreeBSD.conf`, 'pkg update -f', 'pkg install rustproxy', 'sysrc rustproxy_enable=YES', 'service rustproxy start', ].join('\n'), }; } function renderInstall() { if (!state.catalog) return; const kind = state.installing; const instructionsData = instructions(kind,state.catalog); document.querySelectorAll('[data-install]').forEach((node) => { const active = node.dataset.install === kind; node.classList.toggle('active',active); node.setAttribute('aria-selected', String(active)); }); setText('install-tab-title', INSTALL_NAMES[kind]); setText('install-description', instructionsData.description); setText('install-signature', instructionsData.signature); el('install-signature').classList.toggle('live', instructionsData.signature === 'GPG SIGNED'); setText('install-command', instructionsData.code); setText('install-note', instructionsData.note); // Docker discovery agent is only built for Linux DEB/RPM. const hasDockerAgent = (kind === 'deb' || kind === 'rpm' || kind === 'suse') && !!instructionsData.agent; el('agent-card').hidden = !hasDockerAgent; el('install-extra-grid').classList.toggle('single-card', !hasDockerAgent); setText('trust-index', hasDockerAgent ? '02' : '01'); setText('agent-command', hasDockerAgent ? instructionsData.agent : ''); el('install-reference').href = safeLink(instructionsData.reference); } function endpointsCard(name, label, url, links, signed) { return `

${esc(name)}

${esc(label)}
${esc(url)} ↗
`; } function renderRepositories() { const data = state.catalog; if (!data) return; const url = data.repository_urls; const distribution = encodeURIComponent(data.apt_distribution || 'stable'); el('endpoints-grid').innerHTML = [ endpointsCard('Linux','GPG SIGNED',url.linux,[ {name:'GPG public key',url:`${url.linux}/repo-key.gpg`}, {name:'APT InRelease',url:`${url.linux}/apt/dists/${distribution}/InRelease`}, {name:'RPM repomd.xml',url:`${url.linux}/rpm/x86_64/repodata/repomd.xml`}, {name:'Build info',url:`${url.linux}/LINUX-BUILD-INFO.txt`}, ],true), endpointsCard('SUSE / openSUSE','GPG SIGNED',url.suse,[ {name:'GPG public key',url:`${url.linux}/repo-key.gpg`}, {name:'RPM repomd.xml',url:`${url.suse}/x86_64/repodata/repomd.xml`}, {name:'Metadata signature',url:`${url.suse}/x86_64/repodata/repomd.xml.asc`}, {name:'Build info',url:`${url.linux}/SUSE-BUILD-INFO.txt`}, ],true), endpointsCard('OPNsense','UNSIGNED PKG',url.opnsense,[ {name:'RustProxy.conf',url:`${url.opnsense}/RustProxy.conf`}, {name:'meta.conf',url:`${url.opnsense}/meta.conf`}, {name:'packagesite.pkg',url:`${url.opnsense}/packagesite.pkg`}, {name:'Build info',url:`${url.opnsense}/BUILD-INFO.txt`}, ],false), endpointsCard('FreeBSD','UNSIGNED PKG',url.freebsd,[ {name:'RustProxy-FreeBSD.conf',url:`${url.freebsd}/RustProxy-FreeBSD.conf`}, {name:'meta.conf',url:`${url.freebsd}/meta.conf`}, {name:'packagesite.pkg',url:`${url.freebsd}/packagesite.pkg`}, {name:'Build info',url:`${url.freebsd}/BUILD-INFO.txt`}, ],false), ].join(''); el('metadata-table').innerHTML = ['linux','suse','opnsense','freebsd'].map((channel) => { const b = data.builds[channel] || {}; const rows = [ ['APPLICATION VERSION',b.app_version || '—','latest-value'], ['PACKAGE VERSION',b.package_version || '—',''], ['BUILD DATE',formatDate(b.built_at),''], ['SOURCE COMMIT',b.source_commit || '—',''], ['PACKAGING REVISION',b.packaging_revision || '—',''], ]; return `

${esc(CHANNEL_LABELS[channel])}

${rows.map(([label,value,klass]) => `
${esc(label)}${esc(value)}
`).join('')}
`; }).join(''); } function renderAll(data) { state.catalog = data; renderOverview(data); renderPackages(); renderInstall(); renderRepositories(); setText('last-sync', formatDate(data.generated_at)); el('last-sync').innerHTML = ` ${esc(formatDate(data.generated_at))}`; } async function refresh() { if (state.refreshing) return; state.refreshing = true; el('refresh').classList.add('busy'); el('refresh').disabled = true; try { const response = await fetch('/api/v1/packages', {cache:'no-store',headers:{'Accept':'application/json'}}); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); if (!Array.isArray(data.packages) || !data.builds || !data.summary) throw new Error('Invalid catalog'); renderAll(data); el('error-banner').hidden = true; } catch (err) { el('error-banner').hidden = false; setText('last-sync','Load failed'); console.error('RustProxy repository:',err); } finally { state.refreshing = false; el('refresh').classList.remove('busy'); el('refresh').disabled = false; } } async function copyText(text) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { const input = document.createElement('textarea'); input.value = text; input.setAttribute('readonly',''); input.className = 'clipboard-helper'; document.body.appendChild(input); input.focus();input.select(); const copied = document.execCommand('copy'); input.remove(); if (!copied) throw new Error('Clipboard unsupported'); } toast('Copied to clipboard'); } catch (_) { toast('Copy failed. Select and copy the command manually.'); } } function init() { setText('year',new Date().getFullYear()); try {setTheme(localStorage.getItem('rustproxy-repo-theme') || 'dark');} catch (_) {setTheme('dark');} document.querySelectorAll('[data-section]').forEach((button) => button.addEventListener('click',() => navigate(button.dataset.section))); document.querySelectorAll('[data-go-section]').forEach((button) => button.addEventListener('click',() => navigate(button.dataset.goSection))); document.querySelectorAll('[data-install]').forEach((button) => button.addEventListener('click',() => {state.installing = button.dataset.install;renderInstall();})); document.querySelectorAll('[data-copy-target]').forEach((button) => button.addEventListener('click',() => copyText(el(button.dataset.copyTarget)?.textContent || ''))); el('theme-toggle').addEventListener('click',() => { const themes = ['dark','light','cream']; const i = themes.indexOf(document.documentElement.dataset.theme); setTheme(themes[(i+1)%themes.length]); }); el('refresh').addEventListener('click',() => {refresh();}); el('retry-inline').addEventListener('click',() => {refresh();}); el('search-input').addEventListener('input',(event) => {state.search = event.target.value;state.rows = 25;renderPackages();}); el('filter-channel').value = state.channel; el('filter-channel').addEventListener('change',(event) => {state.channel = event.target.value;state.rows = 25;renderPackages();}); el('filter-format').addEventListener('change',(event) => {state.format = event.target.value;state.rows = 25;renderPackages();}); el('filter-sort').addEventListener('change',(event) => {state.sort = event.target.value;state.rows = 25;renderPackages();}); el('show-more').addEventListener('click',() => {state.rows += 25;renderPackages();}); window.addEventListener('hashchange',() => navigate(window.location.hash.slice(1))); navigate(window.location.hash.slice(1) || 'overview'); refresh(); window.setInterval(() => {if (!document.hidden) refresh();}, 60000); document.addEventListener('visibilitychange',() => {if (!document.hidden) refresh();}); } init(); })();