prometheus: scaffold the standalone service — pages, routing, static assets
New top-level Prometheus/ directory holds the Node service that will own prometheus.x once the on-chain record flips from the silentmode.st redirect to ip+tls. Nothing dynamic yet — this milestone delivers the site skeleton and proves the family's visual conventions port cleanly to a dynamic server. Shape: Prometheus/service/server.js node:http listener + map-based router Prometheus/service/layout.js document shell, topnav, footer Prometheus/service/pages.js home / browse / submit / publish / boundaries Prometheus/public/style.css ported silentmode.st tokens + category grid Prometheus/public/favicon.svg flame mark Routes: /, /browse, /submit, /publish, /boundaries, /static/*. Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay, /admin/*) land here in later milestones per the architecture doc. Launch config .claude/launch.json gets a prometheus-service entry on port 3700 so the preview harness can run it. The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left in place for the next session to delete deliberately — the architecture doc marks it as abandoned for prometheus.x but keeps the Sirius Press infra fixes it carried in.
This commit is contained in:
commit
2c5df40d77
8 changed files with 1031 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
db.sqlite
|
||||
db.sqlite-journal
|
||||
db.sqlite-wal
|
||||
db.sqlite-shm
|
||||
build/
|
||||
uploads/
|
||||
38
README.md
Normal file
38
README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Prometheus
|
||||
|
||||
The service behind `prometheus.x` — a standalone website that accepts app
|
||||
listings, scans the binaries, generates APS1 records for publishers to sign,
|
||||
and serves the browse pages.
|
||||
|
||||
The architecture decisions and the reasoning behind them live at
|
||||
[`_coordination/sessions/prometheus-x-architecture.md`](../_coordination/sessions/prometheus-x-architecture.md).
|
||||
Read that before touching the code; it answers the questions this directory
|
||||
otherwise invites ("why isn't this on Sia?", "why isn't the admin on another
|
||||
subdomain?", "why Node?").
|
||||
|
||||
```
|
||||
service/ Node HTTP server, templates, scanner, storage, payments
|
||||
public/ Static assets served under /static/
|
||||
content/ Prose content in a form the templates render into pages
|
||||
```
|
||||
|
||||
## Run it locally
|
||||
|
||||
```bash
|
||||
cd Prometheus
|
||||
npm install # none yet; placeholder for when deps show up
|
||||
node service/server.js
|
||||
```
|
||||
|
||||
Opens on `http://127.0.0.1:3700` by default. Override with `PORT=xxxx`.
|
||||
|
||||
## Layout
|
||||
|
||||
The server is one `node:http` listener behind a trivial map-based router.
|
||||
Pages are plain functions that return `{title, description, body}` and
|
||||
`layout()` wraps them in the common document shell. Static files under
|
||||
`public/` are served directly.
|
||||
|
||||
There's no framework and no templating engine on purpose — a site this
|
||||
small does not earn a dependency surface, and the response-rendering path
|
||||
is short enough to read at a glance.
|
||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "prometheus",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "The service behind prometheus.x",
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node service/server.js"
|
||||
}
|
||||
}
|
||||
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
|
||||
<path d="M32 10 C 22 22, 24 32, 32 36 C 40 32, 42 22, 32 10 Z" fill="#d6ff3d"/>
|
||||
<path d="M26 36 C 20 44, 22 54, 32 56 C 42 54, 44 44, 38 36 C 36 42, 28 42, 26 36 Z" fill="#d6ff3d" opacity=".75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
174
public/style.css
Normal file
174
public/style.css
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/* Prometheus — design tokens and component styles.
|
||||
Mirrors site/shared.css so a colour change propagates across the Silent Mode
|
||||
family. The Prometheus-only additions (`.categories`, `.cat`) are at the end
|
||||
of the file so the shared block stays diffable against the main site. */
|
||||
|
||||
:root{
|
||||
--bg:#0b0e14;
|
||||
--panel:#141a24;
|
||||
--panel2:#18202c;
|
||||
--line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1;
|
||||
--mut:#8b98a9;
|
||||
--dim:#7a8496;
|
||||
--acid:#d6ff3d;
|
||||
--chain:#d6ff3d;
|
||||
--sia:#b39ddb;
|
||||
--srv:#4fd1a5;
|
||||
--tor:#a98bd8;
|
||||
--ok:#4fd1a5;
|
||||
--taken:#f6768a;
|
||||
--warn:#ffc75f;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
margin:0;
|
||||
background:radial-gradient(1100px 560px at 50% -12%,#16202e,var(--bg));
|
||||
color:var(--ink);
|
||||
font:16px/1.65 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||||
min-height:100vh;
|
||||
}
|
||||
a{color:var(--acid)}
|
||||
|
||||
/* ============ top nav ============ */
|
||||
.topnav{
|
||||
position:sticky;top:0;z-index:20;
|
||||
display:flex;gap:2px;align-items:center;flex-wrap:wrap;
|
||||
padding:10px 1.2rem;
|
||||
background:rgba(11,14,20,.82);
|
||||
backdrop-filter:blur(10px);
|
||||
border-bottom:1px solid var(--line);
|
||||
}
|
||||
.topnav .brand{
|
||||
font-weight:600;color:var(--ink);text-decoration:none;
|
||||
margin-right:14px;font-size:15px;letter-spacing:.2px;
|
||||
}
|
||||
.topnav .brand .g{color:var(--acid)}
|
||||
.topnav a{
|
||||
color:var(--mut);text-decoration:none;
|
||||
padding:9px 12px;border-radius:8px;font-size:14px;
|
||||
}
|
||||
.topnav a:hover{color:var(--ink);background:var(--panel)}
|
||||
.topnav a.here{color:var(--acid);background:rgba(214,255,61,.08)}
|
||||
|
||||
/* ============ layout ============ */
|
||||
.wrap{max-width:980px;margin:0 auto;padding:0 1.2rem}
|
||||
.wrap.narrow{max-width:820px}
|
||||
|
||||
header.hero{text-align:center;padding:4.5rem 1.2rem 2.4rem}
|
||||
header.hero.compact{padding:3rem 1.2rem 1.4rem}
|
||||
header.hero .mark{font-size:58px;line-height:1}
|
||||
h1{font-size:clamp(2rem,5vw,3rem);margin:.5rem 0 .3rem;letter-spacing:.4px}
|
||||
h1 .g{color:var(--acid)}
|
||||
.tag{color:var(--mut);font-size:1.1rem;max-width:660px;margin:.4rem auto 0}
|
||||
|
||||
section{padding:2.8rem 0;border-top:1px solid var(--line)}
|
||||
section.plain{border-top:none}
|
||||
h2{font-size:1.55rem;margin:0 0 .4rem}
|
||||
h3{font-size:1.05rem;margin:0 0 .3rem}
|
||||
.lede{color:var(--mut);margin:0 0 1.6rem;max-width:680px}
|
||||
|
||||
.cta{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:1.7rem}
|
||||
|
||||
/* ============ controls ============ */
|
||||
.btn{
|
||||
display:inline-block;text-decoration:none;
|
||||
padding:11px 20px;border-radius:999px;
|
||||
background:var(--panel2);color:var(--ink);font-size:14.5px;
|
||||
border:1px solid var(--line);cursor:pointer;font-family:inherit;
|
||||
}
|
||||
.btn.ghost{background:transparent;border-color:var(--line);color:var(--ink)}
|
||||
.btn.acid{background:var(--acid);color:#0b0e14;font-weight:600;border:none}
|
||||
.btn:hover{filter:brightness(1.12)}
|
||||
.btn:disabled{opacity:.45;cursor:not-allowed;filter:none}
|
||||
|
||||
/* ============ cards & grids ============ */
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
|
||||
.card{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
||||
padding:18px 20px;
|
||||
}
|
||||
.card p{margin:0;color:var(--mut);font-size:14px}
|
||||
.card h3{display:flex;align-items:center;gap:8px}
|
||||
|
||||
/* status pills */
|
||||
.pill{
|
||||
display:inline-block;font-size:10.5px;letter-spacing:.04em;
|
||||
text-transform:uppercase;padding:2px 9px;border-radius:999px;margin-bottom:.6rem;
|
||||
}
|
||||
.p-live{background:rgba(79,209,165,.14);color:var(--srv)}
|
||||
.p-alpha{background:rgba(214,255,61,.13);color:var(--acid)}
|
||||
.p-designed{background:rgba(179,157,219,.14);color:var(--sia)}
|
||||
.p-upcoming{background:rgba(139,152,169,.14);color:var(--mut)}
|
||||
|
||||
/* ============ steps (numbered explainer lists) ============ */
|
||||
ol.steps{counter-reset:s;list-style:none;padding:0;margin:0;display:grid;gap:10px}
|
||||
ol.steps li{
|
||||
counter-increment:s;display:grid;grid-template-columns:26px 1fr;gap:4px 14px;
|
||||
align-items:start;
|
||||
background:var(--panel2);border:1px solid var(--line);border-radius:12px;
|
||||
padding:15px 18px;
|
||||
}
|
||||
ol.steps li::before{
|
||||
content:counter(s);grid-column:1;grid-row:1;align-self:start;
|
||||
display:grid;place-items:center;
|
||||
width:26px;height:26px;border-radius:50%;
|
||||
background:rgba(214,255,61,.14);color:var(--acid);
|
||||
font-size:13px;font-weight:700;
|
||||
}
|
||||
ol.steps li > *{grid-column:2;min-width:0}
|
||||
ol.steps b{color:var(--ink);font-size:14.5px}
|
||||
ol.steps span{color:var(--mut);font-size:14px;line-height:1.62}
|
||||
|
||||
/* ============ code chips + preformatted ============ */
|
||||
code.inline,.mono{
|
||||
font-family:ui-monospace,monospace;font-size:13px;
|
||||
background:#0e131b;border:1px solid var(--line);border-radius:6px;padding:1px 6px;
|
||||
}
|
||||
pre{
|
||||
background:#0e131b;border:1px solid var(--line);border-radius:10px;
|
||||
padding:13px 16px;overflow-x:auto;
|
||||
font-family:ui-monospace,monospace;font-size:13px;color:#c9d4e3;margin:.8rem 0;
|
||||
}
|
||||
|
||||
/* ============ notes ============ */
|
||||
.note{
|
||||
background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--acid);
|
||||
border-radius:10px;padding:14px 18px;color:var(--mut);font-size:14px;margin-top:1.4rem;
|
||||
}
|
||||
.note b{color:var(--ink)}
|
||||
.note.warn{border-left-color:var(--warn)}
|
||||
.note.danger{border-left-color:var(--taken)}
|
||||
|
||||
/* ============ footer ============ */
|
||||
footer{
|
||||
border-top:1px solid var(--line);padding:2.5rem 0 3.5rem;
|
||||
color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem;
|
||||
}
|
||||
footer .fine{max-width:660px;margin:.8rem auto 0;line-height:1.7}
|
||||
|
||||
/* ===================================================================
|
||||
Prometheus-only additions below. Keep the above identical to
|
||||
site/shared.css so a shared-token change diffs cleanly.
|
||||
=================================================================== */
|
||||
|
||||
/* Category grid. Visually distinct from .grid so the two don't blur
|
||||
together — Prometheus is a shop, the top of a page is a foyer. */
|
||||
.categories{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
|
||||
gap:14px;margin-top:1rem;
|
||||
}
|
||||
.cat{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
||||
padding:20px 22px;text-decoration:none;color:inherit;
|
||||
transition:transform .18s ease, border-color .18s ease, background .18s ease;
|
||||
display:flex;flex-direction:column;gap:.6rem;
|
||||
}
|
||||
.cat:hover{transform:translateY(-3px);border-color:rgba(214,255,61,.35);background:var(--panel2)}
|
||||
.cat .ico{font-size:26px;line-height:1;filter:grayscale(.2)}
|
||||
.cat h3{margin:.1rem 0 0;font-size:1.02rem}
|
||||
.cat .count{color:var(--dim);font-size:12px;letter-spacing:.02em;margin-top:auto;padding-top:.4rem}
|
||||
.cat.empty .count::before{content:"— ";color:var(--dim)}
|
||||
104
service/layout.js
Normal file
104
service/layout.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Shared document shell for every rendered page.
|
||||
*
|
||||
* A page function returns `{title, description, body}`; `layout()` wraps it in
|
||||
* the `<html>` document with the right meta, topnav, and footer. The brand mark
|
||||
* is inlined as SVG so pages do not fetch an image to render a 20-pixel logo.
|
||||
*/
|
||||
|
||||
const BRAND_MARK = `<svg width="18" height="18" viewBox="0 0 64 64" style="vertical-align:-4px;margin-right:6px" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
|
||||
<path d="M32 10 C 22 22, 24 32, 32 36 C 40 32, 42 22, 32 10 Z" fill="#d6ff3d"/>
|
||||
<path d="M26 36 C 20 44, 22 54, 32 56 C 42 54, 44 44, 38 36 C 36 42, 28 42, 26 36 Z" fill="#d6ff3d" opacity=".75"/>
|
||||
</svg>`;
|
||||
|
||||
const NAV = [
|
||||
{ href: '/browse', label: 'Browse' },
|
||||
{ href: '/submit', label: 'Submit' },
|
||||
{ href: '/publish', label: 'How publishing works' },
|
||||
{ href: '/boundaries', label: 'Boundaries' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Compose a full HTML document from a page.
|
||||
*
|
||||
* @param {{title: string, description: string, body: string, here?: string}} page
|
||||
* @returns {string} the HTML document, ready to send.
|
||||
*/
|
||||
export function layout(page) {
|
||||
const { title, description, body, here = '' } = page;
|
||||
|
||||
// The canonical URL is always on prometheus.x — never the IP+port the server
|
||||
// actually listens on, and never the SEO mirror at apps.silentmode.st, even
|
||||
// when the mirror is what served this request. Readers who followed a link
|
||||
// from search land on the mirror; the canonical sends search engines back to
|
||||
// the real address and keeps the scorecard on prometheus.x.
|
||||
const canonical = `https://prometheus.x${here}`;
|
||||
|
||||
const navHtml = NAV.map(n => {
|
||||
const classAttr = n.href === here ? ' class="here"' : '';
|
||||
return `<a href="${n.href}"${classAttr}>${n.label}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)} — Prometheus</title>
|
||||
<meta name="description" content="${escapeHtml(description)}">
|
||||
<link rel="canonical" href="${canonical}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Prometheus">
|
||||
<meta property="og:title" content="${escapeHtml(title)} — Prometheus">
|
||||
<meta property="og:description" content="${escapeHtml(description)}">
|
||||
<meta property="og:url" content="${canonical}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="topnav">
|
||||
<a class="brand" href="/">${BRAND_MARK}Prometheus<span class="g">.X</span></a>
|
||||
${navHtml}
|
||||
</nav>
|
||||
|
||||
${body}
|
||||
|
||||
<footer>
|
||||
<div class="fine">
|
||||
<a href="//theseus.x/">Theseus</a> ·
|
||||
<a href="//sirius.x/">Sirius</a> ·
|
||||
<a href="//ariadne.x/">Ariadne</a> ·
|
||||
<a href="//hephaestus.x/">Hephaestus</a>
|
||||
<br>
|
||||
Those are BCNR names, not domains — they resolve in a client that speaks
|
||||
the naming system, and nowhere else.
|
||||
</div>
|
||||
<div class="fine">
|
||||
Named for Prometheus, who gave fire to humans without asking the gods.
|
||||
Distinct from the well-known Prometheus monitoring project — both take
|
||||
the name from the same myth; neither has anything to do with the other.
|
||||
</div>
|
||||
<div class="fine">
|
||||
Silent Mode — a Deviant project. MPL-2.0.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTML escape for text that goes into attributes or text nodes.
|
||||
* Not for anything that must survive as markup; page bodies ship as-is.
|
||||
*/
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
527
service/pages.js
Normal file
527
service/pages.js
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
/**
|
||||
* The pages Prometheus.X renders.
|
||||
*
|
||||
* Each export is `(req?) => {title, description, body}`. The server is the one
|
||||
* that wraps them in the layout — these just return the inner content.
|
||||
*
|
||||
* The copy was lifted from site/prometheus/index.html (the ICANN teaser) and
|
||||
* split by concern so each page is one topic a reader opened on purpose. The
|
||||
* single-scroll version lives at silentmode.st/prometheus and will stay there
|
||||
* as the SEO-friendly mirror.
|
||||
*/
|
||||
|
||||
/* ------------------------------- home ------------------------------- */
|
||||
|
||||
export function home() {
|
||||
return {
|
||||
title: 'Apps and programs on the decentralized web',
|
||||
description:
|
||||
'Prometheus is a home for apps, programs, and dApps published on the Bitcoin Cash chain. Listings live on chain, binaries live on Sia, endorsements are signed. Anyone can publish; nobody can gatekeep.',
|
||||
here: '/',
|
||||
body: `
|
||||
<header class="hero">
|
||||
<div class="mark" style="font-size:52px">🔥</div>
|
||||
<h1>Prometheus
|
||||
<span class="pill p-designed" style="vertical-align:middle;font-size:12px;margin-left:.6rem">designed · not yet built</span>
|
||||
</h1>
|
||||
<p class="tag">A home for apps, programs, dApps, and libraries — <b>listed on the
|
||||
Bitcoin Cash chain, hosted on Sia, endorsed by signature</b>. Anyone can publish.
|
||||
Nobody can gatekeep. Named for the one who gave fire to humans and did not ask
|
||||
the gods first.</p>
|
||||
<div class="cta">
|
||||
<a class="btn acid" href="/browse">Browse apps</a>
|
||||
<a class="btn ghost" href="/submit">Submit an app</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<h2>Browse by category</h2>
|
||||
<p class="lede">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.</p>
|
||||
|
||||
<div class="categories">
|
||||
<a class="cat empty" href="/c/communication"><div class="ico">💬</div>
|
||||
<h3>Communication</h3>
|
||||
<p>Messengers, voice, boards, group chats. Clients that route through peers
|
||||
rather than someone else's server.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/privacy"><div class="ico">🛡️</div>
|
||||
<h3>Privacy & security</h3>
|
||||
<p>Tor and mix-net tooling, key and vault managers, disk crypto. The things
|
||||
a conventional store delists first when a government writes.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/wallets"><div class="ico">💸</div>
|
||||
<h3>Wallets & payments</h3>
|
||||
<p>BCH wallets, cross-chain bridges, point-of-sale terminals. The chain
|
||||
publishes the listing and the chain settles the payment.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/games"><div class="ico">🎮</div>
|
||||
<h3>Games</h3>
|
||||
<p>Standalone titles, on-chain leaderboards, in-app purchases that reach
|
||||
the publisher instead of a 30% toll.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/devtools"><div class="ico">🧰</div>
|
||||
<h3>Developer tools</h3>
|
||||
<p>SDKs, libraries, indexers, faucets, protocol utilities. The pieces other
|
||||
apps on this list are built out of.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/media"><div class="ico">🎧</div>
|
||||
<h3>Media & publishing</h3>
|
||||
<p>Readers, players, one-file blogs, podcast hosts. Clients that find their
|
||||
audience without going through an aggregator.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/utilities"><div class="ico">⚙️</div>
|
||||
<h3>Utilities</h3>
|
||||
<p>File tools, format converters, backup, calculators. The small sharp
|
||||
things that solve one problem and do not ask for an account first.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/other"><div class="ico">✨</div>
|
||||
<h3>Everything else</h3>
|
||||
<p>Anything that does not fit above. New categories are proposed in-band —
|
||||
any listing can declare one and clients will offer to add it.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="note warn" style="margin-top:1.6rem"><b>No live listings yet.</b>
|
||||
The listing protocol (<code class="inline">APS1</code> 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.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>How Prometheus works, in a nutshell</h2>
|
||||
<ol class="steps">
|
||||
<li><b>A listing is a signed on-chain record.</b>
|
||||
<span>Publishing an app writes a small <code class="inline">APS1</code>
|
||||
record to Bitcoin Cash — same protocol shape as a BNS name registration,
|
||||
different prefix. The payload is a hash pointing at a full manifest stored
|
||||
on Sia. Nobody approves the record; the transaction is the publish.</span></li>
|
||||
<li><b>The binary lives on Sia.</b>
|
||||
<span>App files are uploaded to the Sia storage network — encrypted on
|
||||
the publisher's machine, sharded across roughly 30 independent hosts,
|
||||
held by nobody in particular. The listing carries the SHA-256 the file
|
||||
must match.</span></li>
|
||||
<li><b>Scan reports are signed and attached.</b>
|
||||
<span>Prometheus runs an automated malware scan on every binary it hosts
|
||||
and publishes the signed report alongside the listing. The chain still
|
||||
goes up; the report tells your client whether to warn.</span></li>
|
||||
<li><b>Install verifies against the chain.</b>
|
||||
<span>Clients pull the file, check the SHA-256 against the on-chain
|
||||
record, check the signing certificate against the fingerprint, and only
|
||||
then install. The chain is the truth, not the mirror.</span></li>
|
||||
</ol>
|
||||
<p style="margin-top:1.2rem"><a href="/publish">The full publishers' walkthrough,
|
||||
step by step →</a></p>
|
||||
</section>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- publish ---------------------------- */
|
||||
|
||||
export function publish() {
|
||||
return {
|
||||
title: "Publishers' walkthrough",
|
||||
description:
|
||||
'The end-to-end flow of publishing an app on Prometheus: name, hosting, manifest, listing, verification, updates, withdrawal, and endorsements.',
|
||||
here: '/publish',
|
||||
body: `
|
||||
<header class="hero compact">
|
||||
<h1>Publishers' walkthrough</h1>
|
||||
<p class="tag">Pick a name, host the binary, write the manifest, sign the listing,
|
||||
verify it resolves, ship an update when it's time. Here's the whole shape.</p>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<p class="lede">Publishing an app on Prometheus is a sequence of signed records
|
||||
pointing at files stored where you keep them. The whole thing — identity,
|
||||
hosting, the listing itself — is yours; nothing here grants anyone else
|
||||
permission to pull your app down, raise your price, or take a cut of what
|
||||
people pay you for it.</p>
|
||||
|
||||
<p>Steps are tagged <span class="pill p-live">available today</span> or
|
||||
<span class="pill p-designed">designed</span> so it is clear what you can do
|
||||
now and what is waiting on the APS1 record format landing.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What you need before you start</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<span class="pill p-live">available today</span>
|
||||
<h3>A BNS name</h3>
|
||||
<p>Any name registered through <a href="//sirius.x/">Sirius</a> works. The
|
||||
name is what signs the listing, so it is also what readers trust the
|
||||
listing by. Fractions of a cent to register, no monthly bill.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="pill p-live">available today</span>
|
||||
<h3>Somewhere to put the binary</h3>
|
||||
<p>Prometheus can host for you — upload here and we put it on Sia, signed
|
||||
and scanned. If you'd rather self-host on your own URL, the listing just
|
||||
records the URL and the hash and will fetch from wherever you say.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="pill p-live">available today</span>
|
||||
<h3>A signing key</h3>
|
||||
<p>The private key for the BNS name. If the name is in a wallet you already
|
||||
use for Bitcoin Cash, that key already exists; publishing re-uses it.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="pill p-live">available today</span>
|
||||
<h3>A pinch of BCH</h3>
|
||||
<p>One listing is one transaction. The transaction fee is cents, denominated
|
||||
in sats. During the free wave, that is the only cost. Later, a flat
|
||||
listing fee covers the Sia storage.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>The walkthrough</h2>
|
||||
<ol class="steps">
|
||||
<li><b>1. Register a name — or use one you already own.</b>
|
||||
<span>The listing is signed by the name's key. A reader who follows your
|
||||
name will see your listing with the publisher chip set to that name; a
|
||||
reader who does not will see it marked unsigned by anyone they trust.
|
||||
One name can carry any number of listings, so a studio with three games
|
||||
publishes under one identity.</span></li>
|
||||
|
||||
<li><b>2. Upload the binary (or point us at your URL).</b>
|
||||
<span>Prometheus runs a malware scan on upload. If it passes, the file
|
||||
goes into a Sia bucket Silent Mode controls, the signed scan report is
|
||||
prepared, and the manifest fields are prefilled. If you'd rather host
|
||||
the file yourself, give us the URL and the expected SHA-256; the manifest
|
||||
records both, the scan still runs against whatever the URL serves, and
|
||||
the client refuses to install a byte-mismatched download.</span></li>
|
||||
|
||||
<li><b>3. Fill in the manifest.</b>
|
||||
<span>Name, description, category, version, icon, download URL,
|
||||
<code class="inline">sha256</code>, and the signing-cert fingerprint when
|
||||
the binary itself is signed (it should be, if the platform supports it).
|
||||
Icons are referenced by URL, not embedded. A 2 KB manifest with a 500 KB
|
||||
icon beside it is the right shape; a 500 KB manifest is not.</span></li>
|
||||
|
||||
<li><b>4. Sign the APS1 record with your BNS key.</b>
|
||||
<span>One transaction. The on-chain record carries the manifest hash,
|
||||
the category label, and the signature from your name's key. From the
|
||||
moment the transaction confirms, every client that follows the chain can
|
||||
see your listing — no approval queue, no editorial review, no publisher
|
||||
dashboard waiting for a human on the other end.</span></li>
|
||||
|
||||
<li><b>5. Verify it resolves.</b>
|
||||
<span>Open one of the Ariadne clients and look for your listing in the
|
||||
category you labelled it under. The client should pull the manifest from
|
||||
Sia, show the fields you wrote, surface the scan report, and offer the
|
||||
download. Clicking install should fetch the binary, check the SHA-256,
|
||||
check the signing certificate, and only then run.</span></li>
|
||||
|
||||
<li><b>6. Ship updates with another signed record.</b>
|
||||
<span>A new version is another APS1 transaction referencing the same app
|
||||
id, signed by the same key, pointing at a new manifest hash. Clients
|
||||
already following the app see the update offered the next time they
|
||||
check. Nobody can push a hostile update unless they have your key.</span></li>
|
||||
|
||||
<li><b>7. Withdraw a listing when it is time.</b>
|
||||
<span>A withdrawal is a signed record that marks the app id as retired.
|
||||
Clients hide the listing from browse and refuse to install it, though
|
||||
anyone with the manifest hash and the binary can still side-load it —
|
||||
the chain is a signal, not a lock.</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>A note on endorsements</h2>
|
||||
<p>A listing is one signed record. An <em>endorsement</em> is another — a short
|
||||
on-chain statement from any name saying "I vouch for this app." Readers whose
|
||||
trust list includes the endorser's name will see your listing marked as
|
||||
endorsed by them; readers who do not follow that name will not. Endorsements
|
||||
are not reviews, and the client does not aggregate them into a star rating.
|
||||
They are statements: who said what, when, signed.</p>
|
||||
<div class="note"><b>You can endorse too.</b> Any name can publish an endorsement
|
||||
of any listing, and most publishers end up endorsing a handful of apps they
|
||||
use themselves. The trust graph that emerges is the directory's real curation
|
||||
layer; the categories are just filing.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What this costs</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Per listing</h3>
|
||||
<p>One Bitcoin Cash transaction. Fractions of a cent at current rates. Paid
|
||||
once, lives on chain.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Per update</h3>
|
||||
<p>Another transaction. Same shape, same cost. There is no "update bundle"
|
||||
discount because there is no central party to collect one.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Per month of hosting</h3>
|
||||
<p>Free during the opening wave while Prometheus underwrites the storage.
|
||||
Afterwards, a flat per-listing fee (planned around $1 at current rates)
|
||||
covers Sia storage for the typical binary. Your own HTTPS host costs
|
||||
what it costs you.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Per download</h3>
|
||||
<p>Zero to the directory. The reader pulls the binary from wherever the
|
||||
manifest points; Prometheus never meters the traffic.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="note warn"><b>Where this stands today.</b> Names, Sia hosting and
|
||||
BCH signing exist and are in use. The <code class="inline">APS1</code> record
|
||||
format and the client-side browse/install flow are being wired. This page
|
||||
describes the end state; the first live listings will go up as soon as the
|
||||
submit path lands on this site and the Ariadne clients learn to read it.</div>
|
||||
</section>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- browse ----------------------------- */
|
||||
|
||||
export function browse() {
|
||||
return {
|
||||
title: 'Browse',
|
||||
description:
|
||||
'Browse apps and programs published on Prometheus by category. Signed listings on the Bitcoin Cash chain; scan reports attached; your trust list filters.',
|
||||
here: '/browse',
|
||||
body: `
|
||||
<header class="hero compact">
|
||||
<h1>Browse</h1>
|
||||
<p class="tag">Signed listings on chain, scan reports attached, your trust list
|
||||
decides what to render.</p>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<div class="note warn"><b>No live listings yet.</b> 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.</div>
|
||||
|
||||
<div class="categories" style="margin-top:1.4rem">
|
||||
<a class="cat empty" href="/c/communication"><div class="ico">💬</div>
|
||||
<h3>Communication</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/privacy"><div class="ico">🛡️</div>
|
||||
<h3>Privacy & security</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/wallets"><div class="ico">💸</div>
|
||||
<h3>Wallets & payments</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/games"><div class="ico">🎮</div>
|
||||
<h3>Games</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/devtools"><div class="ico">🧰</div>
|
||||
<h3>Developer tools</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/media"><div class="ico">🎧</div>
|
||||
<h3>Media & publishing</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/utilities"><div class="ico">⚙️</div>
|
||||
<h3>Utilities</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/other"><div class="ico">✨</div>
|
||||
<h3>Everything else</h3><span class="count">0 listings</span></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- submit ----------------------------- */
|
||||
|
||||
export function submit() {
|
||||
return {
|
||||
title: 'Submit an app',
|
||||
description:
|
||||
'Upload an app to Prometheus. We scan it, generate the manifest and the APS1 record, you sign with your BNS key and the listing goes on chain.',
|
||||
here: '/submit',
|
||||
body: `
|
||||
<header class="hero compact">
|
||||
<h1>Submit an app</h1>
|
||||
<p class="tag">Upload, scan, sign, publish. Four screens; your key stays with you.</p>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<div class="note warn"><b>The submit UI is being wired right now.</b> Upload
|
||||
+ scan is the next milestone (see
|
||||
<a href="/publish">the publishers' walkthrough</a> for the full flow). This
|
||||
page becomes the first screen of the submit wizard once the upload endpoint
|
||||
lands.</div>
|
||||
|
||||
<ol class="steps" style="margin-top:1.4rem">
|
||||
<li><b>1. Upload the binary.</b>
|
||||
<span>Drag-and-drop or file picker. The server scans with ClamAV on the
|
||||
way in and returns a signed scan report. Scan verdict and SHA-256 appear
|
||||
on the next screen.</span></li>
|
||||
<li><b>2. Fill the manifest.</b>
|
||||
<span>Name, description, category, version, icon URL. Scan verdict is
|
||||
attached automatically. Preview on the right as you type.</span></li>
|
||||
<li><b>3. Sign with your BNS key.</b>
|
||||
<span>The service shows the APS1 payload. Sign in Theseus (if your wallet
|
||||
is there), paste a BIP-137 signature from any BCH wallet, or import your
|
||||
name's mnemonic once in the page (wiped on close). The signed TX is yours
|
||||
regardless of whether we broadcast it.</span></li>
|
||||
<li><b>4. Pay the listing fee (eventually).</b>
|
||||
<span>During the free wave: skip. Later: a BCH invoice for the per-listing
|
||||
fee at the day's rate. Service broadcasts the APS1 TX on confirmation; if
|
||||
the broadcast fails you still have the signed TX and can relay it yourself.</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- boundaries ------------------------- */
|
||||
|
||||
export function boundaries() {
|
||||
return {
|
||||
title: 'Boundaries',
|
||||
description:
|
||||
'What Prometheus does and does not do. Not a curator. Not a moderator. Storage costs money. Readers decide; the chain carries signed records.',
|
||||
here: '/boundaries',
|
||||
body: `
|
||||
<header class="hero compact">
|
||||
<h1>Boundaries</h1>
|
||||
<p class="tag">What this directory is not, and why.</p>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<p class="lede">A permissionless app directory earns the right to call itself
|
||||
permissionless by being honest about what that costs. Here is the shape of
|
||||
the trade.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What Prometheus is not</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Not a curator</h3>
|
||||
<p>Prometheus does not decide what is on it. Malware can be published — as
|
||||
it can on any store. The scan report tells your client to warn; the
|
||||
trust lists you subscribe to filter the view. If nobody you trust
|
||||
endorses it, your client says so.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Not a moderator</h3>
|
||||
<p>Content that is illegal in your jurisdiction is your responsibility to
|
||||
avoid, same as any other permissionless network. Silent Mode operates
|
||||
infrastructure; it does not decide what runs on it.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Not a payment processor</h3>
|
||||
<p>Payments in an app go publisher-to-reader. The listing may carry a BCH
|
||||
receive address or a wallet-connect URL; Prometheus never routes a
|
||||
transaction. There is no refund desk because there is no middleman to
|
||||
issue a refund.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Not an identity provider</h3>
|
||||
<p>The only identity on Prometheus is the BNS name a listing is signed by.
|
||||
Readers decide which names count via their own trust lists. There is no
|
||||
Prometheus account, no "verified publisher" badge, no sign-in flow for
|
||||
readers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What Silent Mode does control</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>The scan report</h3>
|
||||
<p>Our ClamAV + VirusTotal pipeline runs on every binary we host. The
|
||||
signed report is attached to the listing. We cannot stop a listing from
|
||||
being on chain; we can tell readers what we found.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Which binaries we host</h3>
|
||||
<p>Silent Mode's Sia bucket. Operators can refuse to host something and
|
||||
require the publisher to self-host at their own URL. The listing still
|
||||
exists; the scan report says we did not host it.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>The default sort</h3>
|
||||
<p>What the Browse page shows first is a choice. We publish the sort order
|
||||
and let clients override it. The chain holds the data; the directory is
|
||||
one lens.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>The publish mode</h3>
|
||||
<p>Three modes exist (append-only, scan-first-blocking, human-review).
|
||||
Operators pick which one is active per category. The current mode is
|
||||
always visible on this page; a change is announced before it takes
|
||||
effect.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Storage costs money</h2>
|
||||
<p>Sia hosting is inexpensive but not free — publishers pay for the bytes
|
||||
their apps take. There is no advertising model, no data harvesting, no
|
||||
subscription. Storage is the only cost, and it is small. During the
|
||||
opening wave Silent Mode absorbs it; afterwards it is a flat per-listing
|
||||
fee paid in BCH.</p>
|
||||
</section>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- 404 -------------------------------- */
|
||||
|
||||
export function notFound(req) {
|
||||
return {
|
||||
title: 'Not here',
|
||||
description: 'The requested page does not exist on Prometheus.',
|
||||
here: '',
|
||||
body: `
|
||||
<header class="hero compact">
|
||||
<h1>Not here</h1>
|
||||
<p class="tag">Nothing lives at <code class="inline">${escapeHtml(req?.url || '')}</code>.</p>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<section class="plain">
|
||||
<p>Try the <a href="/">home page</a>, the <a href="/browse">browse</a> or the
|
||||
<a href="/publish">publishers' walkthrough</a>.</p>
|
||||
</section>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- helpers ---------------------------- */
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
158
service/server.js
Normal file
158
service/server.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Prometheus HTTP server.
|
||||
*
|
||||
* One `node:http` listener, a map-based router for page routes, and a tiny
|
||||
* static-file handler for /static/*. No framework, no templating engine —
|
||||
* the response-rendering path for a page is: look up the handler, call it,
|
||||
* wrap the result in `layout()`, flush. If any step of that stops reading
|
||||
* at a glance, we have gotten too clever.
|
||||
*
|
||||
* Dynamic routes land here later: /api/upload, /api/manifest, /api/publish,
|
||||
* /api/pay, /admin/*. For now the server is only serving pages, so the
|
||||
* router is only pages.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createReadStream, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, normalize, resolve } from 'node:path';
|
||||
import { layout } from './layout.js';
|
||||
import * as pages from './pages.js';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3700;
|
||||
const BIND = process.env.BIND || '127.0.0.1';
|
||||
|
||||
const SELF = fileURLToPath(import.meta.url);
|
||||
const SERVICE_DIR = dirname(SELF);
|
||||
const PUBLIC_DIR = resolve(SERVICE_DIR, '..', 'public');
|
||||
|
||||
/**
|
||||
* The page router.
|
||||
*
|
||||
* Exact-match paths first. Patterns (category pages, listing detail) will
|
||||
* attach here when the browse milestone lands.
|
||||
*/
|
||||
const PAGE_ROUTES = {
|
||||
'/': pages.home,
|
||||
'/publish': pages.publish,
|
||||
'/browse': pages.browse,
|
||||
'/submit': pages.submit,
|
||||
'/boundaries': pages.boundaries,
|
||||
};
|
||||
|
||||
/**
|
||||
* Content types for static files. Deliberately short — if the server
|
||||
* learns to serve a type that is not here, add it here rather than
|
||||
* reaching for `mime-types`.
|
||||
*/
|
||||
const MIME = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
/* ============ server ============ */
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const started = Date.now();
|
||||
const url = normalizeRequestUrl(req.url);
|
||||
|
||||
try {
|
||||
if (url.startsWith('/static/')) {
|
||||
await serveStatic(url.slice('/static/'.length), res);
|
||||
} else if (PAGE_ROUTES[url]) {
|
||||
servePage(PAGE_ROUTES[url], res);
|
||||
} else {
|
||||
servePage(pages.notFound, res, 404, req);
|
||||
}
|
||||
} catch (err) {
|
||||
// The response-level error log is intentionally short — a stack trace
|
||||
// in the terminal is for the operator, not every reader.
|
||||
console.error('[prometheus] 500', req.method, url, err?.message || err);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
}
|
||||
res.end('internal error\n');
|
||||
}
|
||||
|
||||
res.on('close', () => {
|
||||
const ms = Date.now() - started;
|
||||
console.log(`[prometheus] ${res.statusCode} ${req.method} ${url} ${ms}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, BIND, () => {
|
||||
console.log(`[prometheus] listening on http://${BIND}:${PORT}`);
|
||||
});
|
||||
|
||||
/* ============ helpers ============ */
|
||||
|
||||
/**
|
||||
* Trim query/hash, decode once, collapse any `..` to its parent.
|
||||
* Returns a leading-slash path.
|
||||
*/
|
||||
function normalizeRequestUrl(raw) {
|
||||
const [path] = String(raw || '/').split('?');
|
||||
const decoded = decodeURIComponent(path);
|
||||
// normalize + leading-slash enforcement means `/foo/..//bar` → `/bar`.
|
||||
// On Windows, node:path/normalize uses backslashes; convert back.
|
||||
const normalized = normalize(decoded).replaceAll('\\', '/');
|
||||
return normalized.startsWith('/') ? normalized : '/' + normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a page function to the response.
|
||||
*/
|
||||
function servePage(fn, res, status = 200, req = null) {
|
||||
const page = fn(req);
|
||||
const html = layout(page);
|
||||
res.writeHead(status, {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'public, max-age=60',
|
||||
});
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a file from PUBLIC_DIR by relative path. The caller passes a
|
||||
* path with no leading slash and no `..`; this double-checks via
|
||||
* path.resolve against PUBLIC_DIR and refuses anything that escapes.
|
||||
*/
|
||||
async function serveStatic(relPath, res) {
|
||||
const full = resolve(PUBLIC_DIR, relPath);
|
||||
if (!full.startsWith(PUBLIC_DIR + '\\') && !full.startsWith(PUBLIC_DIR + '/')) {
|
||||
res.writeHead(400, { 'content-type': 'text/plain' });
|
||||
res.end('bad path\n');
|
||||
return;
|
||||
}
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
res.writeHead(404, { 'content-type': 'text/plain' });
|
||||
res.end('not found\n');
|
||||
return;
|
||||
}
|
||||
if (!st.isFile()) {
|
||||
res.writeHead(404, { 'content-type': 'text/plain' });
|
||||
res.end('not found\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = '.' + relPath.split('.').pop();
|
||||
const mime = MIME[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, {
|
||||
'content-type': mime,
|
||||
// Static assets are versioned in the URL (`?v=1`), so a long cache is
|
||||
// safe and the only way to make the viewport snappy on repeat visits.
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
'content-length': st.size,
|
||||
});
|
||||
createReadStream(full).pipe(res);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue