prometheus/service/pages-browse.js
Local Dev 6eeec54266 prometheus: SEO polish and the apps.silentmode.st mirror vhost
layout gained three optional fields: ogType, ogImage, headExtra. The
listing detail page uses all three — ogType=article, ogImage=the icon
URL (fallback to the Prometheus favicon), and headExtra for schema.org
SoftwareApplication JSON-LD. Google renders this as a rich card for
app results; the SEO uplift is real and the data is straightforward.

JSON-LD payload composed in service/seo.js:
  @type SoftwareApplication, name, description, softwareVersion,
  applicationCategory (mapped from Prometheus's slug to schema's name),
  url, downloadUrl, fileSize, offers (free), author (BNS name as org),
  image (icon when present), contentRating ("ClamAV clean" on a clean
  scan).

OG defaults for every page: og:type (website unless overridden),
og:image (favicon unless overridden). The canonical URL stays
prometheus.x on both the live service and the apps.silentmode.st
mirror — crawlers that land on the mirror attribute the content to
the real address, matching the pattern [[silentmode-site-url-scheme]]
describes.

deploy/apps-silentmode-st.conf is the angie vhost for the mirror:
reverse-proxies to 127.0.0.1:3700, declines /admin (operator always
goes direct), 2 GiB client_max_body_size for POST /api/upload,
proxy_request_buffering off so a slow upload does not spool to disk.
Reuses the main silentmode.st certificate (SAN *.silentmode.st; if
that's not set up yet, request one and reload before enabling).
2026-10-08 21:45:59 +02:00

310 lines
12 KiB
JavaScript

/**
* Browse pages — category index and per-listing detail.
*
* Everything here is rendered server-side from the SQLite listings table. No
* client JS, so Googlebot sees the real page, Theseus sees the real page, and
* the s3 snapshot (future milestone #12) is just the same HTML written to disk.
*
* These pages are also where the "0 listings" messaging on the home page and
* /browse flips to real counts — `countsByCategory()` is exported here and
* imported by pages.js so the two render surfaces agree on what is live.
*/
import {
listingsByCategory,
getListing,
countByCategory as countByCategoryDb,
} from './db.js';
import { escapeHtml } from './layout.js';
import { notFound } from './pages.js';
import { softwareApplicationLd } from './seo.js';
/* ------------------------------- categories ------------------------------- */
/**
* The authoritative metadata for the eight categories — icon, title, and the
* blurb that shows on the home grid and at the top of the category page. One
* table so a copy change is a one-liner rather than scattered across templates.
*
* Adding a new category means adding a row here AND adding the slug to the
* allow-list in listings.js. The browse grid picks up the new row
* automatically.
*/
export const CATEGORIES = [
{ slug: 'communication', icon: '💬', title: 'Communication',
blurb: `Messengers, voice, boards, group chats. Clients that route through peers rather than someone else's server.` },
{ slug: 'privacy', icon: '🛡️', title: 'Privacy & security',
blurb: `Tor and mix-net tooling, key and vault managers, disk crypto. The things a conventional store delists first when a government writes.` },
{ slug: 'wallets', icon: '💸', title: 'Wallets & payments',
blurb: `BCH wallets, cross-chain bridges, point-of-sale terminals. The chain publishes the listing and the chain settles the payment.` },
{ slug: 'games', icon: '🎮', title: 'Games',
blurb: `Standalone titles, on-chain leaderboards, in-app purchases that reach the publisher instead of a 30% toll.` },
{ slug: 'devtools', icon: '🧰', title: 'Developer tools',
blurb: `SDKs, libraries, indexers, faucets, protocol utilities. The pieces other apps on this list are built out of.` },
{ slug: 'media', icon: '🎧', title: 'Media & publishing',
blurb: `Readers, players, one-file blogs, podcast hosts. Clients that find their audience without going through an aggregator.` },
{ slug: 'utilities', icon: '⚙️', title: 'Utilities',
blurb: `File tools, format converters, backup, calculators. The small sharp things that solve one problem and do not ask for an account first.` },
{ slug: 'other', icon: '✨', title: 'Everything else',
blurb: `Anything that does not fit above. New categories are proposed in-band — any listing can declare one and clients will offer to add it.` },
];
const CATEGORY_BY_SLUG = Object.fromEntries(CATEGORIES.map((c) => [c.slug, c]));
/**
* {slug: count} for every category — categories with 0 listings are present
* with the value 0, so templates can read them unconditionally.
*/
export function countsByCategory() {
const raw = countByCategoryDb();
const out = {};
for (const c of CATEGORIES) out[c.slug] = raw[c.slug] || 0;
return out;
}
/**
* Shared category grid, used on the home page and on /browse.
*
* `verbose` controls whether each card carries its blurb. Home page wants it;
* the dedicated browse page already has the blurb at the top and does not
* need to repeat it on every card.
*/
export function renderCategoriesGrid({ verbose = true } = {}) {
const counts = countsByCategory();
return `<div class="categories">
${CATEGORIES.map((c) => {
const n = counts[c.slug];
const emptyCls = n === 0 ? ' empty' : '';
const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`;
return `<a class="cat${emptyCls}" href="/c/${c.slug}">
<div class="ico">${c.icon}</div>
<h3>${escapeHtml(c.title)}</h3>
${verbose ? `<p>${escapeHtml(c.blurb)}</p>` : ''}
<span class="count">${countLabel}</span>
</a>`;
}).join('\n ')}
</div>`;
}
/* ------------------------------- category page ------------------------------- */
export function categoryPage(req, slug) {
const cat = CATEGORY_BY_SLUG[slug];
if (!cat) return notFound(req);
const rows = listingsByCategory(slug);
const title = cat.title;
const here = `/c/${slug}`;
return {
title,
description: `${cat.title} apps on Prometheus — ${cat.blurb}`,
here,
body: `
<header class="hero compact">
<div class="mark" style="font-size:40px">${cat.icon}</div>
<h1>${escapeHtml(title)}</h1>
<p class="tag">${escapeHtml(cat.blurb)}</p>
</header>
<div class="wrap">
<section class="plain">
<p class="lede">
${rows.length === 0
? `No listings in this category yet. <a href="/submit">Be the first</a>.`
: `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
</p>
${rows.length === 0 ? emptyCategory(cat) : renderListingList(rows)}
<p style="margin-top:1.8rem;color:var(--dim);font-size:13px">
<a href="/browse">← All categories</a>
</p>
</section>
</div>`,
};
}
/* ------------------------------- app detail page ------------------------------- */
export function appPage(req, publisher, slug) {
const id = `${String(publisher).toLowerCase()}/${String(slug).toLowerCase()}`;
const row = getListing(id);
if (!row || row.withdrawn_at) return notFound(req);
const cat = CATEGORY_BY_SLUG[row.category];
const catChip = cat
? `<a href="/c/${cat.slug}" class="pill p-upcoming" style="text-decoration:none">${cat.icon} ${escapeHtml(cat.title)}</a>`
: '';
const scanReport = safeParse(row.scan_report);
const scanPayload = scanReport?.payload ? safeParse(fromBase64Url(scanReport.payload)) : null;
const verdict = scanPayload?.scan?.verdict || 'unknown';
const scanBadge = renderScanBadge(verdict, scanPayload);
const ld = softwareApplicationLd({ listing: row, scanVerdict: verdict });
const headExtra = `<script type="application/ld+json">${ld}</script>`;
const iconHtml = row.icon
? `<img src="${escapeAttr(row.icon)}" alt="" width="80" height="80"
style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);object-fit:cover">`
: `<div style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);display:grid;place-items:center;font-size:36px">${cat?.icon || '📦'}</div>`;
return {
title: `${row.name} by ${row.publisher}`,
description: row.description.slice(0, 200),
here: `/app/${row.publisher}/${row.slug}`,
ogType: 'article',
ogImage: row.icon || 'https://prometheus.x/static/favicon.svg',
headExtra,
body: `
<header class="hero compact" style="padding-bottom:1rem">
<div style="display:flex;gap:18px;align-items:center;justify-content:center;flex-wrap:wrap;margin-bottom:.8rem">
${iconHtml}
<div style="text-align:left">
<h1 style="margin:0;font-size:1.8rem">${escapeHtml(row.name)}</h1>
<div style="color:var(--mut);font-size:14px;margin-top:.3rem">
by <b style="color:var(--ink)">${escapeHtml(row.publisher)}</b> ·
v${escapeHtml(row.version)} ·
${catChip}
</div>
</div>
</div>
</header>
<div class="wrap">
<section class="plain">
<div class="card" style="padding:22px 26px">
<div style="display:flex;gap:1rem;align-items:center;justify-content:space-between;flex-wrap:wrap">
<div>
<h3 style="margin:0 0 .3rem;font-size:1.05rem">Download</h3>
<p style="margin:0;color:var(--mut);font-size:13px">
${formatBytes(row.download_size)} ·
<code class="inline" title="SHA-256">${shortSha(row.download_sha256)}</code>
${scanBadge}
</p>
</div>
<a class="btn acid" href="${escapeAttr(row.download_url)}" rel="noopener">Download</a>
</div>
<details style="margin-top:1rem">
<summary style="color:var(--dim);font-size:13px;cursor:pointer">Full hash + source URL</summary>
<div style="margin-top:.6rem;font-size:13px;color:var(--mut)">
<div><b style="color:var(--ink)">URL</b> <a href="${escapeAttr(row.download_url)}" rel="noopener">${escapeHtml(row.download_url)}</a></div>
<div style="margin-top:.4rem"><b style="color:var(--ink)">SHA-256</b> <code class="inline">${row.download_sha256}</code></div>
</div>
</details>
</div>
</section>
<section>
<h2>About</h2>
<p style="white-space:pre-wrap">${escapeHtml(row.description)}</p>
${row.homepage ? `<p style="margin-top:1rem"><a href="${escapeAttr(row.homepage)}" rel="noopener">Project homepage →</a></p>` : ''}
</section>
<section>
<h2>Who published this</h2>
<p>Signed by <b>${escapeHtml(row.publisher)}</b> — a BNS name owned by
<code class="inline">${escapeHtml(row.signer_address)}</code>. Any installer
the chain says belongs to that key is trusted by your client; any client
that follows a different trust list will not see it as trusted.</p>
</section>
<section>
<h2>Endorsements</h2>
<div class="note"><b>No endorsements yet.</b> An endorsement is a signed
on-chain statement from any BNS name saying "I vouch for this app." Readers
whose trust list includes the endorser see the endorsement; those who do
not, do not. The endorsement protocol lands with APS1.</div>
</section>
<p style="margin-top:1.8rem;color:var(--dim);font-size:13px">
${cat ? `<a href="/c/${cat.slug}">← Back to ${escapeHtml(cat.title)}</a>` : `<a href="/browse">← Browse</a>`}
</p>
</div>`,
};
}
/* ------------------------------- helpers ------------------------------- */
function renderListingList(rows) {
return `<div class="grid" style="margin-top:1.2rem">
${rows.map(renderListingCard).join('\n')}
</div>`;
}
function renderListingCard(row) {
const href = `/app/${encodeURIComponent(row.publisher)}/${encodeURIComponent(row.slug)}`;
return `<a class="card" href="${href}"
style="text-decoration:none;color:inherit;display:flex;flex-direction:column;gap:.5rem">
<h3 style="margin:0">${escapeHtml(row.name)}</h3>
<p style="margin:0;color:var(--mut);font-size:13.5px;line-height:1.5">
${escapeHtml(truncate(row.description, 160))}
</p>
<div style="color:var(--dim);font-size:12px;margin-top:auto;padding-top:.5rem">
by ${escapeHtml(row.publisher)} · v${escapeHtml(row.version)} · ${formatBytes(row.download_size)}
</div>
</a>`;
}
function emptyCategory(cat) {
return `<div class="note">
<b>Nothing here yet.</b> If you ship software that fits "${escapeHtml(cat.title)}",
the <a href="/submit">submit flow</a> is live — upload, scan, sign, your listing
appears in this view.
</div>`;
}
function renderScanBadge(verdict, payload) {
const engine = payload?.scan?.engine || '';
const sigs = payload?.scan?.signatures || [];
switch (verdict) {
case 'clean':
return `<span class="pill p-live" style="margin-left:.4rem">scan ✓ clean${engine ? ` (${escapeHtml(engine)})` : ''}</span>`;
case 'infected':
return `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken);margin-left:.4rem" title="${escapeAttr(sigs.join(', '))}">scan ⚠ infected</span>`;
default:
return `<span class="pill p-upcoming" style="margin-left:.4rem">scan ? ${escapeHtml(verdict)}</span>`;
}
}
function formatBytes(n) {
const num = Number(n) || 0;
if (num < 1024) return `${num} B`;
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KiB`;
if (num < 1024 * 1024 * 1024) return `${(num / 1024 / 1024).toFixed(1)} MiB`;
return `${(num / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}
function shortSha(sha) {
if (!sha) return '';
const s = String(sha);
if (s.length <= 16) return s;
return `${s.slice(0, 8)}…${s.slice(-6)}`;
}
function truncate(s, max) {
const str = String(s || '');
if (str.length <= max) return str;
return str.slice(0, max - 1) + '…';
}
function escapeAttr(s) {
// Same as text escape; attributes are also always wrapped in double-quotes.
return escapeHtml(s);
}
function safeParse(s) {
if (!s) return null;
if (typeof s === 'object') return s;
try { return JSON.parse(s); } catch { return null; }
}
function fromBase64Url(s) {
try { return Buffer.from(String(s), 'base64url').toString('utf8'); }
catch { return ''; }
}