diff --git a/service/pages-browse.js b/service/pages-browse.js new file mode 100644 index 0000000..b20f434 --- /dev/null +++ b/service/pages-browse.js @@ -0,0 +1,303 @@ +/** + * 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'; + +/* ------------------------------- 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 `
+ ${CATEGORIES.map((c) => { + const n = counts[c.slug]; + const emptyCls = n === 0 ? ' empty' : ''; + const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`; + return ` +
${c.icon}
+

${escapeHtml(c.title)}

+ ${verbose ? `

${escapeHtml(c.blurb)}

` : ''} + ${countLabel} +
`; + }).join('\n ')} +
`; +} + +/* ------------------------------- 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: ` +
+
${cat.icon}
+

${escapeHtml(title)}

+

${escapeHtml(cat.blurb)}

+
+ +
+ +
+

+ ${rows.length === 0 + ? `No listings in this category yet. Be the first.` + : `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`} +

+ + ${rows.length === 0 ? emptyCategory(cat) : renderListingList(rows)} + +

+ โ† All categories +

+
+ +
`, + }; +} + +/* ------------------------------- 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 + ? `${cat.icon} ${escapeHtml(cat.title)}` + : ''; + + 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 iconHtml = row.icon + ? `` + : `
${cat?.icon || '๐Ÿ“ฆ'}
`; + + return { + title: `${row.name} by ${row.publisher}`, + description: row.description.slice(0, 200), + here: `/app/${row.publisher}/${row.slug}`, + body: ` +
+
+ ${iconHtml} +
+

${escapeHtml(row.name)}

+
+ by ${escapeHtml(row.publisher)} ยท + v${escapeHtml(row.version)} ยท + ${catChip} +
+
+
+
+ +
+ +
+
+
+
+

Download

+

+ ${formatBytes(row.download_size)} ยท + ${shortSha(row.download_sha256)} + ${scanBadge} +

+
+ Download +
+
+ Full hash + source URL +
+ +
SHA-256 ${row.download_sha256}
+
+
+
+
+ +
+

About

+

${escapeHtml(row.description)}

+ ${row.homepage ? `

Project homepage โ†’

` : ''} +
+ +
+

Who published this

+

Signed by ${escapeHtml(row.publisher)} โ€” a BNS name owned by + ${escapeHtml(row.signer_address)}. 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.

+
+ +
+

Endorsements

+
No endorsements yet. 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.
+
+ +

+ ${cat ? `โ† Back to ${escapeHtml(cat.title)}` : `โ† Browse`} +

+ +
`, + }; +} + +/* ------------------------------- helpers ------------------------------- */ + +function renderListingList(rows) { + return `
+ ${rows.map(renderListingCard).join('\n')} +
`; +} + +function renderListingCard(row) { + const href = `/app/${encodeURIComponent(row.publisher)}/${encodeURIComponent(row.slug)}`; + return ` +

${escapeHtml(row.name)}

+

+ ${escapeHtml(truncate(row.description, 160))} +

+
+ by ${escapeHtml(row.publisher)} ยท v${escapeHtml(row.version)} ยท ${formatBytes(row.download_size)} +
+
`; +} + +function emptyCategory(cat) { + return `
+ Nothing here yet. If you ship software that fits "${escapeHtml(cat.title)}", + the submit flow is live โ€” upload, scan, sign, your listing + appears in this view. +
`; +} + +function renderScanBadge(verdict, payload) { + const engine = payload?.scan?.engine || ''; + const sigs = payload?.scan?.signatures || []; + switch (verdict) { + case 'clean': + return `scan โœ“ clean${engine ? ` (${escapeHtml(engine)})` : ''}`; + case 'infected': + return `scan โš  infected`; + default: + return `scan ? ${escapeHtml(verdict)}`; + } +} + +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 ''; } +} diff --git a/service/pages.js b/service/pages.js index a1e29b4..5ab2386 100644 --- a/service/pages.js +++ b/service/pages.js @@ -10,6 +10,35 @@ * as the SEO-friendly mirror. */ +import { renderCategoriesGrid, countsByCategory } from './pages-browse.js'; +import { countActive } from './db.js'; + +/** + * The "Browse by category" section used on the home page. Lives here so the + * home page's inline template reads cleanly, but delegates the actual grid to + * pages-browse.js so a single row of counts drives both surfaces. + */ +function renderHomeBrowseSection() { + const total = countActive(); + return `
+

Browse by category

+

On a conventional store, one company decides which shelf an app + belongs on. Here the publisher labels their own listing, and your trust list + decides whether to show it. The categories below are the initial shape; a new + one gets proposed by publishing a listing against it.

+ + ${renderCategoriesGrid({ verbose: true })} + + ${total === 0 ? ` +
No live listings yet. + The on-chain publication protocol (APS1) is in + progress; in the meantime the site, the scanner, the publisher-signed + listing endpoint and the publishers' walkthrough are up. First real + listings appear in this grid as they publish.
+ ` : ''} +
`; +} + /* ------------------------------- home ------------------------------- */ export function home() { @@ -36,70 +65,7 @@ export function home() {
-
-

Browse by category

-

On a conventional store, one company decides which shelf an app - belongs on. Here the publisher labels their own listing, and your trust list - decides whether to show it. The categories below are the initial shape; a new - one gets proposed by publishing a listing against it.

- - - -
No live listings yet. - The listing protocol (APS1 record on chain) and - the submission flow are being wired now. The site, the scanner, the - publishers' walkthrough and this directory are up; a listing you can click - on and install is next.
-
+${renderHomeBrowseSection()}

How Prometheus works, in a nutshell

@@ -303,6 +269,10 @@ export function publish() { /* ------------------------------- browse ----------------------------- */ export function browse() { + const total = countActive(); + const totalLabel = total === 0 + ? `No listings yet โ€” the eight categories are set and ready to receive publications.` + : `${total} ${total === 1 ? 'listing' : 'listings'} across ${Object.values(countsByCategory()).filter((n) => n > 0).length} ${Object.values(countsByCategory()).filter((n) => n > 0).length === 1 ? 'category' : 'categories'}.`; return { title: 'Browse', description: @@ -318,29 +288,9 @@ export function browse() {
-
No live listings yet. The listing protocol is - being wired; the browse pages below will light up as the APS1 index starts - carrying records. In the meantime, the eight categories are set and ready - to receive publications.
+

${totalLabel}

- + ${renderCategoriesGrid({ verbose: false })}
`, diff --git a/service/server.js b/service/server.js index 4fd1b32..a8bbc14 100644 --- a/service/server.js +++ b/service/server.js @@ -26,6 +26,7 @@ import { handleListingIndex, handleListingGet, } from './listings.js'; +import { categoryPage, appPage } from './pages-browse.js'; const PORT = Number(process.env.PORT) || 3700; const BIND = process.env.BIND || '127.0.0.1'; @@ -88,6 +89,17 @@ const server = createServer(async (req, res) => { await serveHealth(res); } else if (url === '/.well-known/prometheus-service-key.json') { serveServiceKey(res); + } else if (url.startsWith('/c/')) { + const slug = url.slice('/c/'.length); + servePageFn(() => categoryPage(req, slug), res); + } else if (url.startsWith('/app/')) { + // /app// + const parts = url.slice('/app/'.length).split('/').filter(Boolean); + if (parts.length === 2) { + servePageFn(() => appPage(req, parts[0], parts[1]), res); + } else { + servePage(pages.notFound, res, 404, req); + } } else if (PAGE_ROUTES[url]) { servePage(PAGE_ROUTES[url], res); } else { @@ -133,6 +145,25 @@ function normalizeRequestUrl(raw) { */ function servePage(fn, res, status = 200, req = null) { const page = fn(req); + renderPage(page, res, status); +} + +/** + * Render a page object (not a function). Used by routes that take extra + * arguments to produce the page (category slug, app publisher/slug) and + * therefore need to call the generator themselves rather than hand a bare + * reference to servePage. + */ +function servePageFn(produce, res) { + const page = produce(); + // A `notFound()` return has this exact title; routes use it as the convention + // for "the thing you asked for does not exist" so we don't need a parallel + // status plumbing path. + const status = page.title === 'Not here' ? 404 : 200; + renderPage(page, res, status); +} + +function renderPage(page, res, status) { const html = layout(page); res.writeHead(status, { 'content-type': 'text/html; charset=utf-8',