diff --git a/backend/.env.example b/backend/.env.example index 5029c2d..737d334 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -58,7 +58,8 @@ DEPOSIT_LNURLP=https://yourdomain.com/.well-known/lnurlp/faucet BASE_SPONSOR_PRICE_PER_DAY=200 SPONSOR_MAX_ACTIVE_PER_USER=5 SPONSOR_MAX_VISIBLE=6 -# Comma-separated hex pubkeys for admin API +# Admin API: shared secret or Nostr pubkeys +# ADMIN_API_KEY=your-secure-random-key-min-32-chars # ADMIN_PUBKEYS=abc123...,def456... # Public API URL for LNbits webhook (e.g. https://api.example.com) # PUBLIC_API_URL=https://api.example.com diff --git a/backend/src/config.ts b/backend/src/config.ts index f7c1fa5..54f9abd 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -82,6 +82,7 @@ export const config = { sponsorMaxActivePerUser: envInt("SPONSOR_MAX_ACTIVE_PER_USER", 5), sponsorMaxVisible: envInt("SPONSOR_MAX_VISIBLE", 6), adminPubkeys: (process.env.ADMIN_PUBKEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean), + adminApiKey: (process.env.ADMIN_API_KEY ?? "").trim(), /** Public API base URL for LNbits webhook (e.g. https://api.example.com). Required for sponsor payments. */ publicApiUrl: (process.env.PUBLIC_API_URL ?? process.env.FRONTEND_URL ?? "").replace(/\/$/, ""), }; diff --git a/backend/src/db/pg.ts b/backend/src/db/pg.ts index 1508d82..1c74054 100644 --- a/backend/src/db/pg.ts +++ b/backend/src/db/pg.ts @@ -154,6 +154,28 @@ export function createPgDb(connectionString: string): Db { ); }, + async clearUserCache(pubkey: string): Promise { + await pool.query("UPDATE users SET last_metadata_fetch_at = 0 WHERE pubkey = $1", [pubkey]); + }, + + async clearAllUsersCache(): Promise { + const res = await pool.query("UPDATE users SET last_metadata_fetch_at = 0"); + return res.rowCount ?? 0; + }, + + async updateUserNostrFirstSeenAt(pubkey: string, nostr_first_seen_at: number): Promise { + const now = Math.floor(Date.now() / 1000); + await pool.query( + `INSERT INTO users (pubkey, nostr_first_seen_at, notes_count, followers_count, following_count, activity_score, last_metadata_fetch_at, lightning_address, name, created_at, updated_at) + VALUES ($1, $2, 0, 0, 0, 0, $3, NULL, NULL, $3, $3) + ON CONFLICT(pubkey) DO UPDATE SET + nostr_first_seen_at = EXCLUDED.nostr_first_seen_at, + last_metadata_fetch_at = EXCLUDED.last_metadata_fetch_at, + updated_at = EXCLUDED.updated_at`, + [pubkey, nostr_first_seen_at, now] + ); + }, + async getLastSuccessfulClaimByPubkey(pubkey: string): Promise { const res = await pool.query( "SELECT * FROM claims WHERE pubkey = $1 AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1", diff --git a/backend/src/db/sqlite.ts b/backend/src/db/sqlite.ts index 568f89e..c93e473 100644 --- a/backend/src/db/sqlite.ts +++ b/backend/src/db/sqlite.ts @@ -112,6 +112,27 @@ export function createSqliteDb(path: string): Db { ); }, + async clearUserCache(pubkey: string): Promise { + db.prepare("UPDATE users SET last_metadata_fetch_at = 0 WHERE pubkey = ?").run(pubkey); + }, + + async clearAllUsersCache(): Promise { + const stmt = db.prepare("UPDATE users SET last_metadata_fetch_at = 0"); + return stmt.run().changes; + }, + + async updateUserNostrFirstSeenAt(pubkey: string, nostr_first_seen_at: number): Promise { + const now = Math.floor(Date.now() / 1000); + db.prepare( + `INSERT INTO users (pubkey, nostr_first_seen_at, notes_count, followers_count, following_count, activity_score, last_metadata_fetch_at, lightning_address, name, created_at, updated_at) + VALUES (?, ?, 0, 0, 0, 0, ?, NULL, NULL, ?, ?) + ON CONFLICT(pubkey) DO UPDATE SET + nostr_first_seen_at = excluded.nostr_first_seen_at, + last_metadata_fetch_at = excluded.last_metadata_fetch_at, + updated_at = excluded.updated_at` + ).run(pubkey, nostr_first_seen_at, now, now, now); + }, + async getLastSuccessfulClaimByPubkey(pubkey: string): Promise { const row = db .prepare("SELECT * FROM claims WHERE pubkey = ? AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1") diff --git a/backend/src/db/types.ts b/backend/src/db/types.ts index c839bf7..22e8a05 100644 --- a/backend/src/db/types.ts +++ b/backend/src/db/types.ts @@ -90,6 +90,9 @@ export interface Db { last_metadata_fetch_at: number; } ): Promise; + clearUserCache(pubkey: string): Promise; + clearAllUsersCache(): Promise; + updateUserNostrFirstSeenAt(pubkey: string, nostr_first_seen_at: number): Promise; getLastSuccessfulClaimByPubkey(pubkey: string): Promise; getLastClaimByIpHash(ipHash: string): Promise; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 63d76fb..4900b8c 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "crypto"; import { Router, Request, Response, NextFunction } from "express"; import { config } from "../config.js"; import { getDb } from "../db/index.js"; @@ -5,15 +6,29 @@ import { verifyJwt } from "../auth/jwt.js"; const router = Router(); +function constantTimeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false; + const bufA = Buffer.from(a, "utf8"); + const bufB = Buffer.from(b, "utf8"); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + function adminAuth(req: Request, res: Response, next: NextFunction): void { - if (config.adminPubkeys.length === 0) { + const adminEnabled = config.adminPubkeys.length > 0 || config.adminApiKey.length > 0; + if (!adminEnabled) { res.status(503).json({ code: "admin_disabled", message: "Admin API not configured" }); return; } const auth = req.headers.authorization; const adminKey = req.headers["x-admin-key"]; - let pubkey: string | null = null; + const providedKey = typeof adminKey === "string" ? adminKey.trim() : auth?.startsWith("Bearer ") ? auth.slice(7).trim() : null; + if (config.adminApiKey.length > 0 && providedKey && constantTimeCompare(providedKey, config.adminApiKey)) { + next(); + return; + } + let pubkey: string | null = null; if (auth?.startsWith("Bearer ")) { const token = auth.slice(7).trim(); const payload = verifyJwt(token); @@ -22,11 +37,11 @@ function adminAuth(req: Request, res: Response, next: NextFunction): void { if (!pubkey && typeof adminKey === "string") { pubkey = adminKey.trim(); } - if (!pubkey || !config.adminPubkeys.includes(pubkey)) { - res.status(403).json({ code: "forbidden", message: "Admin access required" }); + if (pubkey && config.adminPubkeys.includes(pubkey)) { + next(); return; } - next(); + res.status(403).json({ code: "forbidden", message: "Admin access required" }); } router.use(adminAuth); @@ -115,4 +130,49 @@ router.delete("/sponsors/:id", async (req: Request, res: Response) => { res.status(200).json({ ok: true }); }); +const NOSTR_EPOCH = 1609459200; // 2021-01-01 UTC + +router.post("/users/clear-cache", async (req: Request, res: Response) => { + const db = getDb(); + const clearedCount = await db.clearAllUsersCache(); + res.json({ + ok: true, + clearedCount, + message: `Cache cleared for ${clearedCount} user(s). Next claims will re-fetch from relays.`, + }); +}); + +router.post("/users/:pubkey/clear-cache", async (req: Request, res: Response) => { + const pubkey = req.params.pubkey?.trim(); + if (!pubkey || pubkey.length !== 64 || !/^[a-fA-F0-9]+$/.test(pubkey)) { + res.status(400).json({ code: "invalid_pubkey", message: "Valid 64-char hex pubkey required" }); + return; + } + const db = getDb(); + await db.clearUserCache(pubkey); + res.json({ ok: true, message: "User cache cleared. Next claim will re-fetch from relays." }); +}); + +router.post("/users/:pubkey/override-age", async (req: Request, res: Response) => { + const pubkey = req.params.pubkey?.trim(); + if (!pubkey || pubkey.length !== 64 || !/^[a-fA-F0-9]+$/.test(pubkey)) { + res.status(400).json({ code: "invalid_pubkey", message: "Valid 64-char hex pubkey required" }); + return; + } + const ts = typeof (req.body as { nostr_first_seen_at?: number }).nostr_first_seen_at === "number" + ? (req.body as { nostr_first_seen_at: number }).nostr_first_seen_at + : NaN; + const now = Math.floor(Date.now() / 1000); + if (!Number.isFinite(ts) || ts >= now || ts < NOSTR_EPOCH) { + res.status(400).json({ + code: "invalid_timestamp", + message: "nostr_first_seen_at must be a Unix timestamp in the past (between 2021-01-01 and now)", + }); + return; + } + const db = getDb(); + await db.updateUserNostrFirstSeenAt(pubkey, ts); + res.json({ ok: true, message: "Account age overridden. User can claim on next attempt." }); +}); + export default router; diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts index 7744a90..7411285 100644 --- a/backend/src/services/nostr.ts +++ b/backend/src/services/nostr.ts @@ -59,7 +59,7 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = cached.nostr_first_seen_at >= ageCutoffSec && cached.nostr_first_seen_at <= ageCutoffSec + borderlineWindowSec; - if (!forceRefreshProfile && cached && cacheValidUntil > nowSec && !isBorderline) { + if (!forceRefreshProfile && cached && cacheValidUntil > nowSec && !isBorderline && cached.nostr_first_seen_at != null) { return { nostrFirstSeenAt: cached.nostr_first_seen_at, notesCount: cached.notes_count,