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:
Michilis
2026-03-16 00:01:19 +00:00
parent ac9b8dc330
commit dc7007f708
30 changed files with 3123 additions and 68 deletions

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
);
},
};
}

View File

@@ -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>;
}

View File

@@ -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
View 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;

View File

@@ -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({

View 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;

View 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;

View 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;
}
}

View File

@@ -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 };
}