Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+95 -1
View File
@@ -1,6 +1,8 @@
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { DEFAULT_RELAYS } from '@cashumints/shared';
import type { Dialect } from './db-driver.ts';
import { redact } from './db-postgres.ts';
const here = path.dirname(fileURLToPath(import.meta.url));
const apiRoot = path.resolve(here, '..');
@@ -12,9 +14,101 @@ function int(name: string, fallback: number): number {
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export interface DbConfig {
dialect: Dialect;
/** SQLite file. Empty when the dialect is postgres. */
file: string;
/** libpq connection string. Empty when the dialect is sqlite. */
url: string;
/** Postgres connections held open. Ignored by SQLite, which has one. */
poolMax: number;
/** Safe to log: a Postgres password is replaced with `***`. */
label: string;
}
export const DEFAULT_DB_FILE = path.join(apiRoot, 'data', 'cashumints.db');
/**
* Read one connection target.
*
* Accepts what a person is likely to type or paste:
*
* postgres://user:pw@host:5432/cashumints postgres
* postgresql://… postgres
* sqlite:/var/lib/cashumints/cashumints.db sqlite, absolute
* sqlite://./data/cashumints.db sqlite, relative to the working directory
* file:./data/cashumints.db sqlite
* /var/lib/cashumints/cashumints.db sqlite, a bare path
* :memory: sqlite, throwaway
*
* Throws on anything else rather than guessing, because the wrong guess here is a
* second empty database that looks like data loss.
*/
export function parseDbTarget(raw: string, poolMax = 10): DbConfig {
const value = raw.trim();
if (!value) throw new Error('Empty database target.');
const sqlite = (file: string): DbConfig => {
if (!file) throw new Error(`Database target has no path after the scheme: ${value}`);
const resolved = file === ':memory:' ? file : path.resolve(file);
return { dialect: 'sqlite', file: resolved, url: '', poolMax, label: `sqlite:${resolved}` };
};
// A scheme is checked before anything else, so an unsupported one is an error rather
// than a filename. Left to a "does it look like a path?" heuristic, `mysql://db/x`
// reads as a relative path and the API starts on a brand new empty SQLite file — data
// loss that announces itself as a successful boot.
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
switch (scheme) {
case undefined:
return sqlite(value); // A bare path, absolute or relative.
case 'postgres':
case 'postgresql':
return { dialect: 'postgres', file: '', url: value, poolMax, label: redact(value) };
case 'sqlite':
case 'sqlite3':
case 'file':
return sqlite(value.replace(/^[a-z0-9+.-]+:(?:\/\/)?/i, ''));
default:
// `:memory:` has no scheme by this reading — the regex needs a letter first.
if (value === ':memory:') return sqlite(value);
throw new Error(
`Cannot tell what database "${value}" means. Use a postgres:// URL, ` +
'a sqlite: path, or a filesystem path.',
);
}
}
/**
* Where this process keeps its data.
*
* `DATABASE_URL` decides the backend. Without it the API stays on SQLite at `DB_PATH`,
* which is what every existing deployment already has, so adding Postgres support
* changed nothing for anyone who does not ask for it.
*/
export function resolveDbConfig(env: NodeJS.ProcessEnv = process.env): DbConfig {
const poolMax = int('DB_POOL_MAX', 10);
const url = env['DATABASE_URL']?.trim();
if (url) return parseDbTarget(url, poolMax);
const file = path.resolve(env['DB_PATH'] ?? DEFAULT_DB_FILE);
return { dialect: 'sqlite', file, url: '', poolMax, label: `sqlite:${file}` };
}
let dbConfig: DbConfig | null = null;
export const config = {
port: int('PORT', 8787),
dbPath: process.env['DB_PATH'] ?? path.join(apiRoot, 'data', 'cashumints.db'),
/**
* Resolved on first use rather than at import, so a process can still redirect itself
* — `test:offline` points at a throwaway copy by setting DATABASE_URL before anything
* opens a connection, and would otherwise rug the live database instead.
*/
get db(): DbConfig {
dbConfig ??= resolveDbConfig();
return dbConfig;
},
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
[...DEFAULT_RELAYS]) as string[],