Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
+89
-7
@@ -11,9 +11,48 @@ import type { Profile } from './types.js';
|
||||
export const KIND_REVIEW = 38000;
|
||||
/** NIP-87 Cashu mint announcement. */
|
||||
export const KIND_MINT_ANNOUNCEMENT = 38172;
|
||||
/** NIP-87 Fedimint federation announcement. */
|
||||
export const KIND_FEDIMINT_ANNOUNCEMENT = 38173;
|
||||
/** Profile metadata. */
|
||||
export const KIND_PROFILE = 0;
|
||||
|
||||
/**
|
||||
* The one place an ecosystem is tied to its announcement kind.
|
||||
*
|
||||
* Everything downstream reads this rather than testing for 38172 or 38173 itself: the
|
||||
* indexer's subscription list, the review resolver, the `k` tag a published review
|
||||
* carries, and the filter on /reviews. Adding a third ecosystem is an entry here plus
|
||||
* a probe strategy and its pages — see the "Adding an ecosystem" section of README.
|
||||
*
|
||||
* `mints.type` holds these keys verbatim, so they are a stored value: never rename one
|
||||
* without a data migration.
|
||||
*/
|
||||
export const ANNOUNCEMENT_KINDS = {
|
||||
cashu: KIND_MINT_ANNOUNCEMENT,
|
||||
fedimint: KIND_FEDIMINT_ANNOUNCEMENT,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Ecosystems in display order. Cashu first: it is what this site was, and what most of
|
||||
* its content still is.
|
||||
*/
|
||||
export const ECOSYSTEMS = Object.keys(ANNOUNCEMENT_KINDS) as Array<keyof typeof ANNOUNCEMENT_KINDS>;
|
||||
|
||||
/** The announcement kind for an ecosystem, or null for one this build does not know. */
|
||||
export function announcementKind(type: string): number | null {
|
||||
return (ANNOUNCEMENT_KINDS as Record<string, number>)[type] ?? null;
|
||||
}
|
||||
|
||||
/** The ecosystem an announcement kind belongs to, or null. */
|
||||
export function ecosystemForKind(kind: number | string | null): string | null {
|
||||
const n = typeof kind === 'string' ? Number.parseInt(kind, 10) : kind;
|
||||
if (n === null || !Number.isFinite(n)) return null;
|
||||
for (const [type, value] of Object.entries(ANNOUNCEMENT_KINDS)) {
|
||||
if (value === n) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of the old site's read pool (utils/ndk.ts CASHU_RELAY_POOL) and its write pool
|
||||
* (services/reviewPublisher.ts RELAY_URLS). The two lists differed, so reviews the old
|
||||
@@ -180,6 +219,45 @@ export function isCashuMintReview(event: NostrEventLike): boolean {
|
||||
return tagValue(event.tags, 'k') === String(KIND_MINT_ANNOUNCEMENT);
|
||||
}
|
||||
|
||||
/** True when the event's `k` tag marks it as being about a Fedimint federation. */
|
||||
export function isFedimintReview(event: NostrEventLike): boolean {
|
||||
return tagValue(event.tags, 'k') === String(KIND_FEDIMINT_ANNOUNCEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind a review says it is about, from its `k` tag, as a number.
|
||||
*
|
||||
* null when the tag is missing or unparseable, which is the shape most reviews already
|
||||
* on the network have: the old publisher wrote `k` but plenty of other clients do not.
|
||||
* A review with no `k` is treated as Cashu by every resolver here, because that is the
|
||||
* only ecosystem that existed when those events were written.
|
||||
*/
|
||||
export function reviewTargetKind(event: NostrEventLike): number | null {
|
||||
const raw = tagValue(event.tags, 'k');
|
||||
if (!raw) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which ecosystem a review is about: its `k` tag, falling back to Cashu.
|
||||
*
|
||||
* The fallback is not a guess about the future, it is a fact about the past. Every
|
||||
* kind 38000 written before Fedimint support existed is about a Cashu mint, and a great
|
||||
* many of them carry no `k` at all (see NOTES.md). A review whose `k` names a kind this
|
||||
* build does not know returns null and is skipped rather than filed under Cashu.
|
||||
*/
|
||||
export function reviewEcosystem(event: NostrEventLike): string | null {
|
||||
const kind = reviewTargetKind(event);
|
||||
if (kind === null) return 'cashu';
|
||||
return ecosystemForKind(kind);
|
||||
}
|
||||
|
||||
/** The `d` identifier a review points at, verbatim. Resolution is the caller's job. */
|
||||
export function reviewTargetId(event: NostrEventLike): string | null {
|
||||
return tagValue(event.tags, 'd');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every spelling of a mint URL that may appear in a `u` tag.
|
||||
*
|
||||
@@ -210,8 +288,12 @@ export const MAX_PROFILE_NAME = 24;
|
||||
* Written as a code point scan rather than a regex because the set is exactly the
|
||||
* invisible ones: C0 and C1 controls, zero width joiners and the bidi overrides that
|
||||
* let a name reorder the text around it.
|
||||
*
|
||||
* Exported because a Fedimint announcement's `content` carries a federation name
|
||||
* written by the same kind of stranger, and `fedimint.ts` has no business owning a
|
||||
* second copy of these rules.
|
||||
*/
|
||||
function sanitizeText(value: unknown, max: number): string | undefined {
|
||||
export function sanitizeDisplayText(value: unknown, max: number): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
let out = '';
|
||||
@@ -238,7 +320,7 @@ function sanitizeText(value: unknown, max: number): string | undefined {
|
||||
* been verified against a domain. Anything not shaped like an address is dropped.
|
||||
*/
|
||||
function sanitizeNip05(value: unknown, max: number): string | undefined {
|
||||
const text = sanitizeText(value, max);
|
||||
const text = sanitizeDisplayText(value, max);
|
||||
if (!text || text.length > max) return undefined;
|
||||
if (!/^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text)) return undefined;
|
||||
// "_@domain" is NIP-05's root form and reads better as the bare domain.
|
||||
@@ -249,7 +331,7 @@ function sanitizeNip05(value: unknown, max: number): string | undefined {
|
||||
* Only http(s) picture URLs are accepted. A `data:` or `javascript:` picture from a
|
||||
* relay has no business being written into an `img src`.
|
||||
*/
|
||||
function sanitizePicture(value: unknown): string | undefined {
|
||||
export function sanitizePictureUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const url = value.trim();
|
||||
if (!/^https?:\/\/[^\s"'<>]+$/i.test(url) || url.length > 400) return undefined;
|
||||
@@ -278,11 +360,11 @@ export function parseProfileContent(pubkey: string, content: string): Profile {
|
||||
|
||||
const profile: Profile = {
|
||||
pubkey,
|
||||
name: sanitizeText(meta['name'], MAX_PROFILE_NAME),
|
||||
name: sanitizeDisplayText(meta['name'], MAX_PROFILE_NAME),
|
||||
display_name:
|
||||
sanitizeText(meta['display_name'], MAX_PROFILE_NAME)
|
||||
?? sanitizeText(meta['displayName'], MAX_PROFILE_NAME),
|
||||
picture: sanitizePicture(meta['picture']),
|
||||
sanitizeDisplayText(meta['display_name'], MAX_PROFILE_NAME)
|
||||
?? sanitizeDisplayText(meta['displayName'], MAX_PROFILE_NAME),
|
||||
picture: sanitizePictureUrl(meta['picture']),
|
||||
nip05: sanitizeNip05(meta['nip05'], 64),
|
||||
found: true,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user