From dc7007f708a4d074ffc27eaa7d669de8896fa0b4 Mon Sep 17 00:00:00 2001 From: Michilis Date: Mon, 16 Mar 2026 00:01:19 +0000 Subject: [PATCH] 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 --- backend/.env.example | 9 + backend/src/config.ts | 8 + backend/src/db/pg.ts | 247 ++++++- backend/src/db/schema.pg.sql | 26 + backend/src/db/schema.sql | 26 + backend/src/db/sqlite.ts | 215 +++++- backend/src/db/types.ts | 44 ++ backend/src/index.ts | 6 + backend/src/routes/admin.ts | 118 ++++ backend/src/routes/public.ts | 4 +- backend/src/routes/sponsor.ts | 454 ++++++++++++ backend/src/routes/sponsorWebhook.ts | 57 ++ backend/src/services/fetchOgMeta.ts | 61 ++ backend/src/services/lnbits.ts | 50 ++ frontend/.env.example | 6 +- frontend/src/App.tsx | 85 ++- frontend/src/api.ts | 118 ++++ frontend/src/components/Header.tsx | 120 +++- frontend/src/components/LoginModal.tsx | 3 +- frontend/src/components/Modal.tsx | 6 +- frontend/src/components/SponsorCard.tsx | 84 +++ frontend/src/components/SponsorForm.tsx | 145 ++++ .../src/components/SponsorInvoiceModal.tsx | 98 +++ frontend/src/components/SponsorTimeSlider.tsx | 66 ++ frontend/src/components/SponsorsSection.tsx | 31 + frontend/src/pages/MyAdsPage.tsx | 205 ++++++ frontend/src/pages/SponsorsPage.tsx | 231 +++++++ frontend/src/pages/TransactionsPage.tsx | 18 +- frontend/src/styles/global.css | 647 +++++++++++++++++- frontend/vite.config.ts | 3 + 30 files changed, 3123 insertions(+), 68 deletions(-) create mode 100644 backend/src/routes/admin.ts create mode 100644 backend/src/routes/sponsor.ts create mode 100644 backend/src/routes/sponsorWebhook.ts create mode 100644 backend/src/services/fetchOgMeta.ts create mode 100644 frontend/src/components/SponsorCard.tsx create mode 100644 frontend/src/components/SponsorForm.tsx create mode 100644 frontend/src/components/SponsorInvoiceModal.tsx create mode 100644 frontend/src/components/SponsorTimeSlider.tsx create mode 100644 frontend/src/components/SponsorsSection.tsx create mode 100644 frontend/src/pages/MyAdsPage.tsx create mode 100644 frontend/src/pages/SponsorsPage.tsx diff --git a/backend/.env.example b/backend/.env.example index ca07a18..5029c2d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -53,3 +53,12 @@ DEPOSIT_LNURLP=https://yourdomain.com/.well-known/lnurlp/faucet # Cashu redeem (optional; default: https://cashu-redeem.azzamo.net) # CASHU_REDEEM_API_URL=https://cashu-redeem.azzamo.net + +# Sponsors +BASE_SPONSOR_PRICE_PER_DAY=200 +SPONSOR_MAX_ACTIVE_PER_USER=5 +SPONSOR_MAX_VISIBLE=6 +# Comma-separated hex pubkeys for admin API +# ADMIN_PUBKEYS=abc123...,def456... +# Public API URL for LNbits webhook (e.g. https://api.example.com) +# PUBLIC_API_URL=https://api.example.com diff --git a/backend/src/config.ts b/backend/src/config.ts index 7e1cd8e..953fc87 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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 { diff --git a/backend/src/db/pg.ts b/backend/src/db/pg.ts index 5471099..35f035e 100644 --- a/backend/src/db/pg.ts +++ b/backend/src/db/pg.ts @@ -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 { @@ -343,5 +377,212 @@ export function createPgDb(connectionString: string): Db { created_at: Number(r.created_at), })); }, + + async insertSponsor(row: Omit & { extends_sponsor_id?: number | null }): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await pool.query("UPDATE sponsors SET views = views + 1 WHERE id = $1", [id]); + }, + + async incrementSponsorClicks(id: number): Promise { + 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 { + 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 { + await pool.query("UPDATE sponsors SET status = $1 WHERE id = $2", [status, id]); + }, + + async updateSponsorExpiresAt(id: number, expiresAt: number): Promise { + await pool.query("UPDATE sponsors SET expires_at = $1 WHERE id = $2", [expiresAt, id]); + }, + + async updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise { + await pool.query("UPDATE sponsors SET activated_at = $1, expires_at = $2 WHERE id = $3", [activatedAt, expiresAt, id]); + }, + + async updateSponsor( + id: number, + data: Partial> + ): Promise { + 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 { + 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, + }; + } diff --git a/backend/src/db/schema.pg.sql b/backend/src/db/schema.pg.sql index e6b5d4c..d4f2d31 100644 --- a/backend/src/db/schema.pg.sql +++ b/backend/src/db/schema.pg.sql @@ -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); diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index d9467db..6ec02bd 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -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); diff --git a/backend/src/db/sqlite.ts b/backend/src/db/sqlite.ts index 0155a69..6ab2e68 100644 --- a/backend/src/db/sqlite.ts +++ b/backend/src/db/sqlite.ts @@ -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 { @@ -283,5 +317,184 @@ export function createSqliteDb(path: string): Db { created_at: r.created_at, })); }, + + async insertSponsor(row: Omit & { extends_sponsor_id?: number | null }): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + db.prepare("UPDATE sponsors SET views = views + 1 WHERE id = ?").run(id); + }, + + async incrementSponsorClicks(id: number): Promise { + 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 { + 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 { + db.prepare("UPDATE sponsors SET status = ? WHERE id = ?").run(status, id); + }, + + async updateSponsorExpiresAt(id: number, expiresAt: number): Promise { + db.prepare("UPDATE sponsors SET expires_at = ? WHERE id = ?").run(expiresAt, id); + }, + + async updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise { + db.prepare("UPDATE sponsors SET activated_at = ?, expires_at = ? WHERE id = ?").run(activatedAt, expiresAt, id); + }, + + async updateSponsor( + id: number, + data: Partial> + ): Promise { + 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 { + db.prepare("UPDATE sponsors SET payment_hash = ?, payment_request = ? WHERE id = ?").run( + paymentHash, + paymentRequest, + id + ); + }, }; } diff --git a/backend/src/db/types.ts b/backend/src/db/types.ts index 336a5d2..c839bf7 100644 --- a/backend/src/db/types.ts +++ b/backend/src/db/types.ts @@ -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; updateDepositCreatedAtIfMissing(paymentHash: string, createdAt: number): Promise; getRecentDeposits(limit: number): Promise<{ amount_sats: number; source: DepositSource; created_at: number }[]>; + + insertSponsor(row: Omit & { extends_sponsor_id?: number | null }): Promise; + getSponsorById(id: number): Promise; + getSponsorByPaymentHash(paymentHash: string): Promise; + updateSponsorOnPayment(paymentHash: string, activatedAt: number, expiresAt: number): Promise; + updateSponsorExpiresAtAdd(id: number, additionalSeconds: number): Promise; + getActiveSponsorsForHomepage(limit: number): Promise; + getSponsorsForPage(): Promise; + getSponsorsByNpub(npub: string): Promise; + getAllSponsors(opts: { status?: string; limit: number }): Promise; + incrementSponsorViews(id: number): Promise; + incrementSponsorClicks(id: number): Promise; + getRecentSponsorPayments(limit: number): Promise<{ created_at: number; amount_sats: number; title: string }[]>; + countActiveSponsorsByNpub(npub: string): Promise; + updateSponsorStatus(id: number, status: SponsorStatus): Promise; + updateSponsorExpiresAt(id: number, expiresAt: number): Promise; + updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise; + updateSponsor(id: number, data: Partial>): Promise; + updateSponsorPayment(id: number, paymentHash: string, paymentRequest: string): Promise; } diff --git a/backend/src/index.ts b/backend/src/index.ts index 65aa0f7..e18ae7a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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", diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..63d76fb --- /dev/null +++ b/backend/src/routes/admin.ts @@ -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; diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts index e8d910b..e8693d6 100644 --- a/backend/src/routes/public.ts +++ b/backend/src/routes/public.ts @@ -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({ diff --git a/backend/src/routes/sponsor.ts b/backend/src/routes/sponsor.ts new file mode 100644 index 0000000..7936b0d --- /dev/null +++ b/backend/src/routes/sponsor.ts @@ -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> = {}; + 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; diff --git a/backend/src/routes/sponsorWebhook.ts b/backend/src/routes/sponsorWebhook.ts new file mode 100644 index 0000000..392c0f0 --- /dev/null +++ b/backend/src/routes/sponsorWebhook.ts @@ -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; diff --git a/backend/src/services/fetchOgMeta.ts b/backend/src/services/fetchOgMeta.ts new file mode 100644 index 0000000..1be6f99 --- /dev/null +++ b/backend/src/services/fetchOgMeta.ts @@ -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(`]+property=["']${property.replace(":", "\\:")}["'][^>]+content=["']([^"']+)["']`, "i"), + new RegExp(`]+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; + } +} diff --git a/backend/src/services/lnbits.ts b/backend/src/services/lnbits.ts index c0d4c61..e558756 100644 --- a/backend/src/services/lnbits.ts +++ b/backend/src/services/lnbits.ts @@ -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 }; +} diff --git a/frontend/.env.example b/frontend/.env.example index 80146bd..9d6e702 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,6 @@ -# Backend API URL (required in dev when frontend runs on different port) -# Leave empty if frontend is served from same origin as API -VITE_API_URL=http://localhost:3001 +# Backend API URL. For dev: leave empty to use Vite proxy (recommended). +# For production or when frontend is on different origin: set full URL e.g. http://localhost:3001 +# VITE_API_URL= # Nostr relays for fetching user profile metadata (comma-separated) VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f91538..2f26996 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,9 @@ import { ClaimWizard } from "./components/ClaimWizard"; import { StatsSection } from "./components/StatsSection"; import { DepositSection } from "./components/DepositSection"; import { TransactionsPage } from "./pages/TransactionsPage"; +import { SponsorsPage } from "./pages/SponsorsPage"; +import { MyAdsPage } from "./pages/MyAdsPage"; +import { SponsorsSection } from "./components/SponsorsSection"; const FaucetSvg = () => ( @@ -29,9 +32,13 @@ export default function App() { const [loginMethod, setLoginMethod] = useState(null); const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0); - useEffect(() => { + const refreshAuth = useCallback(() => { const token = getToken(); - if (!token) return; + if (!token) { + setPubkey(null); + setLoginMethod(null); + return; + } getAuthMe() .then((r) => { setPubkey(r.pubkey); @@ -44,6 +51,13 @@ export default function App() { }); }, []); + useEffect(() => { + refreshAuth(); + const onAuthChanged = () => refreshAuth(); + window.addEventListener("auth-changed", onAuthChanged); + return () => window.removeEventListener("auth-changed", onAuthChanged); + }, [refreshAuth]); + const handlePubkeyChange = useCallback((pk: string | null, method?: LoginMethod) => { setPubkey(pk); setLoginMethod(pk ? (method ?? "nip98") : null); @@ -62,40 +76,33 @@ export default function App() { return (
-
+
- -
-
- -

Sats Faucet

-
- -
- -
+ <> +
+ +
+
+ +

Sats Faucet

+
+ +
+ +
+ + } /> } /> + + +
+ } + /> + +
+ +
+
+ } + />