diff --git a/README.md b/README.md index f46ad90..eea5dfa 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,16 @@ CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offli `CHECK_DB_URL` does the same for `pnpm --filter ./api test`, which then runs the review-dedupe SQL against both backends instead of just SQLite. +On-demand indexing. The address rules that keep `POST /api/index` from being an SSRF +hole, the redirect hops, the slug collapse that stops one mint becoming two rows, the +invite-code decoder and the rate limiter. Nothing here touches the network: the resolver +and the fetch are both injected, because a rule that can only be exercised against the +real internet stops being exercised the first time CI runs offline. + +```bash +pnpm --filter ./api test:index +``` + Type checking across the workspace: ```bash @@ -537,6 +547,7 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove | `PROBE_TIMEOUT_MS` | `5000` | Per-mint request timeout | | `FEDIMINT_OBSERVER_URL` | `https://observer.fedimint.org/api/federations` | Where federation health is read from. Empty disables the lookup, and every federation stays `announced`. See [Ecosystems](#ecosystems). | | `SCORE_PRIOR_MEAN` | `3` | Bayesian prior. See "Ranking" below before changing. | +| `INDEX_RATE_LIMIT` | `10` | `POST /api/index` submissions allowed per address per hour | ### Web (`web/`) @@ -657,7 +668,7 @@ or copy refers to it, and it is planned as a later stage rather than half-built ## API -Four endpoints, CORS open, no auth. +Five endpoints, CORS open, no auth. Four read; the fifth writes. | Endpoint | Notes | | -------------------- | ------------------------------------------------------------------ | @@ -665,9 +676,54 @@ Four endpoints, CORS open, no auth. | `GET /api/stats` | Network counters, memoized 60s in process. | | `GET /api/mints` | Everything listed, online first then score descending. `?limit=`, `?type=`. | | `GET /api/mints/:host` | One listing plus its ecosystem's own fields, distribution, uptime and probe history. | +| `POST /api/index` | Index a mint nobody has announced yet. Rate limited. See below. | `/icons/*` serves the cached mint icons. +### Indexing on demand + +```bash +curl -X POST https://cashumints.space/api/index \ + -H 'content-type: application/json' \ + -d '{"type":"lnurl","input":"mint.600.wtf"}' +``` + +`type` is `cashu`, `fedimint` or `lnurl`; `input` is a URL, or an invite code for a +federation. The site itself calls this from two places: the 404 page, when somebody opens +`/mint/…`, `/fedimint/…` or `/lnurl-mint/…` for something this build has never heard of, +and the "Write a review" dialog on the three index pages. + +What it does, in order, stopping at the first step that settles it: + +1. **Already indexed** — `200` with the ordinary `GET /api/mints/:host` payload plus + `"existing": true`. Nothing is probed; a submission is not a reason to re-probe. +2. **Answers as what it claims** — one request, the standard `PROBE_TIMEOUT_MS`. The row + is written by the same functions the probe cycle uses, so it is indistinguishable from + one the loop created. `201`, same payload shape, plus `"indexed_from": "probe"`. +3. **Answers as something else** — `422` with `error: "wrong_type"` and `detected_type`, + which is what lets the dialog offer "this is a Cashu mint, review it there" as a + button. A federation's invite code is decoded rather than fetched: there is no + endpoint to probe. +4. **Nothing answers** — one bounded relay lookup (3s) for a NIP-87 announcement or any + review naming the address. Found: the row is written from the announcement with + `status = offline`, which is the mint that rugged last week and is exactly the one + somebody wants to review. Nothing anywhere: `404`, `error: "unverifiable"`. + +Everything indexed this way is an ordinary row afterwards: the probe loop owns it from +the next cycle. + +Because it fetches an address a stranger chose, it is hardened accordingly (`api/src/safe-fetch.ts`): +https only, DNS resolved and every returned address checked against the private, loopback, +link-local, CGNAT and multicast ranges *before* a socket opens, redirects followed by hand +and capped at two with every hop re-checked, a 256KB response cap, and a per-IP limit of +`INDEX_RATE_LIMIT` an hour with a clean `429`. Two simultaneous submissions of one address +share a single probe. `pnpm --filter ./api test:index` covers all of it without a network. + +The limit needs to be able to tell two visitors apart, so behind the nginx block below add +`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` to `location /api/`. The +last entry of that header is the one used, because it is the one the proxy vouched for; +the header is ignored entirely when the peer is not loopback. + ### Ranking Bayesian weighted rating: @@ -792,6 +848,9 @@ server { # blocks only if you build with PUBLIC_API_URL pointing at a separate API host. location /api/ { proxy_pass http://127.0.0.1:8787; + # POST /api/index is rate limited per address, and without this every visitor + # arrives as 127.0.0.1 and shares one budget. + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /icons/ { diff --git a/web/og-fixtures/_thumbs.png b/web/og-fixtures/_thumbs.png new file mode 100644 index 0000000..d96c2ed Binary files /dev/null and b/web/og-fixtures/_thumbs.png differ diff --git a/web/og-fixtures/fixture-lnurl-no-withdrawals.png b/web/og-fixtures/fixture-lnurl-no-withdrawals.png new file mode 100644 index 0000000..b80b72f Binary files /dev/null and b/web/og-fixtures/fixture-lnurl-no-withdrawals.png differ diff --git a/web/og-fixtures/fixture-lnurl-offline.png b/web/og-fixtures/fixture-lnurl-offline.png new file mode 100644 index 0000000..b546d86 Binary files /dev/null and b/web/og-fixtures/fixture-lnurl-offline.png differ diff --git a/web/og-fixtures/fixture-lnurl-online.png b/web/og-fixtures/fixture-lnurl-online.png new file mode 100644 index 0000000..bf3ddd5 Binary files /dev/null and b/web/og-fixtures/fixture-lnurl-online.png differ diff --git a/web/scripts/og/build-og.mjs b/web/scripts/og/build-og.mjs index b6f498a..01eb215 100644 --- a/web/scripts/og/build-og.mjs +++ b/web/scripts/og/build-og.mjs @@ -28,7 +28,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Resvg } from '@resvg/resvg-js'; import { renderSvg } from './card.mjs'; -import { cashuModel, defaultModel, fedimintModel } from './model.mjs'; +import { cashuModel, defaultModel, fedimintModel, lnurlModel } from './model.mjs'; import { FIXTURES, NOW as FIXTURE_NOW } from './fixtures.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -96,7 +96,9 @@ async function buildFixtures() { const model = raw.type === 'fedimint' ? fedimintModel(raw, { now: FIXTURE_NOW }) - : cashuModel(raw, { now: FIXTURE_NOW }); + : raw.type === 'lnurl' + ? lnurlModel(raw, { now: FIXTURE_NOW }) + : cashuModel(raw, { now: FIXTURE_NOW }); const png = await renderPng(model, null); const file = path.join(dir, `${raw.host}.png`); await writeFile(file, png); @@ -114,9 +116,10 @@ async function build() { const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}')); const previousImages = previous.images ?? {}; - const [cashu, fedimint, stats] = await Promise.all([ + const [cashu, fedimint, lnurl, stats] = await Promise.all([ getJson('/api/mints?type=cashu'), getJson('/api/mints?type=fedimint'), + getJson('/api/mints?type=lnurl'), getJson('/api/stats'), ]); @@ -135,6 +138,7 @@ async function build() { const listings = [ ...cashu.map((m) => ({ listing: m, kind: 'cashu' })), ...fedimint.map((m) => ({ listing: m, kind: 'fedimint' })), + ...lnurl.map((m) => ({ listing: m, kind: 'lnurl' })), ]; // A handful at a time: satori renders in milliseconds, the icon fetches are local, @@ -158,7 +162,9 @@ async function build() { const model = kind === 'fedimint' ? fedimintModel(detail, { iconHash: icon?.hash ?? null, now }) - : cashuModel(detail, { iconHash: icon?.hash ?? null, now }); + : kind === 'lnurl' + ? lnurlModel(detail, { iconHash: icon?.hash ?? null, now }) + : cashuModel(detail, { iconHash: icon?.hash ?? null, now }); const hash = hashModel(model); const file = `${model.slug}.${hash}.png`; diff --git a/web/scripts/og/card.mjs b/web/scripts/og/card.mjs index bb289ae..241f246 100644 --- a/web/scripts/og/card.mjs +++ b/web/scripts/og/card.mjs @@ -29,6 +29,8 @@ const AMBER = '#F0A93B'; const VIOLET = '#A78BFA'; const MINT = '#5FD68F'; const RED = '#E06450'; +/** The LNURL ecosystem label. The only cool blue here — see DESIGN.md. */ +const LNURL = '#4EA8DE'; const GROTESK = 'Space Grotesk'; const INTER = 'Inter'; @@ -137,22 +139,29 @@ function statusChip(model) { } } -/** The type badge that makes a federation card unmistakable at thumbnail size. */ -function fedimintBadge() { +/** + * The type badge that makes a non-Cashu card unmistakable at thumbnail size. + * + * Three things separate the three ecosystems at 300px wide, and the badge is only one + * of them: the word itself, the badge's colour, and the glow behind the whole card all + * change together. Cashu gets no badge at all, which is its own signal — it is the + * default and the majority of the index. + */ +function typeBadge(label, color, borderColor) { return h( 'div', { display: 'flex', padding: '9px 14px', borderRadius: '9px', - border: `1.5px solid rgba(167,139,250,.5)`, - color: VIOLET, + border: `1.5px solid ${borderColor}`, + color, fontFamily: MONO, fontWeight: 500, fontSize: '18px', letterSpacing: '3px', }, - 'FEDIMINT', + label, ); } @@ -380,15 +389,86 @@ function thirdCellFedimint(model) { ); } -/** The background glow: amber for a healthy card, red-tinted for trouble, violet for Fedimint. */ +/** + * Third cell, LNURL: the withdraw range — never NUT limits, never a module list. + * + * This is the number that answers an LNURL reader's actual question: how much can one + * of this mint's notes be worth? It is shown in sats, converted from millisatoshi in + * the model, and it is a *range* rather than a pair of independent bounds, which is + * why the two rows read "min"/"max" the way the Cashu limits cell does. + * + * The lightning address takes the space the Fedimint cell gives its network badge: it + * is the one string on the whole card someone might copy out of a screenshot. + */ +function thirdCellLnurl(model) { + const base = { + display: 'flex', + flexDirection: 'column', + flex: 1.1, + padding: '30px 36px', + borderLeft: `1px solid ${LINE_SOFT}`, + }; + + if (model.warning) { + const color = model.warning.severity === 'critical' ? RED : AMBER; + return h( + 'div', + { ...base, backgroundColor: model.warning.severity === 'critical' ? 'rgba(224,100,80,.06)' : 'rgba(240,169,59,.05)' }, + cellLabel('WARNING', color), + h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '34px', lineHeight: 1.05, color }, model.warning.label), + h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub), + ); + } + + const lines = []; + if (model.withdraw) { + if (model.withdraw.min !== null) lines.push(['min', `${compact(model.withdraw.min)} sat`]); + if (model.withdraw.max !== null) lines.push(['max', `${compact(model.withdraw.max)} sat`]); + } + + return h( + 'div', + base, + cellLabel('WITHDRAW'), + lines.length === 0 + ? h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '24px', color: MUTED, marginTop: '8px' }, 'Not published') + : h( + 'div', + { display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '4px' }, + ...lines.map(([k, v]) => + h( + 'div', + { display: 'flex', alignItems: 'baseline', gap: '14px' }, + h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '20px', color: FAINT }, k), + h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '27px', color: TEXT }, v), + ), + ), + ), + model.address && + h( + 'div', + { fontFamily: MONO, fontWeight: 400, fontSize: '18px', color: FAINT, marginTop: '16px', maxWidth: '300px' }, + fitMono(model.address, 30), + ), + ); +} + +/** The background glow: amber healthy, red for trouble, violet Fedimint, blue LNURL. */ function glow(model) { const danger = - model.status === 'offline' || (model.type === 'cashu' && model.warning && model.warning.severity === 'critical'); + model.status === 'offline' || + (model.type !== 'fedimint' && model.warning && model.warning.severity === 'critical'); const color = danger ? 'rgba(224,100,80,.13)' : model.type === 'fedimint' ? 'rgba(167,139,250,.11)' - : 'rgba(240,169,59,.10)'; + : model.type === 'lnurl' + // Deliberately the strongest of the three. Amber and violet only have to + // separate a card from the site's own background; this one has to separate an + // LNURL card from a Fedimint card at 300px, where the badge text is a smudge + // and the tint is the first thing a reader actually resolves. + ? 'rgba(78,168,222,.20)' + : 'rgba(240,169,59,.10)'; return h('div', { position: 'absolute', top: '-260px', @@ -414,10 +494,12 @@ function brandRow(tagline) { /** One mint or federation card. `iconData` is a data: URI or null. */ export function mintCard(model, iconData) { const isFed = model.type === 'fedimint'; + const isLnurl = model.type === 'lnurl'; const monoLine = isFed ? model.shortId : fitMono(model.domain, 46); - // Room beside the icon: canvas minus padding, icon, gaps and the chips. - const chipRoom = 240 + (isFed ? 180 : 0); + // Room beside the icon: canvas minus padding, icon, gaps and the chips. A badge is + // ~180px for FEDIMINT and ~120px for the shorter LNURL. + const chipRoom = 240 + (isFed ? 180 : isLnurl ? 120 : 0); const { size, clamp } = nameSize(model.name, 1200 - 128 - 148 - chipRoom); return h( @@ -433,7 +515,7 @@ export function mintCard(model, iconData) { fontFamily: INTER, }, glow(model), - brandRow(isFed ? 'Fedimint reviews' : 'Cashu mint reviews'), + brandRow(isFed ? 'Fedimint reviews' : isLnurl ? 'LNURL mint reviews' : 'Cashu mint reviews'), h( 'div', { display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 }, @@ -459,7 +541,8 @@ export function mintCard(model, iconData) { model.name, ), statusChip(model), - isFed && fedimintBadge(), + isFed && typeBadge('FEDIMINT', VIOLET, 'rgba(167,139,250,.5)'), + isLnurl && typeBadge('LNURL', LNURL, 'rgba(78,168,222,.5)'), ), h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '25px', color: MUTED, marginTop: '14px' }, monoLine), // The Fedimint soft warning is a muted line, deliberately not a red cell. @@ -479,7 +562,7 @@ export function mintCard(model, iconData) { }, ratingCell(model), reviewsCell(model), - isFed ? thirdCellFedimint(model) : thirdCellCashu(model), + isFed ? thirdCellFedimint(model) : isLnurl ? thirdCellLnurl(model) : thirdCellCashu(model), ), ); } @@ -525,13 +608,16 @@ export function defaultCard(model) { h( 'div', { fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' }, - 'Cashu mint and Fedimint reviews, signed on Nostr', + 'Cashu, Fedimint and LNURL mint reviews, signed on Nostr', ), h( 'div', - { display: 'flex', gap: '86px', marginTop: '64px' }, + // Four stats where there were three, so the gap tightens rather than the row + // running past the card's own margins. + { display: 'flex', gap: '64px', marginTop: '64px' }, stat(model.mints, 'Cashu mints'), stat(model.federations, 'federations'), + stat(model.lnurl, 'LNURL mints'), stat(model.reviews, 'signed reviews'), ), ); diff --git a/web/scripts/og/fixtures.mjs b/web/scripts/og/fixtures.mjs index 19ad955..6f3b930 100644 --- a/web/scripts/og/fixtures.mjs +++ b/web/scripts/og/fixtures.mjs @@ -1,7 +1,7 @@ /** * The edge cases the template must survive, as synthetic API payloads. * - * Run through the same cashuModel/fedimintModel derivation as real data — a fixture + * Run through the same cashuModel/fedimintModel/lnurlModel derivation as real data — a fixture * that bypassed the model would test a card no build can produce. `NOW` is pinned so * the rendered PNGs under og-fixtures/ are byte-stable and can live in the repo as * eyeball snapshots. @@ -32,6 +32,31 @@ const base = { }, }; + +/** + * The shared shape of an LNURL row's detail payload, as `GET /api/mints/:host` returns + * it: the ecosystem fields flattened across the top level, millisatoshi on the wire. + */ +const lnurlBase = { + type: 'lnurl', + status: 'online', + last_online: NOW - 300, + last_probe: NOW - 300, + first_seen: NOW - 200 * 86400, + review_count: 9, + rating_avg: 4.4, + rating_distribution: dist(6, 2, 1), + last_review_at: NOW - 20 * 86400, + features: ['mint', 'melt', 'rotate', 'split', 'merge', 'lud06', 'lud03', 'lud16', 'signed-notes'], + network: 'mainnet', + mint_pubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555', + funding_available: true, + invalid_reason: null, + min_withdrawable_msat: 5_000, + max_withdrawable_msat: 999_899_000, + lightning_address: 'mint@lnurl.example', +}; + export const FIXTURES = [ { // A name at the 30-character mark: must step down, never clip. @@ -109,4 +134,42 @@ export const FIXTURES = [ network: 'mainnet', status_source: null, }, + { + // The healthy LNURL card: blue glow, LNURL badge, a withdraw range in the third + // cell, and the lightning address under it. + ...lnurlBase, + host: 'fixture-lnurl-online', + url: 'lnurl:https://lnurl.example', + base_url: 'https://lnurl.example', + name: null, + icon: null, + }, + { + // Offline 20 days: red glow beats the blue one, warning cell replaces the range. + // The blue badge stays, because which ecosystem it is has not changed. + ...lnurlBase, + host: 'fixture-lnurl-offline', + url: 'lnurl:https://lnurl.gone.example', + base_url: 'https://lnurl.gone.example', + name: 'Departed Notes', + icon: null, + status: 'offline', + last_online: NOW - 20 * 86400, + rating_avg: 2.1, + rating_distribution: dist(0, 1, 1, 3, 5), + review_count: 10, + }, + { + // A withdraw ceiling of zero, while ONLINE: green status chip stays, and the + // critical warning cell appears anyway. The one card that must never print + // "max 0 sat" as though it were a limit. + ...lnurlBase, + host: 'fixture-lnurl-no-withdrawals', + url: 'lnurl:https://lnurl.locked.example', + base_url: 'https://lnurl.locked.example', + name: 'Locked Notes Mint', + icon: null, + min_withdrawable_msat: 0, + max_withdrawable_msat: 0, + }, ]; diff --git a/web/scripts/og/model.mjs b/web/scripts/og/model.mjs index 819e623..441bbd6 100644 --- a/web/scripts/og/model.mjs +++ b/web/scripts/og/model.mjs @@ -14,10 +14,15 @@ * The warning logic is imported from shared/, not restated: the image must never * disagree with the page it decorates about whether a mint is frozen or gone. */ -import { getMintWarnings, parseLimits, readCapabilities } from '@cashumints/shared'; +import { getMintWarnings, msatToSat, parseLimits, readCapabilities } from '@cashumints/shared'; -/** Bump when the template's layout or copy changes, to regenerate every image. */ -export const TEMPLATE_VERSION = 1; +/** + * Bump when the template's layout or copy changes, to regenerate every image. + * + * 2: the LNURL variant. Its badge, glow and third cell are new, and the shared + * `warningCell` learned three LNURL warning kinds, so every card's hash input moved. + */ +export const TEMPLATE_VERSION = 2; const DAY = 86_400; @@ -115,6 +120,11 @@ function warningCell(warning, mint, now) { 'offline-long': 'Offline', 'offline': 'Offline', 'fedimint-offline': 'Reported offline', + // LNURL. `lnurl-withdrawals-disabled` gets the same two words as the Cashu + // melt-disabled cell, because it is the same statement to a reader. + 'lnurl-withdrawals-disabled': 'No withdrawals', + 'lnurl-no-funding': 'No mint / melt', + 'lnurl-invalid': 'Not responding properly', }[warning.kind]; if (!label) return null; @@ -135,11 +145,26 @@ function warningCell(warning, mint, now) { case 'frozen': sub = 'Nothing moves in or out'; break; + case 'lnurl-withdrawals-disabled': + sub = 'Notes cannot be redeemed'; + break; + case 'lnurl-no-funding': + // The precise half of the story, and the half that stops a reader writing the + // mint off: its notes still work, only Lightning is shut. + sub = 'Notes still rotate and split'; + break; + case 'lnurl-invalid': + sub = 'Endpoint answered with junk'; + break; } // The mint is also offline while a capability warning outranks that fact: the cell // still has to say so, the way the page banner folds it into its last sentence. - if (['melt-only', 'melt-disabled', 'frozen'].includes(warning.kind) && mint.status === 'offline' && since !== null) { + if ( + ['melt-only', 'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled'].includes(warning.kind) && + mint.status === 'offline' && + since !== null + ) { sub = `Offline since ${shortDate(since)}`; } @@ -227,6 +252,53 @@ export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now( }; } +/** + * One LNURL mint's card model. + * + * The same skeleton as the other two, with the third cell answering this ecosystem's + * own question: how much can one of this mint's bearer notes be worth? That is the + * withdraw range, in sats, converted here once from the millisatoshi the wire carries. + * + * `withdraw` is null when the ceiling is zero, and deliberately: a card printing + * "max 0 sat" reads as a number rather than as a refusal, and the warning cell that + * outranks it says the true thing instead. + */ +export function lnurlModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) { + const baseUrl = mint.base_url ?? mint.url.replace(/^lnurl:/, ''); + const domain = displayDomain(baseUrl); + const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain; + const warning = getMintWarnings(mint, { now })[0] ?? null; + const offline = mint.status === 'offline'; + const offlineDays = + offline && mint.last_online !== null + ? Math.max(0, Math.floor((now - mint.last_online) / DAY)) + : null; + + const min = msatToSat(mint.min_withdrawable_msat ?? null); + const max = msatToSat(mint.max_withdrawable_msat ?? null); + + return { + v: TEMPLATE_VERSION, + type: 'lnurl', + slug: mint.host, + name, + domain, + status: mint.status, + offlineDays, + rating: mint.rating_avg, + reviewCount: mint.review_count, + sentiment: sentiment(mint.rating_distribution ?? {}), + lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null, + withdraw: max !== null && max > 0 ? { min, max } : null, + /** The LUD-16 address, which is the one string a reader might actually copy. */ + address: mint.lightning_address ?? null, + warning: warningCell(warning, mint, now), + iconHash, + gradient: iconHash ? null : iconGradient(domain), + initials: iconHash ? null : initials(name), + }; +} + /** The default card: brand, tagline, and the network's own numbers. */ export function defaultModel(stats) { return { @@ -235,6 +307,7 @@ export function defaultModel(stats) { slug: 'default', mints: stats.cashu_total, federations: stats.fedimint_total, + lnurl: stats.lnurl_total, reviews: stats.reviews_total, }; } diff --git a/web/scripts/og/thumbs.mjs b/web/scripts/og/thumbs.mjs new file mode 100644 index 0000000..3b55314 --- /dev/null +++ b/web/scripts/og/thumbs.mjs @@ -0,0 +1,46 @@ +/** + * A contact sheet of the three ecosystems' cards at thumbnail size. + * + * Not part of the build: run it by hand (`node scripts/og/thumbs.mjs`) to check the + * one thing a full-size render cannot show you — whether a reader scrolling a timeline + * can tell a Cashu card from a Fedimint one from an LNURL one before any text is + * legible. Writes og-fixtures/_thumbs.png. + */ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Resvg } from '@resvg/resvg-js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const dir = path.resolve(here, '../../og-fixtures'); + +/** 300px wide: roughly how a link preview renders in a feed. */ +const W = 300; +const H = Math.round((630 / 1200) * W); +const GAP = 16; +const LABEL = 22; + +const cards = [ + ['Cashu', 'fixture-no-icon.png'], + ['Fedimint', 'fixture-fed-announced.png'], + ['LNURL', 'fixture-lnurl-online.png'], +]; + +const parts = await Promise.all( + cards.map(async ([label, file], i) => { + const data = await readFile(path.join(dir, file)); + const x = i * (W + GAP); + return ( + `` + + `${label}` + ); + }), +); + +const svg = + `${parts.join('')}`; + +await writeFile(path.join(dir, '_thumbs.png'), new Resvg(svg).render().asPng()); +console.log(`og: og-fixtures/_thumbs.png (${cards.length} cards at ${W}px)`); diff --git a/web/src/bones/mint-page.bones.json b/web/src/bones/mint-page.bones.json index 114c06b..b515a9b 100644 --- a/web/src/bones/mint-page.bones.json +++ b/web/src/bones/mint-page.bones.json @@ -4,7 +4,7 @@ "name": "mint-page", "viewportWidth": 324, "width": 324, - "height": 327, + "height": 407, "bones": [ [ 0, @@ -16,14 +16,14 @@ [ 20.9877, 0, - 59.2593, + 54.4078, 43, 8 ], [ 20.9877, 43, - 59.2593, + 54.4078, 22, 8 ], @@ -102,8 +102,15 @@ 0, 265, 100, - 63, + 84, 8 + ], + [ + 0, + 366, + 42.284, + 41, + 10 ] ] }, @@ -111,7 +118,7 @@ "name": "mint-page", "viewportWidth": 712, "width": 712, - "height": 193, + "height": 252, "bones": [ [ 0, @@ -123,19 +130,19 @@ [ 9.5506, 0, - 26.9663, + 24.7586, 43, 8 ], [ 9.5506, 43, - 26.9663, + 24.7586, 22, 8 ], [ - 38.764, + 36.5564, 18, 10.8146, 29, @@ -143,7 +150,7 @@ true ], [ - 40.4494, + 38.2417, 29, 0.9831, 7, @@ -211,6 +218,13 @@ 100, 42, 8 + ], + [ + 0, + 211, + 19.2416, + 41, + 10 ] ] }, @@ -218,7 +232,7 @@ "name": "mint-page", "viewportWidth": 1184, "width": 1184, - "height": 172, + "height": 231, "bones": [ [ 0, @@ -230,19 +244,19 @@ [ 5.7432, 0, - 16.2162, + 14.8886, 43, 8 ], [ 5.7432, 43, - 16.2162, + 14.8886, 22, 8 ], [ - 23.3108, + 21.9832, 18, 6.5034, 29, @@ -250,7 +264,7 @@ true ], [ - 24.3243, + 22.9967, 29, 0.5912, 7, @@ -318,9 +332,16 @@ 100, 21, 8 + ], + [ + 0, + 190, + 11.5709, + 41, + 10 ] ] } }, - "_hash": "f3f7f7e86a10ecca631b74af17fb0335" + "_hash": "e76f6212eab4b1517002712803fae94e" } \ No newline at end of file diff --git a/web/src/bones/reviews-panel.bones.json b/web/src/bones/reviews-panel.bones.json index 79757f4..b293b1f 100644 --- a/web/src/bones/reviews-panel.bones.json +++ b/web/src/bones/reviews-panel.bones.json @@ -4,132 +4,181 @@ "name": "reviews-panel", "viewportWidth": 322, "width": 322, - "height": 727, + "height": 876, "bones": [ [ 6.2112, - 20, + 18, 11.8012, 38, "50%" ], [ 22.9814, - 19, + 17, 32.6087, 22, 8 ], [ 22.9814, - 41, + 39, 70.8075, - 16, + 17, 5 ], [ 22.9814, - 70, + 68, 20.429, 16, 8 ], [ - 46.5159, - 68, + 45.8948, + 66, + 0.9317, + 20, + 8 + ], + [ + 49.3109, + 66, 11.8012, - 19, + 20, 8 ], [ 22.9814, - 98, + 96, 70.8075, - 116, + 144, + 8 + ], + [ + 22.9814, + 246, + 70.8075, + 27, 8 ], [ 6.2112, - 255, + 310, 11.8012, 38, "50%" ], [ 22.9814, - 254, + 309, 23.6025, 22, 8 ], [ 22.9814, - 277, + 331, 70.8075, - 16, + 17, 5 ], [ 22.9814, - 305, + 360, 20.429, 16, 8 ], [ - 46.5159, - 303, + 45.8948, + 358, + 0.9317, + 20, + 8 + ], + [ + 49.3109, + 358, 12.1118, - 19, + 20, 8 ], [ 22.9814, - 333, + 388, 70.8075, - 116, + 120, + 8 + ], + [ + 22.9814, + 514, + 70.8075, + 27, 8 ], [ 6.2112, - 490, + 578, 11.8012, 38, "50%" ], [ 22.9814, - 489, + 577, 31.0559, 22, 8 ], [ 22.9814, - 512, + 599, 70.8075, - 16, + 17, 5 ], [ 22.9814, - 540, + 628, 20.429, 16, 8 ], [ - 46.5159, - 538, + 45.8948, + 626, + 0.9317, + 20, + 8 + ], + [ + 49.3109, + 626, 12.7329, - 19, + 20, 8 ], [ 22.9814, - 568, + 656, 70.8075, - 139, + 144, + 8 + ], + [ + 22.9814, + 806, + 20.8075, + 17, + 8 + ], + [ + 22.9814, + 831, + 70.8075, + 27, 8 ] ] @@ -138,132 +187,174 @@ "name": "reviews-panel", "viewportWidth": 710, "width": 710, - "height": 455, + "height": 533, "bones": [ [ 2.8169, - 20, + 18, 5.3521, 38, "50%" ], [ 10.4225, - 21, + 17, 14.7887, 22, 8 ], [ 10.4225, - 43, - 75.2421, - 16, + 39, + 67.7773, + 17, 5 ], [ - 87.9181, - 22, + 80.4533, + 21, 9.265, 16, 8 ], [ - 91.831, - 42, - 5.3521, + 90.5634, 19, + 0.4225, + 20, + 8 + ], + [ + 91.831, + 19, + 5.3521, + 20, 8 ], [ 10.4225, - 77, + 68, + 82.3944, + 48, + 8 + ], + [ + 10.4225, + 124, 86.7606, - 46, + 27, 8 ], [ 2.8169, - 164, + 188, 5.3521, 38, "50%" ], [ 10.4225, - 165, + 187, 10.7042, 22, 8 ], [ 10.4225, - 187, - 75.2421, - 16, + 209, + 67.6364, + 17, 5 ], [ - 87.9181, - 166, + 80.3125, + 191, 9.265, 16, 8 ], + [ + 90.4225, + 189, + 0.4225, + 20, + 8 + ], [ 91.6901, - 186, + 189, 5.493, - 19, + 20, 8 ], [ 10.4225, - 221, + 238, + 82.3944, + 48, + 8 + ], + [ + 10.4225, + 294, 86.7606, - 46, + 27, 8 ], [ 2.8169, - 308, + 358, 5.3521, 38, "50%" ], [ 10.4225, - 309, + 357, 14.0845, 22, 8 ], [ 10.4225, - 331, - 75.2421, - 16, + 379, + 67.3548, + 17, 5 ], [ - 87.9181, - 310, + 80.0308, + 361, 9.265, 16, 8 ], + [ + 90.1408, + 359, + 0.4225, + 20, + 8 + ], [ 91.4085, - 330, + 359, 5.7746, - 19, + 20, 8 ], [ 10.4225, - 365, + 408, + 82.3944, + 72, + 8 + ], + [ + 10.4225, + 488, 86.7606, - 70, + 27, 8 ] ] @@ -272,136 +363,178 @@ "name": "reviews-panel", "viewportWidth": 784, "width": 784, - "height": 431, + "height": 533, "bones": [ [ 2.551, - 20, + 18, 4.8469, 38, "50%" ], [ 9.4388, - 21, + 17, 13.3929, 22, 8 ], [ 9.4388, - 43, - 77.5789, - 16, + 39, + 70.8187, + 17, 5 ], [ - 89.0585, - 22, + 82.2983, + 21, 8.3905, 16, 8 ], [ - 92.602, - 42, - 4.8469, + 91.4541, 19, + 0.3827, + 20, + 8 + ], + [ + 92.602, + 19, + 4.8469, + 20, 8 ], [ 9.4388, - 77, + 68, + 74.6173, + 48, + 8 + ], + [ + 9.4388, + 124, 88.0102, - 46, + 27, 8 ], [ 2.551, - 164, + 188, 4.8469, 38, "50%" ], [ 9.4388, - 165, + 187, 9.6939, 22, 8 ], [ 9.4388, - 187, - 77.5789, - 16, + 209, + 70.6912, + 17, 5 ], [ - 89.0585, - 166, + 82.1708, + 191, 8.3905, 16, 8 ], + [ + 91.3265, + 189, + 0.3827, + 20, + 8 + ], [ 92.4745, - 186, + 189, 4.9745, - 19, + 20, 8 ], [ 9.4388, - 221, + 238, + 74.6173, + 48, + 8 + ], + [ + 9.4388, + 294, 88.0102, - 46, + 27, 8 ], [ 2.551, - 308, + 358, 4.8469, 38, "50%" ], [ 9.4388, - 309, + 357, 12.7551, 22, 8 ], [ 9.4388, - 331, - 77.5789, - 16, + 379, + 70.4361, + 17, 5 ], [ - 89.0585, - 310, + 81.9157, + 361, 8.3905, 16, 8 ], + [ + 91.0714, + 359, + 0.3827, + 20, + 8 + ], [ 92.2194, - 330, + 359, 5.2296, - 19, + 20, 8 ], [ 9.4388, - 365, + 408, + 74.6173, + 72, + 8 + ], + [ + 9.4388, + 488, 88.0102, - 46, + 27, 8 ] ] } }, - "_hash": "788093c50463a4ad673ece50c6db52da" + "_hash": "d9fb0ac370be8e706ff4dfea00b0081b" } \ No newline at end of file diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro index 4f2d13d..22023c7 100644 --- a/web/src/components/Footer.astro +++ b/web/src/components/Footer.astro @@ -1,5 +1,23 @@ --- +/** + * The site footer: one component, every page, three zones. + * + * Zone one is four columns — who this is, then the two link groups people actually + * navigate to, then the language control. Zone two is a thin bar with the copyright + * and the credit, and nothing else: the moment anything else moves into that row it + * stops being a signature and starts being a fourth navigation surface. + * + * What used to be here was four flat rows, the last of which was ~20 raw language + * links wrapped over two lines. That wall is now the same switcher the header carries + * (see LanguageSwitcher.astro, `placement="footer"`), so there is one language control + * on the site in two places rather than two controls that happen to agree. + * + * Everything below is static per locale and prerenders. No hex, no npub, no counts, + * nothing fetched: the footer is the one region of every page that must be identical + * on the home page and on a mint page that failed to load. + */ import LanguageSwitcher from './LanguageSwitcher.astro'; +import Moai from './Moai.astro'; import { localePath, useI18n } from '../i18n'; import { pageLocale } from '../i18n/paths'; @@ -9,23 +27,29 @@ const GITHUB = 'https://github.com/Azzamo-net/cashumints.space'; const { locale } = pageLocale(Astro); const t = useI18n(locale); -/** - * Two rows: the pages people navigate to, then the ones they go looking for. The - * disclaimer sits in the second row here and again at the bottom of every mint page, - * which is where the decision it is about actually gets made. +/* + * Route slugs stay English in every language and `localePath` adds the prefix, exactly + * as the header does. See src/i18n/routing.ts for why that trade was made. */ -const nav = [ - { path: '/mints', label: t('footer.allMints') }, - { path: '/fedimints', label: t('footer.allFedimints') }, - { path: '/reviews', label: t('footer.allReviews') }, - { path: '/wallets', label: t('footer.wallets') }, - { path: '/about', label: t('footer.about') }, +const explore = [ + { href: localePath('/mints', locale), label: t('footer.allMints') }, + { href: localePath('/fedimints', locale), label: t('footer.allFedimints') }, + { href: localePath('/lnurl-mints', locale), label: t('footer.allLnurlMints') }, + { href: localePath('/reviews', locale), label: t('footer.allReviews') }, + { href: localePath('/wallets', locale), label: t('footer.wallets') }, ]; -const legal = [ - { path: '/disclaimer', label: t('footer.disclaimer') }, - { path: '/terms', label: t('footer.terms') }, - { path: '/privacy', label: t('footer.privacy') }, +/* + * GitHub sits in this column rather than beside the credit line because it is a place + * to go, not a signature. It is the only external link in the columns, so it carries + * `rel="noopener"` and nothing else needs to. + */ +const site = [ + { href: localePath('/about', locale), label: t('footer.about'), external: false }, + { href: GITHUB, label: 'GitHub', external: true }, + { href: localePath('/terms', locale), label: t('footer.terms'), external: false }, + { href: localePath('/privacy', locale), label: t('footer.privacy'), external: false }, + { href: localePath('/disclaimer', locale), label: t('footer.disclaimer'), external: false }, ]; /* @@ -39,31 +63,65 @@ const madeBy = t('footer.madeBy', { }); --- -