diff --git a/next.config.js b/next.config.js index 3627c63..d2a0d7e 100644 --- a/next.config.js +++ b/next.config.js @@ -17,6 +17,10 @@ module.exports = removeImports({ { source: "/.well-known/nostr.json", destination: "/api/nip05", + }, + { + source: '/.well-known/lnurlp/:slug', + destination: '/api/lightning-address/lnurlp/:slug', } ]; }, diff --git a/package-lock.json b/package-lock.json index 86b21db..f51ff6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "bech32": "^2.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cors": "^2.8.5", "discord.js": "^14.15.3", "light-bolt11-decoder": "^3.1.1", "lucide-react": "^0.441.0", @@ -5842,6 +5843,19 @@ "node": ">= 0.6" } }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -13542,6 +13556,15 @@ "node": ">=8" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.2.tgz", diff --git a/package.json b/package.json index 3ceb98f..17bf6e1 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bech32": "^2.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cors": "^2.8.5", "discord.js": "^14.15.3", "light-bolt11-decoder": "^3.1.1", "lucide-react": "^0.441.0", diff --git a/src/components/content/carousels/InteractivePromotionalCarousel.js b/src/components/content/carousels/InteractivePromotionalCarousel.js index 131a591..535f93c 100644 --- a/src/components/content/carousels/InteractivePromotionalCarousel.js +++ b/src/components/content/carousels/InteractivePromotionalCarousel.js @@ -57,6 +57,7 @@ const promotions = [ }, ] +// todo bigger ore simple CTA to get users into the content const InteractivePromotionalCarousel = () => { const [selectedPromotion, setSelectedPromotion] = useState(promotions[0]) const { returnImageProxy } = useImageProxy(); diff --git a/src/config/appConfig.js b/src/config/appConfig.js index 6ee53ef..d70d048 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,7 +11,30 @@ const appConfig = { "wss://purplerelay.com/", // "wss://relay.devs.tools/" ], - authorPubkeys: ["8cb60e215678879cda0bef4d5b3fc1a5c5925d2adb5d8c4fa7b7d03b5f2deaea", "676c02247668d5b18479be3d1a80933044256f3fbd03640a8c234684e641b6d6"] + authorPubkeys: ["8cb60e215678879cda0bef4d5b3fc1a5c5925d2adb5d8c4fa7b7d03b5f2deaea", "676c02247668d5b18479be3d1a80933044256f3fbd03640a8c234684e641b6d6"], + customLightningAddresses: [ + { + // todo remove need for lowercase + // name will appear as name@plebdevs.com (lowercase) + name: "austin", + // If enabled, zaps are enabled + allowsNostr: true, + // make you're own lud06 metadata description + description: "Austin's Lightning Address", + // millisats + maxSendable: 10000000000, + // millisats + minSendable: 1000, + // Your LND invoice macaroon + invoiceMacaroon: process.env.LND_MACAROON, + // your LND TLS certificate (may be optional depending on your LND configuration) + lndCert: "", + // your LND host (do not include https:// or port) + lndHost: process.env.LND_HOST, + // your LND REST API port (default is 8080) + lndPort: "8080", + }, + ], }; export default appConfig; \ No newline at end of file diff --git a/src/pages/api/lightning-address/callback/[slug].js b/src/pages/api/lightning-address/callback/[slug].js index c46cf04..f4631f1 100644 --- a/src/pages/api/lightning-address/callback/[slug].js +++ b/src/pages/api/lightning-address/callback/[slug].js @@ -1,24 +1,28 @@ import axios from "axios"; import crypto from "crypto"; -import { runMiddleware, corsMiddleware } from "../../../utils/middleware"; import { verifyEvent } from 'nostr-tools/pure'; +import appConfig from "@/config/appConfig"; +import { runMiddleware, corsMiddleware } from "@/utils/corsMiddleware"; const BACKEND_URL = process.env.BACKEND_URL; -const RELAY_PUBKEY = process.env.RELAY_PUBKEY; export default async function handler(req, res) { await runMiddleware(req, res, corsMiddleware); const { slug, ...queryParams } = req.query; - if (slug === 'austin') { + const customAddress = appConfig.customLightningAddresses.find(addr => addr.name === slug); + + if (customAddress) { if (queryParams.amount) { const amount = parseInt(queryParams.amount); let metadata, metadataString, hash, descriptionHash; - if (queryParams.nostr) { + if (queryParams?.nostr) { // This is a zap request const zapRequest = JSON.parse(decodeURIComponent(queryParams.nostr)); + console.log("ZAP REQUEST", zapRequest) + // Verify the zap request if (!verifyEvent(zapRequest)) { res.status(400).json({ error: 'Invalid zap request' }); @@ -37,7 +41,7 @@ export default async function handler(req, res) { } else { // This is a regular lnurl-pay request metadata = [ - ["text/plain", "PlebDevs LNURL endpoint, CHEERS!"] + ["text/plain", `${customAddress.name}'s LNURL endpoint, CHEERS!`] ]; metadataString = JSON.stringify(metadata); hash = crypto.createHash('sha256').update(metadataString).digest('hex'); @@ -45,13 +49,15 @@ export default async function handler(req, res) { } // Convert amount from millisatoshis to satoshis - const value = amount / 1000; - if (value < 1) { + if (amount < (customAddress.minSendable)) { res.status(400).json({ error: 'Amount too low' }); return; + } else if (amount > (customAddress.maxSendable || Number.MAX_SAFE_INTEGER)) { + res.status(400).json({ error: 'Amount too high' }); + return; } else { try { - const response = await axios.post(`${BACKEND_URL}/api/lnd`, { amount: value, description_hash: descriptionHash }); + const response = await axios.post(`${BACKEND_URL}/api/lightning-address/lnd`, { amount: amount, description_hash: descriptionHash, name: slug, zap_request: queryParams?.nostr ? queryParams.nostr : null }); res.status(200).json({ pr: response.data }); } catch (error) { console.error(error); @@ -61,5 +67,7 @@ export default async function handler(req, res) { } else { res.status(400).json({ error: 'Amount not specified' }); } + } else { + res.status(404).json({ error: 'Lightning address not found' }); } } \ No newline at end of file diff --git a/src/pages/api/lightning-address/lnd.js b/src/pages/api/lightning-address/lnd.js index cee0c40..4d3127e 100644 --- a/src/pages/api/lightning-address/lnd.js +++ b/src/pages/api/lightning-address/lnd.js @@ -1,16 +1,42 @@ import axios from "axios"; import { finalizeEvent } from 'nostr-tools/pure'; import { SimplePool } from 'nostr-tools/pool'; +import appConfig from "@/config/appConfig"; const LND_HOST = process.env.LND_HOST; const LND_MACAROON = process.env.LND_MACAROON; -const RELAY_PRIVKEY = process.env.RELAY_PRIVKEY; +const ZAP_PRIVKEY = process.env.ZAP_PRIVKEY; export default async function handler(req, res) { try { + const { amount, description_hash, zap_request=null, name } = req.body; + + // Find the custom lightning address + const customAddress = appConfig.customLightningAddresses.find(addr => addr.name === name); + + if (!customAddress) { + res.status(404).json({ error: 'Lightning address not found' }); + return; + } + + // Check if amount is within allowed range + const minSendable = customAddress.minSendable || appConfig.defaultMinSendable || 1; + const maxSendable = customAddress.maxSendable || appConfig.defaultMaxSendable || Number.MAX_SAFE_INTEGER; + + if (amount < minSendable || amount > maxSendable) { + res.status(400).json({ error: 'Amount out of allowed range' }); + return; + } + + // Check if the custom address allows zaps + if (zap_request && !customAddress.allowsNostr) { + res.status(400).json({ error: 'Nostr zaps not allowed for this address' }); + return; + } + const response = await axios.post(`https://${LND_HOST}/v1/invoices`, { - value: req.body.amount, - description_hash: req.body.description_hash + value_msat: amount, + description_hash: description_hash }, { headers: { 'Grpc-Metadata-macaroon': LND_MACAROON, @@ -20,12 +46,13 @@ export default async function handler(req, res) { const invoice = response.data.payment_request; // If this is a zap, publish a zap receipt - if (req.body.zap_request) { - const zapRequest = JSON.parse(req.body.zap_request); + if (zap_request && customAddress.allowsNostr) { + console.log("ZAP REQUEST", zap_request) + const zapRequest = JSON.parse(zap_request); const zapReceipt = { kind: 9735, created_at: Math.floor(Date.now() / 1000), - content: '', + content: customAddress.zapMessage || appConfig.defaultZapMessage || '', tags: [ ['p', zapRequest.pubkey], ['e', zapRequest.id], @@ -34,12 +61,12 @@ export default async function handler(req, res) { ] }; - const signedZapReceipt = finalizeEvent(zapReceipt, RELAY_PRIVKEY); + const signedZapReceipt = finalizeEvent(zapReceipt, customAddress.relayPrivkey || ZAP_PRIVKEY); // Publish zap receipt to relays const pool = new SimplePool(); - const relays = zapRequest.tags.find(tag => tag[0] === 'relays')?.[1] || []; - await pool.publish(relays, signedZapReceipt); + const relays = customAddress.defaultRelays || appConfig.defaultRelayUrls || []; + await Promise.any(pool.publish(relays, signedZapReceipt)); } res.status(200).json(invoice); diff --git a/src/pages/api/lightning-address/lnurlp/[slug].js b/src/pages/api/lightning-address/lnurlp/[slug].js index b29bacd..9a8ba25 100644 --- a/src/pages/api/lightning-address/lnurlp/[slug].js +++ b/src/pages/api/lightning-address/lnurlp/[slug].js @@ -1,11 +1,11 @@ -import { runMiddleware, corsMiddleware } from "../../../utils/middleware" +import appConfig from "@/config/appConfig" +import { runMiddleware, corsMiddleware } from "@/utils/corsMiddleware"; const BACKEND_URL = process.env.BACKEND_URL -const RELAY_PUBKEY = process.env.RELAY_PUBKEY +const ZAP_PUBKEY = process.env.ZAP_PUBKEY export default async function handler(req, res) { await runMiddleware(req, res, corsMiddleware); - const { slug } = req.query if (!slug || slug === 'undefined') { @@ -13,20 +13,24 @@ export default async function handler(req, res) { return } - if (slug === 'austin') { + const customAddress = appConfig.customLightningAddresses.find(addr => addr.name === slug) + + if (customAddress) { const metadata = [ - ["text/plain", "PlebDevs LNURL endpoint, CHEERS!"] + ["text/plain", `${customAddress.description}`] ]; res.status(200).json({ - callback: `${BACKEND_URL}/api/callback/austin`, - maxSendable: 10000000000, - minSendable: 1000, + callback: `${BACKEND_URL}/api/lightning-address/callback/${customAddress.name}`, + maxSendable: customAddress.maxSendable || 10000000000, + minSendable: customAddress.minSendable || 1000, metadata: JSON.stringify(metadata), tag: 'payRequest', allowsNostr: true, - nostrPubkey: RELAY_PUBKEY + nostrPubkey: ZAP_PUBKEY }) return } + + res.status(404).json({ error: 'Lightning address not found' }) } \ No newline at end of file diff --git a/src/utils/corsMiddleware.js b/src/utils/corsMiddleware.js new file mode 100644 index 0000000..1bd7752 --- /dev/null +++ b/src/utils/corsMiddleware.js @@ -0,0 +1,21 @@ +import Cors from 'cors'; + +// Initialize the cors middleware +export const corsMiddleware = Cors({ + methods: ['GET', 'HEAD', 'POST'], + origin: '*', +}); + + +// Helper method to wait for a middleware to execute before continuing +// And to throw an error when an error happens in a middleware +export async function runMiddleware(req, res, middleware) { + return new Promise((resolve, reject) => { + middleware(req, res, (result) => { + if (result instanceof Error) { + return reject(result); + } + return resolve(result); + }); + }); +} \ No newline at end of file