Add sponsors system with time slider, LNbits invoices, and UX improvements
- Sponsors table, LNbits createInvoice, webhook handler - Sponsor routes: create, homepage, list, my-ads, click, extend, check-payment - Admin routes for sponsor management - Frontend: SponsorForm, SponsorTimeSlider, SponsorCard, SponsorsSection - Sponsors page, My Ads page, homepage sponsor block - Header login dropdown with My Ads, Create Sponsor - Transactions integration for sponsor payments - View/click tracking - OG meta fetch for sponsor images - Sponsor modal spacing, invoice polling fallback - Remove Lightning address and Category fields from sponsor form Made-with: Cursor
This commit is contained in:
@@ -76,6 +76,14 @@ export const config = {
|
||||
depositLightningAddress: process.env.DEPOSIT_LIGHTNING_ADDRESS ?? "",
|
||||
depositLnurlp: process.env.DEPOSIT_LNURLP ?? "",
|
||||
cashuRedeemApiUrl: (process.env.CASHU_REDEEM_API_URL ?? "https://cashu-redeem.azzamo.net").replace(/\/$/, ""),
|
||||
|
||||
// Sponsors
|
||||
baseSponsorPricePerDay: envInt("BASE_SPONSOR_PRICE_PER_DAY", 200),
|
||||
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),
|
||||
/** 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(/\/$/, ""),
|
||||
};
|
||||
|
||||
export function usePostgres(): boolean {
|
||||
|
||||
@@ -2,7 +2,7 @@ import pg from "pg";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, UserRow } from "./types.js";
|
||||
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, SponsorRow, UserRow } from "./types.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -48,8 +48,8 @@ export function createPgDb(connectionString: string): Db {
|
||||
created_at: Number(r.created_at),
|
||||
expires_at: Number(r.expires_at),
|
||||
status: r.status,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async runMigrations() {
|
||||
@@ -84,6 +84,40 @@ export function createPgDb(connectionString: string): Db {
|
||||
"UPDATE deposits SET amount_sats = amount_sats / 1000 WHERE source = 'lightning' AND lnbits_payment_hash IS NOT NULL AND amount_sats >= 1000"
|
||||
);
|
||||
} catch (_) {}
|
||||
try {
|
||||
await pool.query(
|
||||
`CREATE TABLE IF NOT EXISTS sponsors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
npub TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
link_url TEXT NOT NULL,
|
||||
category TEXT,
|
||||
lightning_address TEXT,
|
||||
invoice_id TEXT,
|
||||
payment_hash TEXT,
|
||||
price_sats INTEGER NOT NULL,
|
||||
duration_days INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending_payment','pending_review','active','expired','removed')),
|
||||
created_at BIGINT NOT NULL,
|
||||
activated_at BIGINT,
|
||||
expires_at BIGINT,
|
||||
views INTEGER DEFAULT 0,
|
||||
clicks INTEGER DEFAULT 0
|
||||
)`
|
||||
);
|
||||
await pool.query("CREATE INDEX IF NOT EXISTS idx_sponsors_status ON sponsors(status)");
|
||||
await pool.query("CREATE INDEX IF NOT EXISTS idx_sponsors_npub ON sponsors(npub)");
|
||||
await pool.query("CREATE INDEX IF NOT EXISTS idx_sponsors_expires_at ON sponsors(expires_at)");
|
||||
await pool.query("CREATE INDEX IF NOT EXISTS idx_sponsors_payment_hash ON sponsors(payment_hash)");
|
||||
} catch (_) {}
|
||||
try {
|
||||
await pool.query("ALTER TABLE sponsors ADD COLUMN extends_sponsor_id INTEGER");
|
||||
} catch (_) {}
|
||||
try {
|
||||
await pool.query("ALTER TABLE sponsors ADD COLUMN payment_request TEXT");
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async getUser(pubkey: string): Promise<UserRow | null> {
|
||||
@@ -343,5 +377,212 @@ export function createPgDb(connectionString: string): Db {
|
||||
created_at: Number(r.created_at),
|
||||
}));
|
||||
},
|
||||
|
||||
async insertSponsor(row: Omit<SponsorRow, "id" | "views" | "clicks"> & { extends_sponsor_id?: number | null }): Promise<number> {
|
||||
const res = await pool.query(
|
||||
`INSERT INTO sponsors (npub, title, description, image_url, link_url, category, lightning_address, invoice_id, payment_hash, payment_request, price_sats, duration_days, status, created_at, activated_at, expires_at, extends_sponsor_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id`,
|
||||
[
|
||||
row.npub,
|
||||
row.title,
|
||||
row.description,
|
||||
row.image_url ?? null,
|
||||
row.link_url,
|
||||
row.category ?? null,
|
||||
row.lightning_address ?? null,
|
||||
row.invoice_id ?? null,
|
||||
row.payment_hash ?? null,
|
||||
row.payment_request ?? null,
|
||||
row.price_sats,
|
||||
row.duration_days,
|
||||
row.status,
|
||||
row.created_at,
|
||||
row.activated_at ?? null,
|
||||
row.expires_at ?? null,
|
||||
row.extends_sponsor_id ?? null,
|
||||
]
|
||||
);
|
||||
return res.rows[0].id;
|
||||
},
|
||||
|
||||
async getSponsorById(id: number): Promise<SponsorRow | null> {
|
||||
const res = await pool.query("SELECT * FROM sponsors WHERE id = $1", [id]);
|
||||
if (!res.rows.length) return null;
|
||||
return toSponsorRow(res.rows[0]);
|
||||
},
|
||||
|
||||
async getSponsorByPaymentHash(paymentHash: string): Promise<SponsorRow | null> {
|
||||
const res = await pool.query("SELECT * FROM sponsors WHERE payment_hash = $1", [paymentHash]);
|
||||
if (!res.rows.length) return null;
|
||||
return toSponsorRow(res.rows[0]);
|
||||
},
|
||||
|
||||
async updateSponsorOnPayment(paymentHash: string, activatedAt: number, expiresAt: number): Promise<boolean> {
|
||||
const res = await pool.query(
|
||||
"UPDATE sponsors SET status = 'active', activated_at = $1, expires_at = $2 WHERE payment_hash = $3 AND status = 'pending_payment' AND extends_sponsor_id IS NULL RETURNING id",
|
||||
[activatedAt, expiresAt, paymentHash]
|
||||
);
|
||||
return res.rowCount !== null && res.rowCount > 0;
|
||||
},
|
||||
|
||||
async updateSponsorExpiresAtAdd(id: number, additionalSeconds: number): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await pool.query(
|
||||
`UPDATE sponsors SET expires_at = CASE
|
||||
WHEN expires_at IS NULL OR expires_at < $1 THEN $1 + $2
|
||||
ELSE expires_at + $2
|
||||
END WHERE id = $3`,
|
||||
[now, additionalSeconds, id]
|
||||
);
|
||||
},
|
||||
|
||||
async getActiveSponsorsForHomepage(limit: number): Promise<SponsorRow[]> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const res = await pool.query(
|
||||
`SELECT * FROM sponsors WHERE status = 'active' AND expires_at > $1 ORDER BY
|
||||
(expires_at - $2) * 2 + COALESCE(activated_at, 0)::float / 86400 + random() DESC LIMIT $3`,
|
||||
[now, now, limit]
|
||||
);
|
||||
return res.rows.map(toSponsorRow);
|
||||
},
|
||||
|
||||
async getSponsorsForPage(): Promise<SponsorRow[]> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const res = await pool.query(
|
||||
"SELECT * FROM sponsors WHERE status = 'active' AND expires_at > $1 ORDER BY activated_at DESC",
|
||||
[now]
|
||||
);
|
||||
return res.rows.map(toSponsorRow);
|
||||
},
|
||||
|
||||
async getSponsorsByNpub(npub: string): Promise<SponsorRow[]> {
|
||||
const res = await pool.query("SELECT * FROM sponsors WHERE npub = $1 ORDER BY created_at DESC", [npub]);
|
||||
return res.rows.map(toSponsorRow);
|
||||
},
|
||||
|
||||
async getAllSponsors(opts: { status?: string; limit: number }): Promise<SponsorRow[]> {
|
||||
const { status, limit } = opts;
|
||||
let query = "SELECT * FROM sponsors WHERE 1=1";
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
if (status) {
|
||||
query += ` AND status = $${i++}`;
|
||||
params.push(status);
|
||||
}
|
||||
query += ` ORDER BY created_at DESC LIMIT $${i}`;
|
||||
params.push(limit);
|
||||
const res = await pool.query(query, params);
|
||||
return res.rows.map(toSponsorRow);
|
||||
},
|
||||
|
||||
async incrementSponsorViews(id: number): Promise<void> {
|
||||
await pool.query("UPDATE sponsors SET views = views + 1 WHERE id = $1", [id]);
|
||||
},
|
||||
|
||||
async incrementSponsorClicks(id: number): Promise<void> {
|
||||
await pool.query("UPDATE sponsors SET clicks = clicks + 1 WHERE id = $1", [id]);
|
||||
},
|
||||
|
||||
async getRecentSponsorPayments(limit: number): Promise<{ created_at: number; amount_sats: number; title: string }[]> {
|
||||
const res = await pool.query(
|
||||
"SELECT activated_at as created_at, price_sats as amount_sats, title FROM sponsors WHERE status = 'active' AND activated_at IS NOT NULL ORDER BY activated_at DESC LIMIT $1",
|
||||
[limit]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
created_at: Number(r.created_at),
|
||||
amount_sats: Number(r.amount_sats),
|
||||
title: r.title,
|
||||
}));
|
||||
},
|
||||
|
||||
async countActiveSponsorsByNpub(npub: string): Promise<number> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const res = await pool.query(
|
||||
"SELECT COUNT(*) as c FROM sponsors WHERE npub = $1 AND status = 'active' AND expires_at > $2",
|
||||
[npub, now]
|
||||
);
|
||||
return parseInt(res.rows[0]?.c ?? "0", 10);
|
||||
},
|
||||
|
||||
async updateSponsorStatus(id: number, status: SponsorRow["status"]): Promise<void> {
|
||||
await pool.query("UPDATE sponsors SET status = $1 WHERE id = $2", [status, id]);
|
||||
},
|
||||
|
||||
async updateSponsorExpiresAt(id: number, expiresAt: number): Promise<void> {
|
||||
await pool.query("UPDATE sponsors SET expires_at = $1 WHERE id = $2", [expiresAt, id]);
|
||||
},
|
||||
|
||||
async updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise<void> {
|
||||
await pool.query("UPDATE sponsors SET activated_at = $1, expires_at = $2 WHERE id = $3", [activatedAt, expiresAt, id]);
|
||||
},
|
||||
|
||||
async updateSponsor(
|
||||
id: number,
|
||||
data: Partial<Pick<SponsorRow, "title" | "description" | "image_url" | "link_url" | "category" | "lightning_address">>
|
||||
): Promise<void> {
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (data.title !== undefined) {
|
||||
updates.push(`title = $${i++}`);
|
||||
values.push(data.title);
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
updates.push(`description = $${i++}`);
|
||||
values.push(data.description);
|
||||
}
|
||||
if (data.image_url !== undefined) {
|
||||
updates.push(`image_url = $${i++}`);
|
||||
values.push(data.image_url);
|
||||
}
|
||||
if (data.link_url !== undefined) {
|
||||
updates.push(`link_url = $${i++}`);
|
||||
values.push(data.link_url);
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
updates.push(`category = $${i++}`);
|
||||
values.push(data.category);
|
||||
}
|
||||
if (data.lightning_address !== undefined) {
|
||||
updates.push(`lightning_address = $${i++}`);
|
||||
values.push(data.lightning_address);
|
||||
}
|
||||
if (updates.length === 0) return;
|
||||
values.push(id);
|
||||
await pool.query(`UPDATE sponsors SET ${updates.join(", ")} WHERE id = $${i}`, values);
|
||||
},
|
||||
|
||||
async updateSponsorPayment(id: number, paymentHash: string, paymentRequest: string): Promise<void> {
|
||||
await pool.query("UPDATE sponsors SET payment_hash = $1, payment_request = $2 WHERE id = $3", [
|
||||
paymentHash,
|
||||
paymentRequest,
|
||||
id,
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toSponsorRow(r: pg.QueryResultRow): SponsorRow {
|
||||
return {
|
||||
id: r.id,
|
||||
npub: r.npub,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
image_url: r.image_url ?? null,
|
||||
link_url: r.link_url,
|
||||
category: r.category ?? null,
|
||||
lightning_address: r.lightning_address ?? null,
|
||||
invoice_id: r.invoice_id ?? null,
|
||||
payment_hash: r.payment_hash ?? null,
|
||||
payment_request: r.payment_request ?? null,
|
||||
price_sats: Number(r.price_sats),
|
||||
duration_days: Number(r.duration_days),
|
||||
status: r.status,
|
||||
created_at: Number(r.created_at),
|
||||
activated_at: r.activated_at != null ? Number(r.activated_at) : null,
|
||||
expires_at: r.expires_at != null ? Number(r.expires_at) : null,
|
||||
views: Number(r.views ?? 0),
|
||||
clicks: Number(r.clicks ?? 0),
|
||||
extends_sponsor_id: r.extends_sponsor_id != null ? Number(r.extends_sponsor_id) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,3 +68,29 @@ 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sponsors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
npub TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
link_url TEXT NOT NULL,
|
||||
category TEXT,
|
||||
lightning_address TEXT,
|
||||
invoice_id TEXT,
|
||||
payment_hash TEXT,
|
||||
price_sats INTEGER NOT NULL,
|
||||
duration_days INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending_payment','pending_review','active','expired','removed')),
|
||||
created_at BIGINT NOT NULL,
|
||||
activated_at BIGINT,
|
||||
expires_at BIGINT,
|
||||
views INTEGER DEFAULT 0,
|
||||
clicks INTEGER DEFAULT 0,
|
||||
extends_sponsor_id INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sponsors_status ON sponsors(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sponsors_npub ON sponsors(npub);
|
||||
CREATE INDEX IF NOT EXISTS idx_sponsors_expires_at ON sponsors(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sponsors_payment_hash ON sponsors(payment_hash);
|
||||
|
||||
@@ -69,3 +69,29 @@ 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sponsors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
npub TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
link_url TEXT NOT NULL,
|
||||
category TEXT,
|
||||
lightning_address TEXT,
|
||||
invoice_id TEXT,
|
||||
payment_hash TEXT,
|
||||
price_sats INTEGER NOT NULL,
|
||||
duration_days INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending_payment','pending_review','active','expired','removed')),
|
||||
created_at INTEGER NOT NULL,
|
||||
activated_at INTEGER,
|
||||
expires_at INTEGER,
|
||||
views INTEGER DEFAULT 0,
|
||||
clicks INTEGER DEFAULT 0,
|
||||
extends_sponsor_id INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sponsors_status ON sponsors(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sponsors_npub ON sponsors(npub);
|
||||
CREATE INDEX IF NOT EXISTS idx_sponsors_expires_at ON sponsors(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sponsors_payment_hash ON sponsors(payment_hash);
|
||||
|
||||
@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, UserRow } from "./types.js";
|
||||
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, SponsorRow, UserRow } from "./types.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -36,6 +36,40 @@ export function createSqliteDb(path: string): Db {
|
||||
"UPDATE deposits SET amount_sats = amount_sats / 1000 WHERE source = 'lightning' AND lnbits_payment_hash IS NOT NULL AND amount_sats >= 1000"
|
||||
);
|
||||
} catch (_) {}
|
||||
try {
|
||||
db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS sponsors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
npub TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
link_url TEXT NOT NULL,
|
||||
category TEXT,
|
||||
lightning_address TEXT,
|
||||
invoice_id TEXT,
|
||||
payment_hash TEXT,
|
||||
price_sats INTEGER NOT NULL,
|
||||
duration_days INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending_payment','pending_review','active','expired','removed')),
|
||||
created_at INTEGER NOT NULL,
|
||||
activated_at INTEGER,
|
||||
expires_at INTEGER,
|
||||
views INTEGER DEFAULT 0,
|
||||
clicks INTEGER DEFAULT 0
|
||||
)`
|
||||
);
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_sponsors_status ON sponsors(status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_sponsors_npub ON sponsors(npub)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_sponsors_expires_at ON sponsors(expires_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_sponsors_payment_hash ON sponsors(payment_hash)");
|
||||
} catch (_) {}
|
||||
try {
|
||||
db.exec("ALTER TABLE sponsors ADD COLUMN extends_sponsor_id INTEGER");
|
||||
} catch (_) {}
|
||||
try {
|
||||
db.exec("ALTER TABLE sponsors ADD COLUMN payment_request TEXT");
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async getUser(pubkey: string): Promise<UserRow | null> {
|
||||
@@ -283,5 +317,184 @@ export function createSqliteDb(path: string): Db {
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
},
|
||||
|
||||
async insertSponsor(row: Omit<SponsorRow, "id" | "views" | "clicks"> & { extends_sponsor_id?: number | null }): Promise<number> {
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO sponsors (npub, title, description, image_url, link_url, category, lightning_address, invoice_id, payment_hash, payment_request, price_sats, duration_days, status, created_at, activated_at, expires_at, extends_sponsor_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
row.npub,
|
||||
row.title,
|
||||
row.description,
|
||||
row.image_url ?? null,
|
||||
row.link_url,
|
||||
row.category ?? null,
|
||||
row.lightning_address ?? null,
|
||||
row.invoice_id ?? null,
|
||||
row.payment_hash ?? null,
|
||||
row.payment_request ?? null,
|
||||
row.price_sats,
|
||||
row.duration_days,
|
||||
row.status,
|
||||
row.created_at,
|
||||
row.activated_at ?? null,
|
||||
row.expires_at ?? null,
|
||||
row.extends_sponsor_id ?? null
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
},
|
||||
|
||||
async getSponsorById(id: number): Promise<SponsorRow | null> {
|
||||
const row = db.prepare("SELECT * FROM sponsors WHERE id = ?").get(id) as SponsorRow | undefined;
|
||||
if (!row) return null;
|
||||
return { ...row, views: row.views ?? 0, clicks: row.clicks ?? 0 };
|
||||
},
|
||||
|
||||
async getSponsorByPaymentHash(paymentHash: string): Promise<SponsorRow | null> {
|
||||
const row = db.prepare("SELECT * FROM sponsors WHERE payment_hash = ?").get(paymentHash) as SponsorRow | undefined;
|
||||
if (!row) return null;
|
||||
return { ...row, views: row.views ?? 0, clicks: row.clicks ?? 0 };
|
||||
},
|
||||
|
||||
async updateSponsorOnPayment(paymentHash: string, activatedAt: number, expiresAt: number): Promise<boolean> {
|
||||
const result = db
|
||||
.prepare(
|
||||
"UPDATE sponsors SET status = 'active', activated_at = ?, expires_at = ? WHERE payment_hash = ? AND status = 'pending_payment' AND extends_sponsor_id IS NULL"
|
||||
)
|
||||
.run(activatedAt, expiresAt, paymentHash);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
async updateSponsorExpiresAtAdd(id: number, additionalSeconds: number): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.prepare(
|
||||
`UPDATE sponsors SET expires_at = CASE
|
||||
WHEN expires_at IS NULL OR expires_at < ? THEN ? + ?
|
||||
ELSE expires_at + ?
|
||||
END WHERE id = ?`
|
||||
).run(now, now, additionalSeconds, additionalSeconds, id);
|
||||
},
|
||||
|
||||
async getActiveSponsorsForHomepage(limit: number): Promise<SponsorRow[]> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM sponsors WHERE status = 'active' AND expires_at > ? ORDER BY
|
||||
(expires_at - ?) * 2 + COALESCE(activated_at, 0) / 86400 DESC, random() LIMIT ?`
|
||||
)
|
||||
.all(now, now, limit) as SponsorRow[];
|
||||
return rows.map((r) => ({ ...r, views: r.views ?? 0, clicks: r.clicks ?? 0, extends_sponsor_id: r.extends_sponsor_id ?? null }));
|
||||
},
|
||||
|
||||
async getSponsorsForPage(): Promise<SponsorRow[]> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const rows = db
|
||||
.prepare("SELECT * FROM sponsors WHERE status = 'active' AND expires_at > ? ORDER BY activated_at DESC")
|
||||
.all(now) as SponsorRow[];
|
||||
return rows.map((r) => ({ ...r, views: r.views ?? 0, clicks: r.clicks ?? 0, extends_sponsor_id: r.extends_sponsor_id ?? null }));
|
||||
},
|
||||
|
||||
async getSponsorsByNpub(npub: string): Promise<SponsorRow[]> {
|
||||
const rows = db.prepare("SELECT * FROM sponsors WHERE npub = ? ORDER BY created_at DESC").all(npub) as SponsorRow[];
|
||||
return rows.map((r) => ({ ...r, views: r.views ?? 0, clicks: r.clicks ?? 0, extends_sponsor_id: r.extends_sponsor_id ?? null }));
|
||||
},
|
||||
|
||||
async getAllSponsors(opts: { status?: string; limit: number }): Promise<SponsorRow[]> {
|
||||
const { status, limit } = opts;
|
||||
let query = "SELECT * FROM sponsors WHERE 1=1";
|
||||
const params: unknown[] = [];
|
||||
if (status) {
|
||||
query += " AND status = ?";
|
||||
params.push(status);
|
||||
}
|
||||
query += " ORDER BY created_at DESC LIMIT ?";
|
||||
params.push(limit);
|
||||
const rows = db.prepare(query).all(...params) as SponsorRow[];
|
||||
return rows.map((r) => ({ ...r, views: r.views ?? 0, clicks: r.clicks ?? 0, extends_sponsor_id: r.extends_sponsor_id ?? null }));
|
||||
},
|
||||
|
||||
async incrementSponsorViews(id: number): Promise<void> {
|
||||
db.prepare("UPDATE sponsors SET views = views + 1 WHERE id = ?").run(id);
|
||||
},
|
||||
|
||||
async incrementSponsorClicks(id: number): Promise<void> {
|
||||
db.prepare("UPDATE sponsors SET clicks = clicks + 1 WHERE id = ?").run(id);
|
||||
},
|
||||
|
||||
async getRecentSponsorPayments(limit: number): Promise<{ created_at: number; amount_sats: number; title: string }[]> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT activated_at as created_at, price_sats as amount_sats, title FROM sponsors WHERE status = 'active' AND activated_at IS NOT NULL ORDER BY activated_at DESC LIMIT ?"
|
||||
)
|
||||
.all(limit) as { created_at: number; amount_sats: number; title: string }[];
|
||||
return rows;
|
||||
},
|
||||
|
||||
async countActiveSponsorsByNpub(npub: string): Promise<number> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as c FROM sponsors WHERE npub = ? AND status = 'active' AND expires_at > ?"
|
||||
)
|
||||
.get(npub, now) as { c: number };
|
||||
return row?.c ?? 0;
|
||||
},
|
||||
|
||||
async updateSponsorStatus(id: number, status: SponsorRow["status"]): Promise<void> {
|
||||
db.prepare("UPDATE sponsors SET status = ? WHERE id = ?").run(status, id);
|
||||
},
|
||||
|
||||
async updateSponsorExpiresAt(id: number, expiresAt: number): Promise<void> {
|
||||
db.prepare("UPDATE sponsors SET expires_at = ? WHERE id = ?").run(expiresAt, id);
|
||||
},
|
||||
|
||||
async updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise<void> {
|
||||
db.prepare("UPDATE sponsors SET activated_at = ?, expires_at = ? WHERE id = ?").run(activatedAt, expiresAt, id);
|
||||
},
|
||||
|
||||
async updateSponsor(
|
||||
id: number,
|
||||
data: Partial<Pick<SponsorRow, "title" | "description" | "image_url" | "link_url" | "category" | "lightning_address">>
|
||||
): Promise<void> {
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (data.title !== undefined) {
|
||||
updates.push("title = ?");
|
||||
values.push(data.title);
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
updates.push("description = ?");
|
||||
values.push(data.description);
|
||||
}
|
||||
if (data.image_url !== undefined) {
|
||||
updates.push("image_url = ?");
|
||||
values.push(data.image_url);
|
||||
}
|
||||
if (data.link_url !== undefined) {
|
||||
updates.push("link_url = ?");
|
||||
values.push(data.link_url);
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
updates.push("category = ?");
|
||||
values.push(data.category);
|
||||
}
|
||||
if (data.lightning_address !== undefined) {
|
||||
updates.push("lightning_address = ?");
|
||||
values.push(data.lightning_address);
|
||||
}
|
||||
if (updates.length === 0) return;
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE sponsors SET ${updates.join(", ")} WHERE id = ?`).run(...values);
|
||||
},
|
||||
|
||||
async updateSponsorPayment(id: number, paymentHash: string, paymentRequest: string): Promise<void> {
|
||||
db.prepare("UPDATE sponsors SET payment_hash = ?, payment_request = ? WHERE id = ?").run(
|
||||
paymentHash,
|
||||
paymentRequest,
|
||||
id
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +42,31 @@ export interface IpLimitRow {
|
||||
|
||||
export type DepositSource = "lightning" | "cashu";
|
||||
|
||||
export type SponsorStatus = "pending_payment" | "pending_review" | "active" | "expired" | "removed";
|
||||
|
||||
export interface SponsorRow {
|
||||
id: number;
|
||||
npub: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image_url: string | null;
|
||||
link_url: string;
|
||||
category: string | null;
|
||||
lightning_address: string | null;
|
||||
invoice_id: string | null;
|
||||
payment_hash: string | null;
|
||||
payment_request: string | null;
|
||||
price_sats: number;
|
||||
duration_days: number;
|
||||
status: SponsorStatus;
|
||||
created_at: number;
|
||||
activated_at: number | null;
|
||||
expires_at: number | null;
|
||||
views: number;
|
||||
clicks: number;
|
||||
extends_sponsor_id: number | null;
|
||||
}
|
||||
|
||||
export interface DepositRow {
|
||||
id: number;
|
||||
created_at: number;
|
||||
@@ -98,4 +123,23 @@ export interface Db {
|
||||
hasDepositWithPaymentHash(paymentHash: string): Promise<boolean>;
|
||||
updateDepositCreatedAtIfMissing(paymentHash: string, createdAt: number): Promise<boolean>;
|
||||
getRecentDeposits(limit: number): Promise<{ amount_sats: number; source: DepositSource; created_at: number }[]>;
|
||||
|
||||
insertSponsor(row: Omit<SponsorRow, "id" | "views" | "clicks" | "extends_sponsor_id"> & { extends_sponsor_id?: number | null }): Promise<number>;
|
||||
getSponsorById(id: number): Promise<SponsorRow | null>;
|
||||
getSponsorByPaymentHash(paymentHash: string): Promise<SponsorRow | null>;
|
||||
updateSponsorOnPayment(paymentHash: string, activatedAt: number, expiresAt: number): Promise<boolean>;
|
||||
updateSponsorExpiresAtAdd(id: number, additionalSeconds: number): Promise<void>;
|
||||
getActiveSponsorsForHomepage(limit: number): Promise<SponsorRow[]>;
|
||||
getSponsorsForPage(): Promise<SponsorRow[]>;
|
||||
getSponsorsByNpub(npub: string): Promise<SponsorRow[]>;
|
||||
getAllSponsors(opts: { status?: string; limit: number }): Promise<SponsorRow[]>;
|
||||
incrementSponsorViews(id: number): Promise<void>;
|
||||
incrementSponsorClicks(id: number): Promise<void>;
|
||||
getRecentSponsorPayments(limit: number): Promise<{ created_at: number; amount_sats: number; title: string }[]>;
|
||||
countActiveSponsorsByNpub(npub: string): Promise<number>;
|
||||
updateSponsorStatus(id: number, status: SponsorStatus): Promise<void>;
|
||||
updateSponsorExpiresAt(id: number, expiresAt: number): Promise<void>;
|
||||
updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise<void>;
|
||||
updateSponsor(id: number, data: Partial<Pick<SponsorRow, "title" | "description" | "image_url" | "link_url" | "category" | "lightning_address">>): Promise<void>;
|
||||
updateSponsorPayment(id: number, paymentHash: string, paymentRequest: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import publicRoutes from "./routes/public.js";
|
||||
import authRoutes from "./routes/auth.js";
|
||||
import claimRoutes from "./routes/claim.js";
|
||||
import userRoutes from "./routes/user.js";
|
||||
import sponsorWebhookRoutes from "./routes/sponsorWebhook.js";
|
||||
import sponsorRoutes from "./routes/sponsor.js";
|
||||
import adminRoutes from "./routes/admin.js";
|
||||
|
||||
const NONCE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -62,6 +65,9 @@ async function main() {
|
||||
);
|
||||
|
||||
app.use("/", publicRoutes);
|
||||
app.use("/", sponsorWebhookRoutes);
|
||||
app.use("/sponsor", sponsorRoutes);
|
||||
app.use("/admin", adminRoutes);
|
||||
app.use("/auth", authRoutes);
|
||||
app.use(
|
||||
"/claim",
|
||||
|
||||
118
backend/src/routes/admin.ts
Normal file
118
backend/src/routes/admin.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import { config } from "../config.js";
|
||||
import { getDb } from "../db/index.js";
|
||||
import { verifyJwt } from "../auth/jwt.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function adminAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (config.adminPubkeys.length === 0) {
|
||||
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;
|
||||
|
||||
if (auth?.startsWith("Bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
const payload = verifyJwt(token);
|
||||
if (payload?.pubkey) pubkey = payload.pubkey;
|
||||
}
|
||||
if (!pubkey && typeof adminKey === "string") {
|
||||
pubkey = adminKey.trim();
|
||||
}
|
||||
if (!pubkey || !config.adminPubkeys.includes(pubkey)) {
|
||||
res.status(403).json({ code: "forbidden", message: "Admin access required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.use(adminAuth);
|
||||
|
||||
router.get("/sponsors", async (req: Request, res: Response) => {
|
||||
const db = getDb();
|
||||
const status = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||
const limit = Math.min(parseInt(String(req.query.limit || 100), 10) || 100, 500);
|
||||
|
||||
const sponsors = await db.getAllSponsors({ status, limit });
|
||||
|
||||
res.json(sponsors);
|
||||
});
|
||||
|
||||
router.patch("/sponsors/:id", async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id < 1) {
|
||||
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||
return;
|
||||
}
|
||||
const action = typeof (req.body as { action?: string }).action === "string"
|
||||
? (req.body as { action: string }).action
|
||||
: "";
|
||||
const durationDays = typeof (req.body as { duration_days?: number }).duration_days === "number"
|
||||
? (req.body as { duration_days: number }).duration_days
|
||||
: undefined;
|
||||
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorById(id);
|
||||
if (!sponsor) {
|
||||
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "approve":
|
||||
await db.updateSponsorStatus(id, "active");
|
||||
if (!sponsor.activated_at || !sponsor.expires_at) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresAt = now + sponsor.duration_days * 86400;
|
||||
await db.updateSponsorActivation(id, now, expiresAt);
|
||||
}
|
||||
break;
|
||||
case "reject":
|
||||
await db.updateSponsorStatus(id, "removed");
|
||||
break;
|
||||
case "pause":
|
||||
await db.updateSponsorStatus(id, "pending_review");
|
||||
break;
|
||||
case "remove":
|
||||
await db.updateSponsorStatus(id, "removed");
|
||||
break;
|
||||
case "extend":
|
||||
if (durationDays && durationDays >= 1 && durationDays <= 365) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const current = sponsor.expires_at ?? now;
|
||||
const newExpires = Math.max(current, now) + durationDays * 86400;
|
||||
await db.updateSponsorExpiresAt(id, newExpires);
|
||||
} else {
|
||||
res.status(400).json({ code: "invalid_duration", message: "duration_days 1-365 required" });
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
res.status(400).json({ code: "invalid_action", message: "action must be approve, reject, pause, remove, or extend" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await db.getSponsorById(id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete("/sponsors/:id", async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id < 1) {
|
||||
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||
return;
|
||||
}
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorById(id);
|
||||
if (!sponsor) {
|
||||
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||
return;
|
||||
}
|
||||
await db.updateSponsorStatus(id, "removed");
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -26,7 +26,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
|
||||
const db = getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const dayStart = now - (now % 86400);
|
||||
const [balance, totalPaid, totalClaims, claims24h, spentToday, recent, recentDeposits] = await Promise.all([
|
||||
const [balance, totalPaid, totalClaims, claims24h, spentToday, recent, recentDeposits, recentSponsorPayments] = await Promise.all([
|
||||
getWalletBalanceSats().catch(() => 0),
|
||||
db.getTotalPaidSats(),
|
||||
db.getTotalClaimsCount(),
|
||||
@@ -34,6 +34,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
|
||||
db.getPaidSatsSince(dayStart),
|
||||
db.getRecentPayouts(20),
|
||||
db.getRecentDeposits(20),
|
||||
db.getRecentSponsorPayments(20),
|
||||
]);
|
||||
res.json({
|
||||
balanceSats: balance,
|
||||
@@ -44,6 +45,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
|
||||
spentTodaySats: spentToday,
|
||||
recentPayouts: recent,
|
||||
recentDeposits,
|
||||
recentSponsorPayments,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({
|
||||
|
||||
454
backend/src/routes/sponsor.ts
Normal file
454
backend/src/routes/sponsor.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { config } from "../config.js";
|
||||
import { getDb } from "../db/index.js";
|
||||
import { createInvoice, checkPaymentStatus } from "../services/lnbits.js";
|
||||
import { fetchOgMeta } from "../services/fetchOgMeta.js";
|
||||
import { authOrNip98 } from "../middleware/auth.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const SNAP_DAYS = [1, 3, 7, 14, 30, 60, 90, 180, 365];
|
||||
|
||||
function snapDays(days: number): number {
|
||||
let best = SNAP_DAYS[0];
|
||||
for (const d of SNAP_DAYS) {
|
||||
if (Math.abs(d - days) < Math.abs(best - days)) best = d;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function calculatePrice(days: number): number {
|
||||
const base = config.baseSponsorPricePerDay;
|
||||
let price = base * days;
|
||||
if (days >= 180) price *= 0.7;
|
||||
else if (days >= 90) price *= 0.8;
|
||||
else if (days >= 30) price *= 0.9;
|
||||
return Math.round(price);
|
||||
}
|
||||
|
||||
function requireAuth(req: Request): string | null {
|
||||
if (!req.nostr?.pubkey) return null;
|
||||
return req.nostr.pubkey;
|
||||
}
|
||||
|
||||
interface CreateSponsorBody {
|
||||
title?: string;
|
||||
description?: string;
|
||||
link_url?: string;
|
||||
image_url?: string;
|
||||
category?: string;
|
||||
lightning_address?: string;
|
||||
duration_days?: number;
|
||||
}
|
||||
|
||||
router.post("/create", authOrNip98, async (req: Request, res: Response) => {
|
||||
const pubkey = requireAuth(req);
|
||||
if (!pubkey) {
|
||||
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (req.body || {}) as CreateSponsorBody;
|
||||
const title = typeof body.title === "string" ? body.title.trim() : "";
|
||||
const description = typeof body.description === "string" ? body.description.trim() : "";
|
||||
const linkUrl = typeof body.link_url === "string" ? body.link_url.trim() : "";
|
||||
const imageUrl = typeof body.image_url === "string" ? body.image_url.trim() || null : null;
|
||||
const category = typeof body.category === "string" ? body.category.trim() || null : null;
|
||||
const lightningAddress = typeof body.lightning_address === "string" ? body.lightning_address.trim() || null : null;
|
||||
let durationDays = typeof body.duration_days === "number" ? body.duration_days : 0;
|
||||
|
||||
if (!title || title.length > 100) {
|
||||
res.status(400).json({ code: "invalid_title", message: "Title is required (max 100 chars)" });
|
||||
return;
|
||||
}
|
||||
if (!description || description.length > 500) {
|
||||
res.status(400).json({ code: "invalid_description", message: "Description is required (max 500 chars)" });
|
||||
return;
|
||||
}
|
||||
if (!linkUrl || !/^https?:\/\/.+/.test(linkUrl)) {
|
||||
res.status(400).json({ code: "invalid_link_url", message: "Valid link_url (https://...) is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
durationDays = snapDays(durationDays);
|
||||
if (durationDays < 1 || durationDays > 365) {
|
||||
res.status(400).json({ code: "invalid_duration", message: "Duration must be 1-365 days" });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const activeCount = await db.countActiveSponsorsByNpub(pubkey);
|
||||
if (activeCount >= config.sponsorMaxActivePerUser) {
|
||||
res.status(403).json({
|
||||
code: "max_sponsors",
|
||||
message: `Maximum ${config.sponsorMaxActivePerUser} active sponsors per user`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const priceSats = calculatePrice(durationDays);
|
||||
|
||||
let finalImageUrl = imageUrl;
|
||||
if (!finalImageUrl && linkUrl) {
|
||||
try {
|
||||
const og = await fetchOgMeta(linkUrl);
|
||||
if (og.image) finalImageUrl = og.image;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const webhookUrl = config.publicApiUrl
|
||||
? `${config.publicApiUrl}/sponsor/webhook`
|
||||
: "";
|
||||
if (!webhookUrl) {
|
||||
console.warn("[sponsor] PUBLIC_API_URL not set; webhook will not be called");
|
||||
}
|
||||
|
||||
let paymentHash: string;
|
||||
let paymentRequest: string;
|
||||
try {
|
||||
const inv = await createInvoice(
|
||||
priceSats,
|
||||
`Sponsor: ${title}`,
|
||||
webhookUrl
|
||||
);
|
||||
paymentHash = inv.payment_hash;
|
||||
paymentRequest = inv.payment_request;
|
||||
} catch (e) {
|
||||
console.error("[sponsor] createInvoice failed:", e);
|
||||
res.status(502).json({ code: "invoice_failed", message: "Failed to create invoice" });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const id = await db.insertSponsor({
|
||||
npub: pubkey,
|
||||
title,
|
||||
description,
|
||||
image_url: finalImageUrl,
|
||||
link_url: linkUrl,
|
||||
category,
|
||||
lightning_address: lightningAddress,
|
||||
invoice_id: null,
|
||||
payment_hash: paymentHash,
|
||||
payment_request: paymentRequest,
|
||||
price_sats: priceSats,
|
||||
duration_days: durationDays,
|
||||
status: "pending_payment",
|
||||
created_at: now,
|
||||
activated_at: null,
|
||||
expires_at: null,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id,
|
||||
payment_hash: paymentHash,
|
||||
payment_request: paymentRequest,
|
||||
price_sats: priceSats,
|
||||
duration_days: durationDays,
|
||||
status: "pending_payment",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/check-payment/:payment_hash", async (req: Request, res: Response) => {
|
||||
const paymentHash = (req.params.payment_hash || "").trim();
|
||||
if (!paymentHash) {
|
||||
res.status(400).json({ paid: false, error: "Missing payment_hash" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { paid } = await checkPaymentStatus(paymentHash);
|
||||
if (!paid) {
|
||||
res.json({ paid: false });
|
||||
return;
|
||||
}
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorByPaymentHash(paymentHash);
|
||||
if (!sponsor || sponsor.status !== "pending_payment") {
|
||||
res.json({ paid: true });
|
||||
return;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (sponsor.extends_sponsor_id) {
|
||||
const additionalSeconds = sponsor.duration_days * 86400;
|
||||
await db.updateSponsorExpiresAtAdd(sponsor.extends_sponsor_id, additionalSeconds);
|
||||
await db.updateSponsorStatus(sponsor.id, "removed");
|
||||
} else {
|
||||
const expiresAt = now + sponsor.duration_days * 86400;
|
||||
await db.updateSponsorOnPayment(paymentHash, now, expiresAt);
|
||||
}
|
||||
res.json({ paid: true });
|
||||
} catch (e) {
|
||||
console.error("[sponsor] check-payment failed:", e);
|
||||
res.status(502).json({ paid: false, error: "Failed to check payment" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/homepage", async (_req: Request, res: Response) => {
|
||||
const db = getDb();
|
||||
const sponsors = await db.getActiveSponsorsForHomepage(config.sponsorMaxVisible);
|
||||
res.json(sponsors.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
image_url: s.image_url,
|
||||
link_url: s.link_url,
|
||||
category: s.category,
|
||||
expires_at: s.expires_at,
|
||||
views: s.views,
|
||||
clicks: s.clicks,
|
||||
})));
|
||||
});
|
||||
|
||||
router.get("/list", async (_req: Request, res: Response) => {
|
||||
const db = getDb();
|
||||
const sponsors = await db.getSponsorsForPage();
|
||||
res.json(sponsors.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
image_url: s.image_url,
|
||||
link_url: s.link_url,
|
||||
category: s.category,
|
||||
expires_at: s.expires_at,
|
||||
views: s.views,
|
||||
clicks: s.clicks,
|
||||
})));
|
||||
});
|
||||
|
||||
router.get("/my-ads", authOrNip98, async (req: Request, res: Response) => {
|
||||
const pubkey = requireAuth(req);
|
||||
if (!pubkey) {
|
||||
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||
return;
|
||||
}
|
||||
const db = getDb();
|
||||
const sponsors = await db.getSponsorsByNpub(pubkey);
|
||||
res.json(
|
||||
sponsors.map((s) => ({
|
||||
...s,
|
||||
payment_request: s.status === "pending_payment" ? s.payment_request ?? null : undefined,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
router.post("/:id/regenerate-invoice", authOrNip98, async (req: Request, res: Response) => {
|
||||
const pubkey = requireAuth(req);
|
||||
if (!pubkey) {
|
||||
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id < 1) {
|
||||
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||
return;
|
||||
}
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorById(id);
|
||||
if (!sponsor || sponsor.npub !== pubkey) {
|
||||
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||
return;
|
||||
}
|
||||
if (sponsor.status !== "pending_payment") {
|
||||
res.status(400).json({ code: "invalid_status", message: "Can only regenerate invoice for pending payment" });
|
||||
return;
|
||||
}
|
||||
const priceSats = sponsor.price_sats;
|
||||
const webhookUrl = config.publicApiUrl ? `${config.publicApiUrl}/sponsor/webhook` : "";
|
||||
let paymentHash: string;
|
||||
let paymentRequest: string;
|
||||
try {
|
||||
const inv = await createInvoice(priceSats, `Sponsor: ${sponsor.title}`, webhookUrl);
|
||||
paymentHash = inv.payment_hash;
|
||||
paymentRequest = inv.payment_request;
|
||||
} catch (e) {
|
||||
console.error("[sponsor] regenerate-invoice createInvoice failed:", e);
|
||||
res.status(502).json({ code: "invoice_failed", message: "Failed to create invoice" });
|
||||
return;
|
||||
}
|
||||
await db.updateSponsorPayment(id, paymentHash, paymentRequest);
|
||||
res.json({
|
||||
payment_hash: paymentHash,
|
||||
payment_request: paymentRequest,
|
||||
price_sats: priceSats,
|
||||
duration_days: sponsor.duration_days,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/click/:id", async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id < 1) {
|
||||
res.status(400).json({ error: "Invalid sponsor id" });
|
||||
return;
|
||||
}
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorById(id);
|
||||
if (!sponsor || sponsor.status !== "active") {
|
||||
res.status(404).json({ error: "Sponsor not found" });
|
||||
return;
|
||||
}
|
||||
await db.incrementSponsorClicks(id);
|
||||
res.redirect(302, sponsor.link_url);
|
||||
});
|
||||
|
||||
router.patch("/:id/view", async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id < 1) {
|
||||
res.status(400).json({ error: "Invalid sponsor id" });
|
||||
return;
|
||||
}
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorById(id);
|
||||
if (!sponsor || sponsor.status !== "active") {
|
||||
res.status(404).json({ error: "Sponsor not found" });
|
||||
return;
|
||||
}
|
||||
await db.incrementSponsorViews(id);
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
router.patch("/:id/extend", authOrNip98, async (req: Request, res: Response) => {
|
||||
const pubkey = requireAuth(req);
|
||||
if (!pubkey) {
|
||||
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||
return;
|
||||
}
|
||||
const sponsorId = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(sponsorId) || sponsorId < 1) {
|
||||
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as { duration_days?: number } | undefined;
|
||||
let durationDays = typeof body?.duration_days === "number" ? body.duration_days : 0;
|
||||
durationDays = snapDays(durationDays);
|
||||
if (durationDays < 1 || durationDays > 365) {
|
||||
res.status(400).json({ code: "invalid_duration", message: "Duration must be 1-365 days" });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorById(sponsorId);
|
||||
if (!sponsor || sponsor.npub !== pubkey) {
|
||||
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||
return;
|
||||
}
|
||||
if (sponsor.status !== "active" && sponsor.status !== "expired") {
|
||||
res.status(400).json({ code: "invalid_status", message: "Can only extend active or expired sponsors" });
|
||||
return;
|
||||
}
|
||||
|
||||
const priceSats = calculatePrice(durationDays);
|
||||
const webhookUrl = config.publicApiUrl ? `${config.publicApiUrl}/sponsor/webhook` : "";
|
||||
|
||||
let paymentHash: string;
|
||||
let paymentRequest: string;
|
||||
try {
|
||||
const inv = await createInvoice(priceSats, `Extend sponsor: ${sponsor.title}`, webhookUrl);
|
||||
paymentHash = inv.payment_hash;
|
||||
paymentRequest = inv.payment_request;
|
||||
} catch (e) {
|
||||
console.error("[sponsor] extend createInvoice failed:", e);
|
||||
res.status(502).json({ code: "invoice_failed", message: "Failed to create invoice" });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const currentExpires = sponsor.expires_at ?? now;
|
||||
const newExpiresAt = Math.max(currentExpires, now) + durationDays * 86400;
|
||||
|
||||
await db.insertSponsor({
|
||||
npub: pubkey,
|
||||
title: sponsor.title,
|
||||
description: sponsor.description,
|
||||
image_url: sponsor.image_url,
|
||||
link_url: sponsor.link_url,
|
||||
category: sponsor.category,
|
||||
lightning_address: sponsor.lightning_address,
|
||||
invoice_id: null,
|
||||
payment_hash: paymentHash,
|
||||
payment_request: paymentRequest,
|
||||
price_sats: priceSats,
|
||||
duration_days: durationDays,
|
||||
status: "pending_payment",
|
||||
created_at: now,
|
||||
activated_at: null,
|
||||
expires_at: null,
|
||||
extends_sponsor_id: sponsorId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
sponsor_id: sponsorId,
|
||||
payment_hash: paymentHash,
|
||||
payment_request: paymentRequest,
|
||||
price_sats: priceSats,
|
||||
duration_days: durationDays,
|
||||
new_expires_at: newExpiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
router.patch("/:id", authOrNip98, async (req: Request, res: Response) => {
|
||||
const pubkey = requireAuth(req);
|
||||
if (!pubkey) {
|
||||
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id < 1) {
|
||||
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorById(id);
|
||||
if (!sponsor || sponsor.npub !== pubkey) {
|
||||
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||
return;
|
||||
}
|
||||
if (sponsor.status !== "pending_payment" && sponsor.status !== "active") {
|
||||
res.status(400).json({ code: "invalid_status", message: "Can only edit pending or active sponsors" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as CreateSponsorBody;
|
||||
const updates: Partial<Pick<import("../db/types.js").SponsorRow, "title" | "description" | "image_url" | "link_url" | "category" | "lightning_address">> = {};
|
||||
if (typeof body.title === "string" && body.title.trim()) updates.title = body.title.trim();
|
||||
if (typeof body.description === "string") updates.description = body.description.trim();
|
||||
if (typeof body.link_url === "string" && /^https?:\/\/.+/.test(body.link_url)) updates.link_url = body.link_url.trim();
|
||||
if (typeof body.image_url === "string") updates.image_url = body.image_url.trim() || null;
|
||||
if (typeof body.category === "string") updates.category = body.category.trim() || null;
|
||||
if (typeof body.lightning_address === "string") updates.lightning_address = body.lightning_address.trim() || null;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db.updateSponsor(id, updates);
|
||||
}
|
||||
|
||||
const updated = await db.getSponsorById(id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete("/:id", authOrNip98, async (req: Request, res: Response) => {
|
||||
const pubkey = requireAuth(req);
|
||||
if (!pubkey) {
|
||||
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id < 1) {
|
||||
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorById(id);
|
||||
if (!sponsor || sponsor.npub !== pubkey) {
|
||||
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db.updateSponsorStatus(id, "removed");
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
57
backend/src/routes/sponsorWebhook.ts
Normal file
57
backend/src/routes/sponsorWebhook.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { getDb } from "../db/index.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** LNbits webhook payload when invoice is paid. */
|
||||
interface LnbitsWebhookPayload {
|
||||
payment_hash?: string;
|
||||
payment_request?: string;
|
||||
amount?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /sponsor/webhook
|
||||
* Called by LNbits when a sponsor invoice is paid.
|
||||
* No auth - webhook is invoked by LNbits server.
|
||||
*/
|
||||
router.post("/sponsor/webhook", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = (req.body || {}) as LnbitsWebhookPayload;
|
||||
const paymentHash = typeof body.payment_hash === "string" ? body.payment_hash.trim() : null;
|
||||
if (!paymentHash) {
|
||||
res.status(400).json({ error: "Missing payment_hash" });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const sponsor = await db.getSponsorByPaymentHash(paymentHash);
|
||||
if (!sponsor || sponsor.status !== "pending_payment") {
|
||||
res.status(200).json({ ok: true, message: "No matching sponsor or already processed" });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (sponsor.extends_sponsor_id) {
|
||||
const additionalSeconds = sponsor.duration_days * 86400;
|
||||
await db.updateSponsorExpiresAtAdd(sponsor.extends_sponsor_id, additionalSeconds);
|
||||
await db.updateSponsorStatus(sponsor.id, "removed");
|
||||
console.log(`[sponsor] Extended sponsor id=${sponsor.extends_sponsor_id} by ${sponsor.duration_days} days`);
|
||||
} else {
|
||||
const expiresAt = now + sponsor.duration_days * 86400;
|
||||
const updated = await db.updateSponsorOnPayment(paymentHash, now, expiresAt);
|
||||
if (updated) {
|
||||
console.log(`[sponsor] Activated sponsor id=${sponsor.id} title="${sponsor.title}"`);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error("[sponsor/webhook]", e);
|
||||
res.status(500).json({ error: "Internal error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
61
backend/src/services/fetchOgMeta.ts
Normal file
61
backend/src/services/fetchOgMeta.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Fetch Open Graph meta tags from a URL.
|
||||
* Used when sponsor image_url is empty - we try to get og:image from the target site.
|
||||
*/
|
||||
export async function fetchOgMeta(url: string): Promise<{ image?: string; title?: string; description?: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "SatsFaucet/1.0 (Sponsor preview fetcher)",
|
||||
},
|
||||
redirect: "follow",
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return {};
|
||||
const html = await res.text();
|
||||
return parseOgFromHtml(html, url);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseOgFromHtml(html: string, baseUrl: string): { image?: string; title?: string; description?: string } {
|
||||
const result: { image?: string; title?: string; description?: string } = {};
|
||||
const ogImage = matchMeta(html, "og:image");
|
||||
const ogTitle = matchMeta(html, "og:title");
|
||||
const ogDesc = matchMeta(html, "og:description");
|
||||
|
||||
if (ogImage) {
|
||||
result.image = resolveUrl(ogImage, baseUrl);
|
||||
}
|
||||
if (ogTitle) result.title = ogTitle;
|
||||
if (ogDesc) result.description = ogDesc;
|
||||
return result;
|
||||
}
|
||||
|
||||
function matchMeta(html: string, property: string): string | null {
|
||||
const patterns = [
|
||||
new RegExp(`<meta[^>]+property=["']${property.replace(":", "\\:")}["'][^>]+content=["']([^"']+)["']`, "i"),
|
||||
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${property.replace(":", "\\:")}["']`, "i"),
|
||||
];
|
||||
for (const re of patterns) {
|
||||
const m = html.match(re);
|
||||
if (m) return m[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveUrl(url: string, base: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
try {
|
||||
return new URL(trimmed, base).href;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -188,3 +188,53 @@ export async function getIncomingPaymentsFromLnbits(limit = 100): Promise<
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a payment (invoice) has been paid.
|
||||
* LNbits API: GET /api/v1/payments/{payment_hash}
|
||||
*/
|
||||
export async function checkPaymentStatus(paymentHash: string): Promise<{ paid: boolean }> {
|
||||
const res = await fetch(`${base}/api/v1/payments/${encodeURIComponent(paymentHash)}`, {
|
||||
headers: { "X-Api-Key": adminKey },
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return { paid: false };
|
||||
const text = await res.text();
|
||||
throw new Error(`LNbits check payment failed: ${res.status} ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as { paid?: boolean };
|
||||
return { paid: Boolean(data.paid) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an incoming Lightning invoice (receive payment).
|
||||
* LNbits API: POST /api/v1/payments with out: false.
|
||||
*/
|
||||
export async function createInvoice(
|
||||
amountSats: number,
|
||||
memo: string,
|
||||
webhookUrl: string
|
||||
): Promise<{ payment_hash: string; payment_request: string }> {
|
||||
const res = await fetch(`${base}/api/v1/payments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Api-Key": adminKey },
|
||||
body: JSON.stringify({
|
||||
out: false,
|
||||
amount: amountSats,
|
||||
memo: memo.slice(0, 200),
|
||||
webhook: webhookUrl || undefined,
|
||||
expiry: 3600,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`LNbits create invoice failed: ${res.status} ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as { payment_hash?: string; payment_request?: string };
|
||||
const paymentHash = data.payment_hash;
|
||||
const paymentRequest = data.payment_request;
|
||||
if (!paymentHash || !paymentRequest) {
|
||||
throw new Error("LNbits invoice response missing payment_hash or payment_request");
|
||||
}
|
||||
return { payment_hash: paymentHash, payment_request: paymentRequest };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user