Production-ready overhaul: backend fixes, claim flow polish, SEO, mobile, nginx

Backend:
- Fix activity score (followersCount check), NIP-98 URL proto, wallet balance guard
- Add payment_hash to confirm response, spentTodaySats to stats
- Add idx_claims_ip_hash; security headers, graceful shutdown, periodic nonce cleanup

Frontend:
- Remove 8 legacy components; polish wizard (rules summary, profile card, confetti, share)
- Stats budget bar uses spentTodaySats; ErrorBoundary with reload

SEO & production:
- Full meta/OG/Twitter, favicon, JSON-LD, robots.txt, sitemap.xml
- Mobile CSS fixes; nginx static dist + gzip + cache + security headers
- Vite manualChunks; aria-labels, dynamic page titles

Made-with: Cursor
This commit is contained in:
Michaël
2026-02-27 16:29:37 -03:00
parent f31bbb12ab
commit 5b516f02cb
32 changed files with 432 additions and 927 deletions

View File

@@ -46,5 +46,6 @@ CREATE TABLE IF NOT EXISTS nonces (
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_claims_ip_hash ON claims(ip_hash);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);

View File

@@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS deposits (
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_claims_ip_hash ON claims(ip_hash);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);

View File

@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS deposits (
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_claims_ip_hash ON claims(ip_hash);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);

View File

@@ -9,6 +9,8 @@ import authRoutes from "./routes/auth.js";
import claimRoutes from "./routes/claim.js";
import userRoutes from "./routes/user.js";
const NONCE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
async function main() {
const db = getDb();
await db.runMigrations();
@@ -17,6 +19,14 @@ async function main() {
const app = express();
if (config.trustProxy) app.set("trust proxy", 1);
app.use(express.json({ limit: "10kb" }));
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
next();
});
app.use(
cors({
origin: (origin, cb) => {
@@ -50,12 +60,34 @@ async function main() {
userRoutes
);
app.listen(config.port, () => {
const server = app.listen(config.port, () => {
console.log(`Faucet API listening on port ${config.port}`);
if (config.lnbitsBaseUrl && config.lnbitsAdminKey) {
startLnbitsDepositSync();
}
});
const nonceCleanupTimer = setInterval(() => {
db.deleteExpiredNonces().catch((err) =>
console.error("[nonce-cleanup] Failed:", err instanceof Error ? err.message : err)
);
}, NONCE_CLEANUP_INTERVAL_MS);
function shutdown(signal: string) {
console.log(`\n[${signal}] Shutting down gracefully…`);
clearInterval(nonceCleanupTimer);
server.close(() => {
console.log("[shutdown] HTTP server closed.");
process.exit(0);
});
setTimeout(() => {
console.error("[shutdown] Forceful exit after timeout.");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
}
main().catch((err) => {

View File

@@ -83,8 +83,8 @@ export async function nip98Auth(req: Request, res: Response, next: NextFunction)
}
// Reconstruct absolute URL (protocol + host + path + query)
const proto = req.headers["x-forwarded-proto"] ?? (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http";
const host = req.headers["x-forwarded-host"] ?? req.headers.host ?? "";
const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? ((req.socket as { encrypted?: boolean }).encrypted ? "https" : "http");
const host = (req.headers["x-forwarded-host"] as string | undefined) ?? req.headers.host ?? "";
const path = req.originalUrl ?? req.url;
const absoluteUrl = `${proto}://${host}${path}`;
if (u !== absoluteUrl) {

View File

@@ -140,6 +140,7 @@ router.post("/confirm", authOrNip98, async (req: Request, res: Response) => {
res.json({
success: true,
payout_sats: quote.payout_sats,
payment_hash: paymentHash,
next_eligible_at: cooldownEnd,
});
} catch (err) {

View File

@@ -24,11 +24,14 @@ router.get("/config", (_req: Request, res: Response) => {
router.get("/stats", async (_req: Request, res: Response) => {
try {
const db = getDb();
const [balance, totalPaid, totalClaims, claims24h, recent, recentDeposits] = await Promise.all([
const now = Math.floor(Date.now() / 1000);
const dayStart = now - (now % 86400);
const [balance, totalPaid, totalClaims, claims24h, spentToday, recent, recentDeposits] = await Promise.all([
getWalletBalanceSats().catch(() => 0),
db.getTotalPaidSats(),
db.getTotalClaimsCount(),
db.getClaimsCountSince(Math.floor(Date.now() / 1000) - 86400),
db.getClaimsCountSince(now - 86400),
db.getPaidSatsSince(dayStart),
db.getRecentPayouts(20),
db.getRecentDeposits(20),
]);
@@ -38,6 +41,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
totalClaims,
claimsLast24h: claims24h,
dailyBudgetSats: config.dailyBudgetSats,
spentTodaySats: spentToday,
recentPayouts: recent,
recentDeposits,
});

View File

@@ -54,7 +54,7 @@ export async function checkEligibility(pubkey: string, ipHash: string): Promise<
};
}
if (balanceSats < config.faucetMinSats) {
if (balanceSats < config.minWalletBalanceSats) {
return {
eligible: false,
denialCode: "insufficient_balance",

View File

@@ -101,7 +101,8 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
if (hasMetadata) score += 10;
if (notesInLookback >= config.minNotesCount) score += 20;
if (followingCount >= config.minFollowingCount) score += 10;
if (0 >= config.minFollowersCount) score += 10; // followers not fetched for MVP; treat as 0
const followersCount = 0; // followers not fetched for MVP
if (followersCount >= config.minFollowersCount) score += 10;
let lightning_address: string | null = null;
let name: string | null = null;