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
|
BASE_SPONSOR_PRICE_PER_DAY=200
|
||||||
SPONSOR_MAX_ACTIVE_PER_USER=5
|
SPONSOR_MAX_ACTIVE_PER_USER=5
|
||||||
SPONSOR_MAX_VISIBLE=6
|
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...
|
# ADMIN_PUBKEYS=abc123...,def456...
|
||||||
# Public API URL for LNbits webhook (e.g. https://api.example.com)
|
# Public API URL for LNbits webhook (e.g. https://api.example.com)
|
||||||
# PUBLIC_API_URL=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),
|
sponsorMaxActivePerUser: envInt("SPONSOR_MAX_ACTIVE_PER_USER", 5),
|
||||||
sponsorMaxVisible: envInt("SPONSOR_MAX_VISIBLE", 6),
|
sponsorMaxVisible: envInt("SPONSOR_MAX_VISIBLE", 6),
|
||||||
adminPubkeys: (process.env.ADMIN_PUBKEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
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. */
|
/** 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(/\/$/, ""),
|
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> {
|
async getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null> {
|
||||||
const res = await pool.query(
|
const res = await pool.query(
|
||||||
"SELECT * FROM claims WHERE pubkey = $1 AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1",
|
"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> {
|
async getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null> {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare("SELECT * FROM claims WHERE pubkey = ? AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1")
|
.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;
|
last_metadata_fetch_at: number;
|
||||||
}
|
}
|
||||||
): Promise<void>;
|
): 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>;
|
getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null>;
|
||||||
getLastClaimByIpHash(ipHash: 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 { Router, Request, Response, NextFunction } from "express";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { getDb } from "../db/index.js";
|
import { getDb } from "../db/index.js";
|
||||||
@@ -5,15 +6,29 @@ import { verifyJwt } from "../auth/jwt.js";
|
|||||||
|
|
||||||
const router = Router();
|
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 {
|
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" });
|
res.status(503).json({ code: "admin_disabled", message: "Admin API not configured" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const auth = req.headers.authorization;
|
const auth = req.headers.authorization;
|
||||||
const adminKey = req.headers["x-admin-key"];
|
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 ")) {
|
if (auth?.startsWith("Bearer ")) {
|
||||||
const token = auth.slice(7).trim();
|
const token = auth.slice(7).trim();
|
||||||
const payload = verifyJwt(token);
|
const payload = verifyJwt(token);
|
||||||
@@ -22,11 +37,11 @@ function adminAuth(req: Request, res: Response, next: NextFunction): void {
|
|||||||
if (!pubkey && typeof adminKey === "string") {
|
if (!pubkey && typeof adminKey === "string") {
|
||||||
pubkey = adminKey.trim();
|
pubkey = adminKey.trim();
|
||||||
}
|
}
|
||||||
if (!pubkey || !config.adminPubkeys.includes(pubkey)) {
|
if (pubkey && config.adminPubkeys.includes(pubkey)) {
|
||||||
res.status(403).json({ code: "forbidden", message: "Admin access required" });
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
next();
|
res.status(403).json({ code: "forbidden", message: "Admin access required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
router.use(adminAuth);
|
router.use(adminAuth);
|
||||||
@@ -115,4 +130,49 @@ router.delete("/sponsors/:id", async (req: Request, res: Response) => {
|
|||||||
res.status(200).json({ ok: true });
|
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;
|
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 &&
|
||||||
cached.nostr_first_seen_at <= ageCutoffSec + borderlineWindowSec;
|
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 {
|
return {
|
||||||
nostrFirstSeenAt: cached.nostr_first_seen_at,
|
nostrFirstSeenAt: cached.nostr_first_seen_at,
|
||||||
notesCount: cached.notes_count,
|
notesCount: cached.notes_count,
|
||||||
|
|||||||
Reference in New Issue
Block a user