Admin API key auth, account_too_new fixes, clear-all-users cache
- Add ADMIN_API_KEY to config; accept via X-Admin-Key or Bearer (constant-time compare) - Never serve cached null for nostr_first_seen_at; admin clear-cache and override-age - Add clearAllUsersCache() and POST /admin/users/clear-cache for all users - Update .env.example with admin API key and pubkeys comments Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(/\/$/, ""),
|
||||
};
|
||||
|
||||
@@ -154,6 +154,28 @@ export function createPgDb(connectionString: string): Db {
|
||||
);
|
||||
},
|
||||
|
||||
async clearUserCache(pubkey: string): Promise<void> {
|
||||
await pool.query("UPDATE users SET last_metadata_fetch_at = 0 WHERE pubkey = $1", [pubkey]);
|
||||
},
|
||||
|
||||
async clearAllUsersCache(): Promise<number> {
|
||||
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<void> {
|
||||
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<ClaimRow | null> {
|
||||
const res = await pool.query(
|
||||
"SELECT * FROM claims WHERE pubkey = $1 AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1",
|
||||
|
||||
@@ -112,6 +112,27 @@ export function createSqliteDb(path: string): Db {
|
||||
);
|
||||
},
|
||||
|
||||
async clearUserCache(pubkey: string): Promise<void> {
|
||||
db.prepare("UPDATE users SET last_metadata_fetch_at = 0 WHERE pubkey = ?").run(pubkey);
|
||||
},
|
||||
|
||||
async clearAllUsersCache(): Promise<number> {
|
||||
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<void> {
|
||||
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<ClaimRow | null> {
|
||||
const row = db
|
||||
.prepare("SELECT * FROM claims WHERE pubkey = ? AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1")
|
||||
|
||||
@@ -90,6 +90,9 @@ export interface Db {
|
||||
last_metadata_fetch_at: number;
|
||||
}
|
||||
): Promise<void>;
|
||||
clearUserCache(pubkey: string): Promise<void>;
|
||||
clearAllUsersCache(): Promise<number>;
|
||||
updateUserNostrFirstSeenAt(pubkey: string, nostr_first_seen_at: number): Promise<void>;
|
||||
|
||||
getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null>;
|
||||
getLastClaimByIpHash(ipHash: string): Promise<ClaimRow | null>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user