Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
+95
-1
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user