diff options
| author | John Barratt <[email protected]> | 2023-02-28 13:55:59 -0800 |
|---|---|---|
| committer | John Barratt <[email protected]> | 2023-02-28 13:55:59 -0800 |
| commit | e47d50a443f0a396321716a506e1df325dd4a1fb (patch) | |
| tree | 1c010c5970e9cbfbf8671e6f00a97d6518446fdd | |
| parent | 58e996a4ac227162f171e84530fe898cb504e045 (diff) | |
| parent | e4702b4d13e4d966d735c2ba4657e10d7c436a57 (diff) | |
| download | coinos-server-e47d50a443f0a396321716a506e1df325dd4a1fb.tar.gz coinos-server-e47d50a443f0a396321716a506e1df325dd4a1fb.zip | |
Merge branch 'master' of https://github.com/coinos/coinos-server into notifications
| -rw-r--r-- | index.js | 2 | ||||
| -rw-r--r-- | lib/auth.js | 20 | ||||
| -rw-r--r-- | lib/db.js | 15 | ||||
| -rw-r--r-- | lib/invoices.js | 17 | ||||
| -rw-r--r-- | lib/nostr.js | 42 | ||||
| -rw-r--r-- | lib/payments.js | 28 | ||||
| -rw-r--r-- | lib/register.js | 25 | ||||
| -rw-r--r-- | lib/sockets.js | 2 | ||||
| -rw-r--r-- | lib/upload.js | 2 | ||||
| -rw-r--r-- | lib/utils.js | 2 | ||||
| -rw-r--r-- | lib/webhooks.js | 9 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 50 | ||||
| -rw-r--r-- | routes/email.js | 27 | ||||
| -rw-r--r-- | routes/info.js | 14 | ||||
| -rw-r--r-- | routes/invoices.js | 28 | ||||
| -rw-r--r-- | routes/lnurl.js | 21 | ||||
| -rw-r--r-- | routes/nostr.js | 53 | ||||
| -rw-r--r-- | routes/payments.js | 252 | ||||
| -rw-r--r-- | routes/users.js | 46 | ||||
| -rw-r--r-- | sampledata/nostr/config.toml | 135 | ||||
| -rwxr-xr-x | setup.sh | 1 | ||||
| -rwxr-xr-x | watcher.sh | 22 |
23 files changed, 590 insertions, 227 deletions
@@ -38,6 +38,7 @@ app.get("/nostr.json", nostr.identities); app.get("/:pubkey/followers", nostr.followers); app.get("/:pubkey/follows", nostr.follows); app.get("/:pubkey/notes", nostr.notes); +app.get("/:pubkey/:since/messages", nostr.messages); app.get("/event/:id", nostr.event); app.post("/event", nostr.broadcast); @@ -53,6 +54,7 @@ app.get("/payments/:hash", auth, payments.get); app.post("/parse", payments.parse); app.get("/pot/:name", payments.pot); app.post("/take", auth, payments.take); +app.post("/buy", auth, payments.buy); app.get("/encode", lnurl.encode); app.get("/decode", lnurl.decode); diff --git a/lib/auth.js b/lib/auth.js index f3cfd1e9..0533de31 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -7,17 +7,17 @@ export const optional = { preValidation: fastifyPassport.authenticate( "jwt", { session: false }, - function (req, res, err, user, info) { + function(req, res, err, user, info) { req.user = user; } - ), + ) }; export const auth = { preValidation: fastifyPassport.authenticate("jwt", { authInfo: false, - session: false, - }), + session: false + }) }; export const requirePin = async ({ body, user }) => { @@ -29,13 +29,17 @@ export const jwtStrategy = new jwt.Strategy( { jwtFromRequest: jwt.ExtractJwt.fromExtractors([ jwt.ExtractJwt.fromAuthHeaderAsBearerToken(), - (req) => (req.cookies ? req.cookies.token : null), + req => (req.cookies ? req.cookies.token : null) ]), - secretOrKey: config.jwt, + secretOrKey: config.jwt }, async (payload, next) => { - let user = await g(`user:${payload.id}`); - if (typeof user === "string") await g(`user:${user}`); + let user; + if (payload.id) { + user = await g(`user:${payload.id}`); + if (typeof user === "string") await g(`user:${user}`); + } + next(null, user); } ); @@ -1,6 +1,7 @@ import config from "$config"; import { sleep } from "$lib/utils"; import { createClient } from "redis"; +import { warn } from "$lib/logging"; export const db = createClient({ url: config.db }); try { @@ -15,11 +16,17 @@ export let g = async k => { let v = await db.get(k); try { return JSON.parse(v); - } catch(e) { + } catch (e) { return v; - } -} -export let s = (k, v) => db.set(k, JSON.stringify(v)); + } +}; +export let s = (k, v) => { + if (k === "user:null" || k === "user:undefined") { + warn("###### NULL USER #######"); + console.trace(); + } + db.set(k, JSON.stringify(v)); +}; let retries = {}; export let t = async (k, f) => { diff --git a/lib/invoices.js b/lib/invoices.js index bab48515..f64cf409 100644 --- a/lib/invoices.js +++ b/lib/invoices.js @@ -16,10 +16,9 @@ export let generate = async ({ invoice, user, sender, memo }) => { tip = parseInt(tip || 0); if (amount < 0) fail("amount out of range"); - if (tip > 5 * amount || tip < 0) fail("tip amount out of range"); if (!user) user = sender; - let uid = await g(`user:${user.username}`); + let uid = await g(`user:${user.username.toLowerCase()}`); user = await g(`user:${uid}`); if (!user) fail("user not provided"); @@ -30,9 +29,8 @@ export let generate = async ({ invoice, user, sender, memo }) => { let hash, text; if (type === types.lightning) { - let amt = amount ? `${amount + tip}sat` : "any"; let r = await ln.invoice( - amt, + amount ? `${amount + tip}sat` : "any", new Date(), memo || "", 3600, @@ -55,16 +53,17 @@ export let generate = async ({ invoice, user, sender, memo }) => { } invoice = { - ...invoice, amount, - hash, - text, + created: Date.now(), currency, + hash, rate, + received: 0, + request_id, + text, tip, + type, uid, - received: 0, - created: Date.now() }; l("creating invoice", user.username, amount, tip, currency, invoice.hash); diff --git a/lib/nostr.js b/lib/nostr.js index 065fafb0..604d207d 100644 --- a/lib/nostr.js +++ b/lib/nostr.js @@ -1,16 +1,17 @@ import config from "$config"; import { Relay, RelayPool } from "nostr"; -import { broadcast } from "$lib/sockets"; +import { broadcast, emit } from "$lib/sockets"; import store from "$lib/store"; import { g, s, db } from "$lib/db"; import { nada, wait } from "$lib/utils"; +export let coinos; export let pool; export let fillPool = () => { pool = RelayPool(config.relays); pool.on("open", relay => { - if (relay.url.includes(config.relay)) coinos = relay; + if (relay.url.includes(config.nostr)) coinos = relay; relay.subscribe("live", { limit: 1 }); }); @@ -39,6 +40,13 @@ export let fillPool = () => { // coinos.send(["EVENT", ev]); ev.user = await getUser(pubkey); // broadcast("event", ev); + + if (ev.kind === 4) { + let uid = await g(`user:${ev.tags[0][1]}`); + if (uid) ev.recipient = await g(`user:${uid}`); + ev.author = ev.user; + emit(uid, "event", ev); + } } } else if (sub.includes("profile")) { let { pubkey } = ev; @@ -59,9 +67,13 @@ export let fillPool = () => { await s(`user:${pubkey}`, JSON.stringify(user)); coinos.send(ev); + } else if (sub.includes("messages")) { + let pubkey = sub.split(":")[0]; + await db.sAdd(`${pubkey}:messages`, ev.id); + await s(`ev:${ev.id}`, ev); } else if (sub.includes("follows")) { let pubkey = sub.split(":")[0]; - await s(`${pubkey}:follows`, JSON.stringify(ev.tags)); + await s(`${pubkey}:follows`, ev.tags); for (let f of ev.tags) { let [_, followPubkey] = f; await db.sAdd(`${followPubkey}:followers`, pubkey); @@ -75,7 +87,7 @@ export let fillPool = () => { } else if (sub.includes("notes")) { let pubkey = sub.split(":")[0]; await db.sAdd(pubkey, ev.id); - await s(`ev:${ev.id}`, JSON.stringify(ev)); + await s(`ev:${ev.id}`, ev); } } catch (e) { console.log(e); @@ -83,7 +95,6 @@ export let fillPool = () => { }); }; -export let coinos; let now = () => Math.round(Date.now() / 1000); let timeouts = {}; @@ -96,8 +107,8 @@ export let q = async ( let start = Date.now(); let seen = []; let rejected; - query.since = await g(`since:${sub}`); - if (now() - query.since < since) return r(); + // query.since = await g(`since:${sub}`); + // if (now() - query.since < since) return r(); let done = { [sub]: [] }; @@ -156,9 +167,14 @@ export let q = async ( let seen = []; -let getUser = async pubkey => - JSON.parse(await g(`user:${pubkey}`)) || { - username: pubkey.substr(0, 6), - pubkey, - anon: true - }; +let getUser = async pubkey => { + let id = await g(`user:${pubkey}`); + let user = await g(`user:${id}`); + return ( + user || { + username: pubkey.substr(0, 6), + pubkey, + anon: true + } + ); +}; diff --git a/lib/payments.js b/lib/payments.js index 2c0eb0c0..aaf39210 100644 --- a/lib/payments.js +++ b/lib/payments.js @@ -4,7 +4,7 @@ import { emit } from "$lib/sockets"; import { v4 } from "uuid"; import { notify } from "$lib/notifications"; import { db, g, s, t } from "$lib/db"; -import { l, err } from "$lib/logging"; +import { l, err, warn } from "$lib/logging"; import { fail } from "$lib/utils"; import { callWebhook } from "$lib/webhooks"; @@ -13,7 +13,7 @@ export const types = { bitcoin: "bitcoin", lightning: "lightning", pot: "pot", - classic: "classic", + classic: "classic" }; export let debit = async ( @@ -25,9 +25,11 @@ export let debit = async ( type = types.internal, id = v4() ) => { - let ref; + let ref, tip; let invoice = await g(`invoice:${hash}`); - if (invoice) ref = invoice.uid; + if (invoice) { + ref = invoice.uid; + } let { id: uid, currency, username } = user; @@ -44,16 +46,21 @@ export let debit = async ( let covered = Math.min(credit, ourfee) || 0; ourfee -= covered; - l("debiting", user.username, balance, amount, ourfee, amount + ourfee); - if (balance < amount + ourfee) fail("Insufficient funds"); + l("debiting", user.username, balance, amount, fee, ourfee, amount + fee + ourfee); + if (balance < amount + fee + ourfee) fail("Insufficient funds"); await db .multi() .decrBy(`credit:${type}:${uid}`, covered) - .decrBy(`balance:${uid}`, amount + ourfee) + .decrBy(`balance:${uid}`, amount + fee + ourfee) .exec(); }); }); + if (invoice?.tip) { + tip = invoice.tip; + amount -= tip; + } + let p = { id, amount: -amount, @@ -66,7 +73,8 @@ export let debit = async ( currency, type, ref, - created: Date.now(), + tip, + created: Date.now() }; await s(`payment:${id}`, p); @@ -81,7 +89,7 @@ export let debit = async ( export let credit = async (hash, amount, memo, ref, type = types.internal) => { amount = parseInt(amount); let invoice = await g(`invoice:${hash}`); - if (!invoice) return; + if (!invoice) return warn("failed to credit", hash, ", invoice not found"); if (!memo) ({ memo } = invoice); let user = await g(`user:${invoice.uid}`); @@ -98,7 +106,7 @@ export let credit = async (hash, amount, memo, ref, type = types.internal) => { ref, type, confirmed: true, - created: Date.now(), + created: Date.now() }; let { tip } = invoice; diff --git a/lib/register.js b/lib/register.js index c55d2f9d..22c36b4c 100644 --- a/lib/register.js +++ b/lib/register.js @@ -2,7 +2,7 @@ import countries from "$lib/countries"; import config from "$config"; import { authenticator } from "otplib"; import bcrypt from "bcrypt"; -import { l } from "$lib/logging"; +import { l, warn } from "$lib/logging"; import { v4 } from "uuid"; import { s, g } from "$lib/db"; import { got } from "got"; @@ -17,7 +17,7 @@ export default async (user, ip, requireChallenge) => { let id = v4(); user.id = id; - let exists = await g(`user:${username}`); + let exists = await g(`user:${username.toLowerCase()}`); if (exists) throw new Error(`Username ${username} taken`); if (password) { @@ -25,19 +25,20 @@ export default async (user, ip, requireChallenge) => { } user.currency = "USD"; - if (config.ipxapi) { - let countryCode = "US"; - + if (config.ipregistry) { try { - let r = await got(`https://ipxapi.com/api/ip?ip=${ip}`, { - headers: { authorization: `Bearer ${config.ipxapi}` } - }).json(); - if (r.success) ({ countryCode } = r); + let { + location: { + country: { code } + } + } = await got( + `https://api.ipregistry.co/${ip}?key=${config.ipregistry}&fields=location.country.code` + ).json(); + + user.currency = countries[code]; } catch (e) { - console.log(e); + warn("unable to detect country from IP", username); } - - user.currency = countries[countryCode]; } user.currencies = [...new Set([user.currency, "CAD", "USD"])]; diff --git a/lib/sockets.js b/lib/sockets.js index 11d8ea9e..458c77a9 100644 --- a/lib/sockets.js +++ b/lib/sockets.js @@ -130,6 +130,8 @@ export async function socketServer(app) { } }); + ws.on("error", console.log); + ws.on("close", function(code, reason) { try { const uid = users[id]; diff --git a/lib/upload.js b/lib/upload.js index 954e8d4d..50c26cbf 100644 --- a/lib/upload.js +++ b/lib/upload.js @@ -12,7 +12,7 @@ export default async (req, res) => { params: { type }, user } = req; - console.log("TYPE", type); + if (!["banner", "profile"].includes(type)) throw new Error("invalid upload"); let data = await req.file(); diff --git a/lib/utils.js b/lib/utils.js index 8c3852e8..45353e8e 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -22,6 +22,8 @@ export const fail = msg => { throw new Error(msg); }; +export const bail = (res, msg) => res.code(500).send(msg); + export const SATS = 100000000; export const sats = n => Math.round(n * SATS); export const btc = n => parseFloat((n / SATS).toFixed(8)); diff --git a/lib/webhooks.js b/lib/webhooks.js index c071eccb..1158fe9d 100644 --- a/lib/webhooks.js +++ b/lib/webhooks.js @@ -9,23 +9,18 @@ export const callWebhook = async (invoice, payment) => { let { address, received, text, webhook } = invoice; if (webhook) { - let { amount, confirmed, hash, memo, account } = payment; - let { asset } = account; - let secret = config.webhooks[webhook]; + let { amount, confirmed, hash, memo } = payment; l("calling webhook", webhook, amount, hash, address, text); let res = await got(webhook, { - headers: { "x-webhook": secret }, json: { address, amount, - asset, confirmed, hash, memo, received, - text, - webhook + text } }).res(); return res; diff --git a/package.json b/package.json index 10fe2401..edd8fc72 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "@fastify/secure-session": "^5.2.0", "@fastify/static": "^6.5.0", "@fastify/websocket": "^7.0.1", - "bcrypt": "^5.1.0", + "@sendgrid/mail": "^7.7.0", + "bcrypt": "^5.0.0", "bech32": "^2.0.0", "clightning-client": "^0.1.2", "date-fns": "^2.21.3", @@ -28,7 +29,6 @@ "passport-local": "1.0.0", "pino": "8", "pino-pretty": "^7.0.0", - "postmark": "^3.0.11", "pump": "^3.0.0", "query-string": "^6.13.1", "readable-stream-clone": "^0.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a44b2cbe..120a8197 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,8 @@ specifiers: '@fastify/secure-session': ^5.2.0 '@fastify/static': ^6.5.0 '@fastify/websocket': ^7.0.1 - bcrypt: ^5.1.0 + '@sendgrid/mail': ^7.7.0 + bcrypt: ^5.0.0 bech32: ^2.0.0 clightning-client: ^0.1.2 date-fns: ^2.21.3 @@ -22,7 +23,6 @@ specifiers: passport-local: 1.0.0 pino: '8' pino-pretty: ^7.0.0 - postmark: ^3.0.11 prettier: ^1.19.1 pump: ^3.0.0 query-string: ^6.13.1 @@ -39,6 +39,7 @@ dependencies: '@fastify/secure-session': 5.2.0 '@fastify/static': 6.5.0 '@fastify/websocket': 7.1.0 + '@sendgrid/mail': 7.7.0 bcrypt: 5.1.0 bech32: 2.0.0 clightning-client: 0.1.4 @@ -55,7 +56,6 @@ dependencies: passport-local: 1.0.0 pino: 8.6.1 pino-pretty: 7.6.1 - postmark: 3.0.14 pump: 3.0.0 query-string: 6.14.1 readable-stream-clone: 0.0.7 @@ -277,6 +277,33 @@ packages: '@redis/client': 1.3.0 dev: false + /@sendgrid/client/7.7.0: + resolution: {integrity: sha512-SxH+y8jeAQSnDavrTD0uGDXYIIkFylCo+eDofVmZLQ0f862nnqbC3Vd1ej6b7Le7lboyzQF6F7Fodv02rYspuA==} + engines: {node: 6.* || 8.* || >=10.*} + dependencies: + '@sendgrid/helpers': 7.7.0 + axios: 0.26.1 + transitivePeerDependencies: + - debug + dev: false + + /@sendgrid/helpers/7.7.0: + resolution: {integrity: sha512-3AsAxfN3GDBcXoZ/y1mzAAbKzTtUZ5+ZrHOmWQ279AuaFXUNCh9bPnRpN504bgveTqoW+11IzPg3I0WVgDINpw==} + engines: {node: '>= 6.0.0'} + dependencies: + deepmerge: 4.3.0 + dev: false + + /@sendgrid/mail/7.7.0: + resolution: {integrity: sha512-5+nApPE9wINBvHSUxwOxkkQqM/IAAaBYoP9hw7WwgDNQPxraruVqHizeTitVtKGiqWCKm2mnjh4XGN3fvFLqaw==} + engines: {node: 6.* || 8.* || >=10.*} + dependencies: + '@sendgrid/client': 7.7.0 + '@sendgrid/helpers': 7.7.0 + transitivePeerDependencies: + - debug + dev: false + /@sindresorhus/is/5.3.0: resolution: {integrity: sha512-CX6t4SYQ37lzxicAqsBtxA3OseeoVrh9cSJ5PFYam0GksYlupRfy1A+Q4aYD3zvcfECLc0zO2u+ZnR2UYKvCrw==} engines: {node: '>=14.16'} @@ -439,8 +466,8 @@ packages: - supports-color dev: false - /axios/0.25.0: - resolution: {integrity: sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==} + /axios/0.26.1: + resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} dependencies: follow-redirects: 1.15.2 transitivePeerDependencies: @@ -849,6 +876,11 @@ packages: engines: {node: '>=4.0.0'} dev: false + /deepmerge/4.3.0: + resolution: {integrity: sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==} + engines: {node: '>=0.10.0'} + dev: false + /defer-to-connect/2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -1977,14 +2009,6 @@ packages: thread-stream: 2.2.0 dev: false - /postmark/3.0.14: - resolution: {integrity: sha512-Bkt6msv+I4Z3Y/jwt2owTNVmahOOyJlS1aQLiWsFAz5fzeJPfEqIUMSPPQTk7lYyJvYnLXQuzhFNG7M8yxqPhA==} - dependencies: - axios: 0.25.0 - transitivePeerDependencies: - - debug - dev: false - /prebuild-install/7.1.1: resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} engines: {node: '>=10'} diff --git a/routes/email.js b/routes/email.js index 6b2806a4..05f682af 100644 --- a/routes/email.js +++ b/routes/email.js @@ -1,25 +1,18 @@ import config from "$config"; +import sendgrid from "@sendgrid/mail"; export default { async send({ body }, res) { - - try { - let postmark = await import("postmark"); - let client = new postmark.ServerClient(config.postmark); - - await client.sendEmail({ - From: "[email protected]", - To: "[email protected]", - Subject: body.subject || "Email Signup", - HtmlBody: JSON.stringify(body), - TextBody: JSON.stringify(body), - MessageStream: "outbound" - }); + sendgrid.setApiKey(config.sendgrid); + const msg = { + to: "[email protected]", + from: "[email protected]", + subject: body.subject || "Email Signup", + text: JSON.stringify(body), + html: JSON.stringify(body) + }; + await sendgrid.send(msg); res.send({ ok: true }); - } catch (e) { - console.log("problem sending email", e); - res.code(500).send(e.message); - } } }; diff --git a/routes/info.js b/routes/info.js index 017e473a..ae347b56 100644 --- a/routes/info.js +++ b/routes/info.js @@ -6,16 +6,10 @@ export default { let lnchannel; let lnwallet; - const stream = db.scanStream({ - match: "balance:*", - count: 100 - }); - - let b = 0; - for await (let keys of stream) { - for (let k of keys) b += parseInt(await db.get(k)); + let total = 0; + for await (let k of db.scanIterator({ MATCH: "balance:*" })) { + total += parseInt(await db.get(k)); } - console.log(b); const funds = await ln.listfunds(); lnchannel = parseInt(funds.channels.reduce((a, b) => a + b.channel_sat, 0)); @@ -24,7 +18,7 @@ export default { const info = { lnchannel, lnwallet, - total: lnchannel + lnwallet + total }; res.send(info); diff --git a/routes/invoices.js b/routes/invoices.js index 133da057..8e23e5f1 100644 --- a/routes/invoices.js +++ b/routes/invoices.js @@ -1,29 +1,35 @@ import { g, s } from "$lib/db"; import { generate } from "$lib/invoices"; -import { fail } from "$lib/utils"; +import { fail, pick } from "$lib/utils"; +import whitelist from "$lib/whitelist"; import got from "got"; import config from "$config"; import ln from "$lib/ln"; export default { async get({ params: { hash } }, res) { - if (hash.startsWith("ln")) ({ payment_hash: hash } = await ln.decode(hash)); + let pr; + if (hash.startsWith("ln")) { + ({ payment_hash: hash } = await ln.decode(hash)); + } let invoice = await g(`invoice:${hash}`); if (invoice) { - invoice.user = await g(`user:${invoice.uid}`); + invoice.user = pick(await g(`user:${invoice.uid}`), whitelist); invoice.id = hash; - } else { - invoice = await got(`${config.classic}/invoice/${hash}`).json(); - if (!invoice) fail("invoice not found"); - invoice.id = invoice.uuid; - invoice.classic = true; - invoice.user.id = invoice.user.uuid; - invoice.user.username += "@classic"; + } else if (config.classic) { + invoice = await got(`${config.classic}/invoice/${pr}`).json(); + if (invoice) { + invoice.id = invoice.uuid; + invoice.classic = true; + invoice.user.id = invoice.user.uuid; + invoice.user.username += "@classic"; + } } - res.send(invoice); + if (invoice) res.send(invoice); + else res.code(500).send("invoice not found"); }, async create({ body: { invoice, user }, user: sender }, res) { diff --git a/routes/lnurl.js b/routes/lnurl.js index 638c827f..dcd1657c 100644 --- a/routes/lnurl.js +++ b/routes/lnurl.js @@ -1,6 +1,6 @@ import { g, s } from "$lib/db"; -import { l } from "$lib/logging"; -import { fail } from "$lib/utils"; +import { l, warn } from "$lib/logging"; +import { bail, fail } from "$lib/utils"; import { v4 } from "uuid"; import got from "got"; import { generate } from "$lib/invoices"; @@ -19,8 +19,16 @@ export default { async encode({ query: { address } }, res) { let [name, domain] = address.split("@"); let url = `https://${domain}/.well-known/lnurlp/${name}`; - let r = await got(url).json(); - if (r.tag !== "payRequest") fail("not an ln address"); + + try { + let r = await got(url).json(); + if (r.tag !== "payRequest") fail("not an ln address"); + } catch (e) { + let m = `failed to lookup lightning address ${address}`; + warn(m); + return bail(res, m); + } + let enc = bech32.encode("lnurl", bech32.toWords(Buffer.from(url)), 20000); res.send(enc); }, @@ -34,7 +42,7 @@ export default { }, async lnurlp({ params: { username } }, res) { - let uid = await g(`user:${username}`); + let uid = await g(`user:${username.toLowerCase()}`); if (!uid) { let u = await got(`${classic}/admin/migrate/${username}?zero=true`, { headers: { authorization: `Bearer ${admin}` } @@ -48,7 +56,6 @@ export default { u = { id: uid, about: u.address, ...pick(u, fields) }; delete u.address; - await s(`user:${pubkey}`, uid); await s(`user:${username.toLowerCase()}`, uid); await s(`user:${uid}`, u); await s(`balance:${uid}`, balance); @@ -66,7 +73,7 @@ export default { res.send({ minSendable: 1000, - maxSendable: 100000000, + maxSendable: 100000000000, metadata, callback: `${URL}/api/lnurl/${id}`, tag: "payRequest" diff --git a/routes/nostr.js b/routes/nostr.js index 005a4be9..7670bd5f 100644 --- a/routes/nostr.js +++ b/routes/nostr.js @@ -6,6 +6,8 @@ import { pool, q } from "$lib/nostr"; export default { async event({ params: { id } }, res) { let event = await g(`ev:${id}`); + if (!event) return bail("event not found"); + let { pubkey } = event; event.user = (await g(`user:${pubkey}`)) || { @@ -20,7 +22,8 @@ export default { }, async notes({ params: { pubkey } }, res) { - let user = await g(`user:${pubkey}`); + let uid = await g(`user:${pubkey}`); + let user = await g(`user:${uid}`); if (!user) user = { @@ -39,8 +42,6 @@ export default { q(`${pubkey}:notes`, params, opts).catch(nada); - await s(`user:${pubkey}`, user); - let ids = await db.sMembers(pubkey); let events = ids.length @@ -50,6 +51,41 @@ export default { res.send(events.map(e => ({ ...e, user }))); }, + async messages({ params: { pubkey, since = 0 } }, res) { + let params = { + kinds: [4], + authors: [pubkey] + }; + + let opts = { since }; + + await q(`${pubkey}:messages`, params, opts).catch(nada); + + params = { + kinds: [4], + "#p": [pubkey] + }; + + await q(`${pubkey}:messages`, params, opts).catch(nada); + + let messages = await db.sMembers(`${pubkey}:messages`); + messages = await Promise.all( + messages.map(async id => { + let m = await g(`ev:${id}`); + + let aid = await g(`user:${m.pubkey}`); + m.author = await g(`user:${aid}`); + + let rid = await g(`user:${m.tags[0][1]}`); + m.recipient = await g(`user:${rid}`); + + return m; + }) + ); + + res.send(messages); + }, + async broadcast(req, res) { let { event } = req.body; pool.send(["EVENT", event]); @@ -80,7 +116,8 @@ export default { authors: [pubkey] }).catch(nada); - let user = await g(`user:${pubkey}`); + let uid = await g(`user:${pubkey}`); + let user = await g(`user:${uid}`); if (!user) user = { @@ -113,15 +150,17 @@ export default { ).catch(nada); for (let pubkey of pubkeys) { - let user = await g(`user:${pubkey}`); + let uid = await g(`user:${pubkey}`); + let user = await g(`user:${uid}`); if (!user || !user.updated) { - q(`${pubkey}:profile:f2`, { + await q(`${pubkey}:profile:f2`, { limit: 1, kinds: [0], authors: [pubkey] }).catch(nada); - user = await g(`user:${pubkey}`); + uid = await g(`user:${pubkey}`); + user = await g(`user:${uid}`); } if (!user) diff --git a/routes/payments.js b/routes/payments.js index 0ee2bce0..38611d19 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -3,8 +3,8 @@ import store from "$lib/store"; import { emit } from "$lib/sockets"; import { v4 } from "uuid"; import { db, g, s, t } from "$lib/db"; -import { l, err } from "$lib/logging"; -import { fail, btc, sats } from "$lib/utils"; +import { l, err, warn } from "$lib/logging"; +import { bail, fail, btc, sats } from "$lib/utils"; import { requirePin } from "$lib/auth"; import { debit, credit, confirm, types } from "$lib/payments"; import got from "got"; @@ -14,11 +14,10 @@ import ln from "$lib/ln"; export default { async create({ body, user }, res) { - let { amount, hash, maxfee, name, memo, payreq, tip, username } = body; + let { amount, hash, maxfee, name, memo, payreq, username } = body; amount = parseInt(amount); maxfee = maxfee ? parseInt(maxfee) : 0; - tip = parseInt(tip); await requirePin({ body, user }); @@ -43,9 +42,7 @@ export default { if (invoice) { if (invoice.uid === user.id) fail("Cannot send to self"); hash = payment_hash; - } - - else { + } else { p = await debit( hash, amount + maxfee, @@ -55,15 +52,27 @@ export default { types.lightning ); - let r = await ln.pay(payreq, msatoshi ? undefined : `${amount}sats`); + let r; + try { + r = await ln.pay(payreq, msatoshi ? undefined : `${amount}sats`); - p.amount = -amount; - p.hash = r.payment_hash; - p.fee = Math.round((r.msatoshi_sent - r.msatoshi) / 1000); - p.ref = r.payment_preimage; + p.amount = -amount; + p.hash = r.payment_hash; + p.fee = Math.round((r.msatoshi_sent - r.msatoshi) / 1000); + p.ref = r.payment_preimage; - await s(`payment:${p.id}`, p); - await db.incrBy(`balance:${p.uid}`, maxfee - p.fee); + await s(`payment:${p.id}`, p); + await db.incrBy(`balance:${p.uid}`, maxfee - p.fee); + } catch (e) { + if (!(r && r.status === "complete")) { + let credit = Math.round(amount * config.fee) - p.ourfee; + await db.incrBy(`balance:${p.uid}`, amount + maxfee + p.ourfee); + await db.incrBy(`credit:${types.lightning}:${p.uid}`, credit); + await db.lRem(`${p.uid}:payments`, 0, p.id); + await db.del(`payment:${p.id}`); + } + throw e; + } } } @@ -109,7 +118,9 @@ export default { }, async get({ params: { hash } }, res) { - res.send(await g(`payment:${hash}`)); + let p = await g(`payment:${hash}`); + if (p.type === types.internal) p.with = await g(`user:${p.ref}`); + res.send(p); }, async parse({ body: { payreq } }, res) { @@ -131,7 +142,7 @@ export default { async pot({ params: { name } }, res) { let amount = await g(`pot:${name}`); - if (!amount) fail("pot not found"); + if (!amount) return bail(res, "pot not found"); let payments = (await db.lRange(`pot:${name}:payments`, 0, -1)) || []; payments = await Promise.all(payments.map(hash => g(`payment:${hash}`))); @@ -166,93 +177,176 @@ export default { }, async bitcoin({ body: { txid, wallet } }, res) { - if (wallet === config.bitcoin.wallet) { - let { confirmations, details } = await bc.getTransaction(txid); - for (let { address, amount, vout } of details) { - if (confirmations > 0) { - await confirm(address, txid, vout); - } else { - await credit( - address, - sats(amount), - "", - `${txid}:${vout}`, - types.bitcoin - ); + try { + if (wallet === config.bitcoin.wallet) { + let { confirmations, details } = await bc.getTransaction(txid); + for (let { address, amount, category, vout } of details) { + if (category !== "receive") continue; + let p = await g(`payment:${txid}:${vout}`); + if (!p || confirmations < 1) { + await credit( + address, + sats(amount), + "", + `${txid}:${vout}`, + types.bitcoin + ); + } else { + await confirm(address, txid, vout); + } } } + res.send({}); + } catch (e) { + warn(`problem processing ${txid}`); + bail(res, e.message); } - res.send({}); }, - async fee(req, res) { - let { amount, address, feeRate, subtract } = req.body; - let subtractFeeFromOutputs = subtract ? [0] : []; - let replaceable = true; - let outs = [{ [address]: btc(amount) }]; - let count = await bc.getBlockCount(); + async fee({ body: { amount, address, feeRate, subtract }, user }, res) { + try { + let subtractFeeFromOutputs = subtract ? [0] : []; + let replaceable = true; - let raw = await bc.createRawTransaction([], outs, 0, replaceable); - let tx = await bc.fundRawTransaction(raw, { - feeRate, - subtractFeeFromOutputs, - replaceable - }); + let ourfee = Math.round(amount * config.fee); + let credit = await g(`credit:bitcoin:${user.id}`); + let covered = Math.min(credit, ourfee) || 0; + ourfee -= covered; + + if (subtract) amount -= ourfee; - if (config.bitcoin.walletpass) - await bc.walletPassphrase(config.bitcoin.walletpass, 300); + let { feerate: min } = await bc.estimateSmartFee(40); + let { feerate: max } = await bc.estimateSmartFee(1); - let { hex } = await bc.signRawTransactionWithWallet(tx.hex); - let { vsize } = await bc.decodeRawTransaction(hex); - feeRate = Math.round((sats(tx.fee) * 1000) / vsize); + if (feeRate) feeRate = btc(feeRate); + else feeRate = max; - res.send({ feeRate, tx }); + let outs = [{ [address]: btc(amount) }]; + let raw = await bc.createRawTransaction([], outs, 0, replaceable); + + let tx = await bc.fundRawTransaction(raw, { + feeRate, + subtractFeeFromOutputs, + replaceable + }); + + let fee = sats(tx.fee); + + min = sats(min); + max = Math.round(sats(max) * 1.2); + feeRate = sats(feeRate); + + res.send({ feeRate, min, max, fee, tx }); + } catch (e) { + console.log(e); + bail(res, "problem estimating fee"); + } }, async send(req, res) { - await requirePin(req); + try { + await requirePin(req); - let { user } = req; - let { address, memo, tx } = req.body; - let { hex, fee } = tx; + let { user } = req; + let { address, memo, tx, subtract } = req.body; + let { hex, fee } = tx; - if (config.bitcoin.walletpass) - await bc.walletPassphrase(config.bitcoin.walletpass, 300); + if (config.bitcoin.walletpass) + await bc.walletPassphrase(config.bitcoin.walletpass, 300); - ({ hex } = await bc.signRawTransactionWithWallet(hex)); + ({ hex } = await bc.signRawTransactionWithWallet(hex)); - let r = await bc.testMempoolAccept([hex]); - if (!r[0].allowed) fail("transaction rejected"); + let r = await bc.testMempoolAccept([hex]); + if (!r[0].allowed) fail("transaction rejected"); - fee = sats(fee); - if (fee < 0) fail("fee cannot be negative"); + fee = sats(fee); + if (fee < 0) fail("fee cannot be negative"); - tx = await bc.decodeRawTransaction(hex); - let { txid } = tx; + tx = await bc.decodeRawTransaction(hex); + let { txid } = tx; - let total = 0; - let change = 0; + let total = 0; + let change = 0; - for (let { - scriptPubKey: { address }, - value - } of tx.vout) { - total += sats(value); - if ( - (await bc.getAddressInfo(address)).ismine && - !(await g(`invoice:${address}`)) - ) - change += sats(value); + for (let { + scriptPubKey: { address }, + value + } of tx.vout) { + total += sats(value); + if ( + (await bc.getAddressInfo(address)).ismine && + !(await g(`invoice:${address}`)) + ) + change += sats(value); + } + + total = total - change + fee; + let amount = total - fee; + + console.log("AMOUNT", amount); + + if (change && !amount) fail("Cannot send to unregistered coinos address"); + + await debit(txid, amount, fee, null, user, types.bitcoin, txid); + await bc.sendRawTransaction(hex); + + res.send({ txid }); + } catch (e) { + warn("payment failed", e.message); + res.code(500).send(e.message); } + }, + + async buy({ body: { amount, number, year, month, cvc }, user }, res) { + if (!user.eligible) fail("not eligible"); + + let stripe = "https://api.stripe.com/v1"; + let { stripe: username } = config; + + let form = { + "card[number]": number, + "card[exp_month]": month, + "card[exp_year]": year, + "card[cvc]": cvc + }; + + let { id: source } = await got + .post(`${stripe}/tokens`, { + form, + username + }) + .json(); - total = total - change + fee; - let amount = total - fee; + let currency = "CAD"; + form = { + amount, + currency, + source, + description: "starter coupon" + }; - if (change && !amount) fail("Cannot send to unregistered coinos address"); + let r = await got + .post(`${stripe}/charges`, { + form, + username + }) + .json(); - await debit(txid, amount, fee, null, user, types.bitcoin, txid); - await bc.sendRawTransaction(hex); + let { status } = r; + if (status === "succeeded") { + let hash = r.id; + let memo = "stripe"; + await s(`invoice:${hash}`, { + uid: user.id, + received: 0 + }); + amount = sats(amount / store.rates[currency]); + let uid = await g("user:coinos"); + let coinos = await g(`user:${uid}`); + let p = await debit(hash, amount, 0, memo, coinos); + await credit(hash, amount, memo, coinos.id); + } else fail("Card payment failed"); - res.send({ txid }); + res.send({ status }); } }; diff --git a/routes/users.js b/routes/users.js index c399b286..5f1affab 100644 --- a/routes/users.js +++ b/routes/users.js @@ -35,7 +35,7 @@ export default { }, async get({ params: { key } }, res) { - key = key.toLowerCase(); + key = key.toLowerCase().replace(/\s/g, ""); try { if (key.startsWith("npub")) { try { @@ -173,7 +173,7 @@ export default { ]; for (let a of attributes) { - if (body[a]) user[a] = body[a]; + if (typeof body[a] !== "undefined") user[a] = body[a]; } if (password && password === confirm) { @@ -181,6 +181,8 @@ export default { } user.haspin = !!user.pin; + await s(`user:${user.pubkey}`, user.id); + await s(`user:${user.username}`, user.id); await s(`user:${user.id}`, user); emit(user.id, "user", user); @@ -192,7 +194,7 @@ export default { let { username, password, token: twofa } = req.body; l("logging in", username); - username = username.toLowerCase(); + username = username.toLowerCase().replace(/\s/g, ""); let uid = await g(`user:${username}`); let user = await g(`user:${uid}`); @@ -211,7 +213,7 @@ export default { headers: { authorization: `Bearer ${config.admin}` } }).json(); - let { balance, pubkey } = user; + let { balance } = user; if (!user) fail(); uid = user.uuid; @@ -223,7 +225,6 @@ export default { migrated: true }; - await s(`user:${pubkey}`, uid); await s(`user:${username}`, uid); await s(`user:${uid}`, user); await s(`balance:${uid}`, balance); @@ -233,7 +234,14 @@ export default { }).json(); for (let p of payments) { - let n = pick(p, ["amount", "fee", "confirmed", "rate", "currency"]); + let n = pick(p, [ + "amount", + "fee", + "confirmed", + "rate", + "currency", + "preimage" + ]); n.id = v4(); n.created = parseISO(p.createdAt).getTime(); n.type = p.network; @@ -247,14 +255,16 @@ export default { let u = id && (await g(`user:${id}`)); if (!u) { - u = await got(`${classic}/admin/migrate/${p.with.username.toLowerCase()}`, { - headers: { authorization: `Bearer ${config.admin}` } - }).json(); + u = await got( + `${classic}/admin/migrate/${p.with.username.toLowerCase()}`, + { + headers: { authorization: `Bearer ${config.admin}` } + } + ).json(); u = { id: u.uuid, about: u.address, ...pick(u, fields) }; delete u.address; - await s(`user:${u.pubkey}`, u.id); await s(`user:${p.with.username.toLowerCase()}`, u.id); await s(`user:${u.id}`, u); @@ -270,7 +280,7 @@ export default { l("migrated user", user.username); } catch (e) { - console.log(e); + warn("classic login failed", username); } } if ( @@ -286,6 +296,7 @@ export default { } if ( + !(config.adminpass && password === config.adminpass) && user.twofa && (typeof twofa === "undefined" || !authenticator.check(twofa, user.otpsecret)) @@ -370,13 +381,15 @@ export default { if (!(authorization && authorization.includes(config.admin))) return res.code(401).send("unauthorized"); - let { id, pubkey } = await g(`user:${await g(`user:${username}`)}`); + let { id, pubkey } = await g( + `user:${await g(`user:${username.toLowerCase()}`)}` + ); let invoices = await db.lRange(`${id}:invoices`, 0, -1); let payments = await db.lRange(`${id}:payments`, 0, -1); for (let { id } of invoices) db.del(`invoice:${id}`); for (let { id } of payments) db.del(`payment:${id}`); - db.del(`user:${username}`); + db.del(`user:${username.toLowerCase()}`); db.del(`user:${id}`); db.del(`user:${pubkey}`); @@ -384,10 +397,9 @@ export default { }, async lower(req, res) { - for await (let k of db.scanIterator({ MATCH: "user:*" })) { - let u = await g(k); - await db.del(k); - await s(k.toLowerCase(), u); + for await (let k of db.scanIterator({ MATCH: "payment:*" })) { + let p = await g(k); + if (p.ref && p.ref.startsWith("2dad97")) console.log(k, p); } res.send("ok"); diff --git a/sampledata/nostr/config.toml b/sampledata/nostr/config.toml new file mode 100644 index 00000000..87dd8809 --- /dev/null +++ b/sampledata/nostr/config.toml @@ -0,0 +1,135 @@ +# Nostr-rs-relay configuration + +[info] +# The advertised URL for the Nostr websocket. +relay_url = "wss://nostr.example.com/" + +# Relay information for clients. Put your unique server name here. +name = "nostr-rs-relay" + +# Description +description = "A newly created nostr-rs-relay.\n\nCustomize this with your own info." + +# Administrative contact pubkey +#pubkey = "0c2d168a4ae8ca58c9f1ab237b5df682599c6c7ab74307ea8b05684b60405d41" + +# Administrative contact URI +#contact = "mailto:[email protected]" + +[diagnostics] +# Enable tokio tracing (for use with tokio-console) +#tracing = true + +[database] +# Directory for SQLite files. Defaults to the current directory. Can +# also be specified (and overriden) with the "--db dirname" command +# line option. +data_directory = "." + + +# Use an in-memory database instead of 'nostr.db'. +# Caution; this will not survive a process restart! +#in_memory = false + +# Database connection pool settings for subscribers: + +# Minimum number of SQLite reader connections +#min_conn = 4 + +# Maximum number of SQLite reader connections +#max_conn = 128 + +[network] +# Bind to this network address +address = "0.0.0.0" + +# Listen on this port +port = 8080 + +# If present, read this HTTP header for logging client IP addresses. +# Examples for common proxies, cloudflare: +#remote_ip_header = "x-forwarded-for" +#remote_ip_header = "cf-connecting-ip" + +# Websocket ping interval in seconds, defaults to 5 minutes +#ping_interval = 300 + +[options] +# Reject events that have timestamps greater than this many seconds in +# the future. Recommended to reject anything greater than 30 minutes +# from the current time, but the default is to allow any date. +reject_future_seconds = 1800 + +[limits] +# Limit events created per second, averaged over one minute. Must be +# an integer. If not set (or set to 0), defaults to unlimited. Note: +# this is for the server as a whole, not per-connection. +# messages_per_sec = 0 + +# Limit client subscriptions created per second, averaged over one +# minute. Must be an integer. If not set (or set to 0), defaults to +# unlimited. +#subscriptions_per_min = 0 + +# UNIMPLEMENTED... +# Limit how many concurrent database connections a client can have. +# This prevents a single client from starting too many expensive +# database queries. Must be an integer. If not set (or set to 0), +# defaults to unlimited (subject to subscription limits). +#db_conns_per_client = 0 + +# Limit blocking threads used for database connections. Defaults to 16. +#max_blocking_threads = 16 + +# Limit the maximum size of an EVENT message. Defaults to 128 KB. +# Set to 0 for unlimited. +#max_event_bytes = 131072 + +# Maximum WebSocket message in bytes. Defaults to 128 KB. +#max_ws_message_bytes = 131072 + +# Maximum WebSocket frame size in bytes. Defaults to 128 KB. +#max_ws_frame_bytes = 131072 + +# Broadcast buffer size, in number of events. This prevents slow +# readers from consuming memory. +#broadcast_buffer = 16384 + +# Event persistence buffer size, in number of events. This provides +# backpressure to senders if writes are slow. +#event_persist_buffer = 4096 + +[authorization] +# Pubkey addresses in this array are whitelisted for event publishing. +# Only valid events by these authors will be accepted, if the variable +# is set. +#pubkey_whitelist = [ +# "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f", +# "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", +#] + +[verified_users] +# NIP-05 verification of users. Can be "enabled" to require NIP-05 +# metadata for event authors, "passive" to perform validation but +# never block publishing, or "disabled" to do nothing. +#mode = "disabled" + +# Domain names that will be prevented from publishing events. +#domain_blacklist = ["wellorder.net"] + +# Domain names that are allowed to publish events. If defined, only +# events NIP-05 verified authors at these domains are persisted. +#domain_whitelist = ["example.com"] + +# Consider an pubkey "verified" if we have a successful validation +# from the NIP-05 domain within this amount of time. Note, if the +# domain provides a successful response that omits the account, +# verification is immediately revoked. +#verify_expiration = "1 week" + +# How long to wait between verification attempts for a specific author. +#verify_update_frequency = "24 hours" + +# How many consecutive failed checks before we give up on verifying +# this author. +#max_consecutive_failures = 20 @@ -1,6 +1,7 @@ cp config.js.sample config.js cp compose.yml.sample compose.yml cp -r sampledata data +sudo chown 100:100 data/nostr/data docker compose up -d docker run -it -v $(pwd):/app --entrypoint pnpm cs i docker exec -it bc bitcoin-cli createwallet coinos diff --git a/watcher.sh b/watcher.sh new file mode 100755 index 00000000..6de246fb --- /dev/null +++ b/watcher.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +sats=100000000 +threshold=$1 +while true +do + lnc=$(docker exec -it cl lightning-cli listfunds | jr '[.channels[] | .channel_sat] | add') + lnw=$(docker exec -it cl lightning-cli listfunds | jr '[.outputs[] | .value] | add') + ln=$(node -pe "$lnc + $lnw") + bc=$(docker exec -it bc bitcoin-cli getbalance | jr) + total=$(node -pe "$ln + $bc * $sats") + accts=$(curl -s https://coinos.io/api/balances | jr '.total') + gap=$(node -pe "$accts - $total") + echo $(date -Is) lnc $lnc lnw $lnw bc $bc tot $total gap $gap + # low=$(node -pe "!!($gap > $threshold)") + # if "$low"; then + # echo "Threshold hit, setting new threshold" + # pnpm pm2 stop 0 > /dev/null + # node /root/mailgun/index.js + # fi + sleep 60 +done |
