diff options
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/invoice.ts | 2 | ||||
| -rw-r--r-- | src/lib/locales.ts | 62 | ||||
| -rw-r--r-- | src/lib/nostr.ts | 42 | ||||
| -rw-r--r-- | src/lib/parse.ts | 50 | ||||
| -rw-r--r-- | src/lib/random.ts | 20 | ||||
| -rw-r--r-- | src/lib/samples.ts | 14 | ||||
| -rw-r--r-- | src/lib/server/ip.ts | 2 | ||||
| -rw-r--r-- | src/lib/settings.ts | 2 | ||||
| -rw-r--r-- | src/lib/socket.ts | 8 | ||||
| -rw-r--r-- | src/lib/utils.ts | 123 |
10 files changed, 255 insertions, 70 deletions
diff --git a/src/lib/invoice.ts b/src/lib/invoice.ts index d7576e82..91a69d88 100644 --- a/src/lib/invoice.ts +++ b/src/lib/invoice.ts @@ -15,7 +15,7 @@ export default async ({ cookies, request }) => { items: JSON.parse(form.get("items")), memo: form.get("memo"), memoPrompt: form.get("memoPrompt"), - tip: parseInt(form.get("tip")) || 0, + tip: form.get("tip") ?? null, type: form.get("type"), prompt: form.get("prompt") === "true", rate: parseFloat(form.get("rate")) || rates[form.get("currency")], diff --git a/src/lib/locales.ts b/src/lib/locales.ts index b87ecc7a..68a28abd 100644 --- a/src/lib/locales.ts +++ b/src/lib/locales.ts @@ -1,23 +1,41 @@ -import { - de, - el, - enUS as en, - es, - faIR as fa, - fr, - ja, - pt, - ru, - zhCN as zh, - arSA as ar, - hiIN as hi, - bnBD as bn, - tr, - ko, - it, - nl, - th, - pl -} from "date-fns/locale/index.js"; +import de from "date-fns/locale/de"; +import el from "date-fns/locale/el"; +import en from "date-fns/locale/en-US"; +import es from "date-fns/locale/es"; +import fa from "date-fns/locale/fa-IR"; +import fr from "date-fns/locale/fr"; +import ja from "date-fns/locale/ja"; +import pt from "date-fns/locale/pt"; +import ru from "date-fns/locale/ru"; +import zh from "date-fns/locale/zh-CN"; +import ar from "date-fns/locale/ar-SA"; +import hi from "date-fns/locale/hi"; +import bn from "date-fns/locale/bn"; +import tr from "date-fns/locale/tr"; +import ko from "date-fns/locale/ko"; +import it from "date-fns/locale/it"; +import nl from "date-fns/locale/nl"; +import th from "date-fns/locale/th"; +import pl from "date-fns/locale/pl"; -export default { de, el, en, es, fa, fr, ja, pt, ru, zh, ar, hi, bn, tr, ko, it, nl, th, pl }; +export default { + de, + el, + en, + es, + fa, + fr, + ja, + pt, + ru, + zh, + ar, + hi, + bn, + tr, + ko, + it, + nl, + th, + pl +}; diff --git a/src/lib/nostr.ts b/src/lib/nostr.ts index bfaa14c1..2e993f9d 100644 --- a/src/lib/nostr.ts +++ b/src/lib/nostr.ts @@ -8,7 +8,7 @@ import { signer as $signer, } from "$lib/store"; import { post, wait } from "$lib/utils"; -import { bytesToHex } from "@noble/hashes/utils"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { type EventTemplate, finalizeEvent, @@ -20,6 +20,7 @@ import { import { decrypt as nip44decrypt, getConversationKey } from "nostr-tools/nip44"; import { Relay } from "nostr-tools/relay"; import { get } from "svelte/store"; +import cookies from "js-cookie"; import { decrypt as nip49decrypt, @@ -72,12 +73,22 @@ export const getPrivateKey = async (user: User): Promise<Uint8Array> => { } } - const { nsec } = user; + // /me no longer returns the encrypted key; /login decrypts it into the + // sk cookie instead. user.nsec is only present on a fresh login response. + // Sessions without either (nostr or older logins) fetch it with the + // password. + const sk = browser && cookies.get("sk"); + let { nsec } = user; - if (nsec) { + if (!sk && !nsec && user.hasnsec) + ({ nsec } = await post("/nsec", { password: await getPassword() })); + + if (sk) { + k = hexToBytes(sk); + } else if (nsec) { k = nip49decrypt(nsec, await getPassword()); } else { - throw new Error("nsec not available"); + throw new Error("Log in again with your password to unlock your nostr key"); } localStorage.setItem("nsec", nip19.nsecEncode(k)); @@ -105,7 +116,7 @@ export const encryptNsec = async (nsec: string) => { }; export const sign = async (event, user) => { - if (user?.nsec && !get($signer)?.ready) { + if ((user?.hasnsec || user?.nsec) && !get($signer)?.ready) { const sk = await getPrivateKey(user); $signer.set({ @@ -117,8 +128,14 @@ export const sign = async (event, user) => { eventToSign.set(event); let unsubscribe; - const { method, params } = await new Promise((r) => { - unsubscribe = $signer.subscribe((v) => v?.ready && r(v)); + const { method, params } = await new Promise((r, j) => { + unsubscribe = $signer.subscribe((v) => { + if (v === "cancel") { + j("cancelled"); + $signer.set(undefined); + } + v?.ready && r(v); + }); }); await unsubscribe(); const signedEvent = await signingMethods[method](event, params); @@ -148,7 +165,8 @@ const signingMethods = { params: [JSON.stringify(event)], }; - const content = await nip04.encrypt(sk, pk, JSON.stringify(signEvent)); + const skBytes = hexToBytes(sk); + const content = await nip04.encrypt(skBytes, pk, JSON.stringify(signEvent)); const signedSignEvent = finalizeEvent( { @@ -158,7 +176,7 @@ const signingMethods = { content, tags: [["p", pk]], }, - sk, + skBytes, ); const relay = await Relay.connect(nostrConnectRelay); @@ -171,9 +189,9 @@ const signingMethods = { try { let response; try { - response = JSON.parse(await nip04.decrypt(sk, pk, event.content)); + response = JSON.parse(await nip04.decrypt(hexToBytes(sk), pk, event.content)); } catch (e) { - const ck = await getConversationKey(sk, pk); + const ck = await getConversationKey(hexToBytes(sk), pk); response = JSON.parse(await nip44decrypt(event.content, ck)); } @@ -187,7 +205,7 @@ const signingMethods = { }, async nsec(event, { sk }) { - return finalizeEvent(event, sk); + return finalizeEvent(event, hexToBytes(sk)); }, async extension(event) { diff --git a/src/lib/parse.ts b/src/lib/parse.ts index ba264be7..e8d8bafd 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -1,20 +1,36 @@ import { PUBLIC_DOMAIN } from "$env/static/public"; import { decode } from "$lib/bip21"; -import { get, isLiquid, post, sats } from "$lib/utils"; +import { auth, get, isLiquid, post, sats } from "$lib/utils"; import { redirect } from "@sveltejs/kit"; import { validate } from "bitcoin-address-validation"; -export default async (s, host) => { +export default async (s, host, cookies) => { if (!s) return; let t = s.trim(); let amount; + let invoice; let user; if (t.startsWith("http")) redirect(307, t); if (t.startsWith(host)) redirect(307, `http://${t}`); + if (t.includes("lightning=") || t.includes("lno=")) { + const url = new URL(t); + const params = new URLSearchParams(url.search); + t = params.get("lightning") || params.get("lno"); + } + if (t.startsWith("lightning:")) t = t.replace("lightning:", ""); - if (t.endsWith(`@${PUBLIC_DOMAIN}`)) t = t.split("@")[0]; + // Our own domain is no longer only ours: coinos v3 payment addresses live + // here too and are paid over lightning like anyone else's. Only strip the + // domain (and pay internally) when the name really is a local account. + if (t.includes("@") && t.split("@").pop() === PUBLIC_DOMAIN) { + const name = t.split("@")[0]; + try { + const u = await get(`/users/${name}`); + if (u && !u.anon) t = name; + } catch (e) {} + } if (t.includes("@") && t.includes(".")) { try { t = await get(`/encode?address=${t}`); @@ -51,16 +67,12 @@ export default async (s, host) => { if (t.toLowerCase().startsWith("lnurl")) redirect(307, `/ln/${t}`); if (t.includes(":")) t = t.split(":")[1]; - if (t.includes("lightning=")) { - const url = new URL(t); - const params = new URLSearchParams(url.search); - t = params.get("lightning"); - } - - if (t.toLowerCase().startsWith("ln")) { + if (t.toLowerCase().startsWith("lnr")) { + const { iid } = await post("/sendinvoice", { invreq: t }, auth(cookies)); + invoice = { id: iid }; + } else if (t.toLowerCase().startsWith("ln")) { try { - const inv = await get(`/invoice/${t}`); - if (inv.user.username === "mint") throw new Error("mint payment"); + invoice = await get(`/invoice/${t}`); } catch (e) { if (t.toLowerCase().startsWith("lno")) { const { offer_amount_msat: a } = await get(`/decode/${t}`); @@ -70,6 +82,8 @@ export default async (s, host) => { } } + if (invoice) redirect(307, `/invoice/${invoice.id}`); + if (t.match(/^🥜([\uFE00-\uFE0F]|[\uE0100-\uE01EF]+)$/)) { t = Array.from(t) .slice(1) @@ -91,10 +105,8 @@ export default async (s, host) => { .join(""); } - if (t.startsWith("cashu")) { - const { id } = await post("/cash", { token: t }); - redirect(307, `/ecash/${id}`); - } + // The token rides in the URL; the ecash page asks the server for its status. + if (t.startsWith("cashu")) redirect(307, `/ecash/${encodeURIComponent(t)}`); if (t.startsWith("creq")) redirect(307, `/send/ecash/${t}`); @@ -115,11 +127,9 @@ export default async (s, host) => { if (fund) redirect(307, `/send/fund/${t}`); - // invoice - let invoice; try { - invoice = await get(`/invoice/${t}`); + invoice ||= await get(`/invoice/${t}`); } catch (e) {} - if (invoice) redirect(307, `/send/invoice/${invoice.id}`); + if (invoice) redirect(307, `/invoice/${invoice.id}`); }; diff --git a/src/lib/random.ts b/src/lib/random.ts new file mode 100644 index 00000000..36dde0cc --- /dev/null +++ b/src/lib/random.ts @@ -0,0 +1,20 @@ +import { + NumberDictionary, + animals, + colors, + uniqueNamesGenerator, +} from "unique-names-generator"; + +export const randomName = () => + uniqueNamesGenerator({ + dictionaries: [animals, NumberDictionary.generate({ min: 10, max: 99 })], + length: 2, + separator: "", + }); + +export const randomPassword = () => + uniqueNamesGenerator({ + dictionaries: [colors, NumberDictionary.generate({ min: 100, max: 999 })], + length: 2, + separator: "", + }); diff --git a/src/lib/samples.ts b/src/lib/samples.ts index ae7ef6d5..cc6b4039 100644 --- a/src/lib/samples.ts +++ b/src/lib/samples.ts @@ -29,6 +29,20 @@ export default { -d '{ "payreq": "lnbc1pj94d8fsp5n77k340ps4m9jn7kp8he8yynpddvurv6mcsrrqpnq5l2jxdxzlwqpp5m96pqhc5nrlk8cqsu9ufdxxa43sarp8vwf9egvm2pg9nl0zu9r8qdq2vdhkjmn0wvxqztgcqpjrzjqwhmav82kntsppmkp8jp4vg4h9nns78tsy8mg7ve4lq5txrkp0h56zlarvqqdtcqqsqqqqqqqqqqp6cq9q9qyysgqzxlypywzphyujm3ga5j5csfcmqvlnae0fgnvymkaaw94eeg7py5rqzysyjkr3ev2snq63qpsc69vf54adkd0szvmvwt5cuadjnuy95sq4xfa40" }'`, + quote: `curl "${api}/lightning/quote" + -H "content-type: application/json" + -H "Authorization: Bearer $token" + -d '{ + "payreq": "lnbc1pj94d8fsp5n77k340ps4m9jn7kp8he8yynpddvurv6mcsrrqpnq5l2jxdxzlwqpp5m96pqhc5nrlk8cqsu9ufdxxa43sarp8vwf9egvm2pg9nl0zu9r8qdq2vdhkjmn0wvxqztgcqpjrzjqwhmav82kntsppmkp8jp4vg4h9nns78tsy8mg7ve4lq5txrkp0h56zlarvqqdtcqqsqqqqqqqqqqp6cq9q9qyysgqzxlypywzphyujm3ga5j5csfcmqvlnae0fgnvymkaaw94eeg7py5rqzysyjkr3ev2snq63qpsc69vf54adkd0szvmvwt5cuadjnuy95sq4xfa40", + "max": true +}'`, + quoteResponse: `{ + "amount": 99899, + "fee": 1, + "ourfee": 100, + "total": 100000, + "balance": 100000 +}`, bitcoin: `curl "${api}/bitcoin/send" -H "content-type: application/json" -H "Authorization: Bearer $token" diff --git a/src/lib/server/ip.ts b/src/lib/server/ip.ts new file mode 100644 index 00000000..cfd933ac --- /dev/null +++ b/src/lib/server/ip.ts @@ -0,0 +1,2 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +export const ipStore = new AsyncLocalStorage<string>(); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 53a25e18..6f4d3f62 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -8,7 +8,7 @@ type Error = { export default async ({ cookies, request }) => { const form = await fd(request); - if (form.tab === "preferences") { + if (form.tab === "account") { form.notify = form.notify === "on"; form.push = form.push === "on"; form.nip5 = form.nip5 === "on"; diff --git a/src/lib/socket.ts b/src/lib/socket.ts index 2bb0d4f2..b28b1060 100644 --- a/src/lib/socket.ts +++ b/src/lib/socket.ts @@ -57,8 +57,12 @@ export const messages = (data) => ({ pathname.includes("/receive") || pathname.includes("/invoice") ) { - await wait(() => !get(navigating)); - await goto(`/invoice/${iid}`); + // `paid=<amount>` marks this as a payment that arrived just now, and + // carries the amount of THIS payment. A reusable bolt12 offer needs + // both: its `received` is a lifetime total, so the invoice layout + // can't tell a live payment from an old one, and the success screen + // would otherwise show the accumulated figure. + if (!get(navigating)) goto(`/invoice/${iid}?paid=${amount}`); } else success(`${confirmed ? "Received" : "Detected"} ⚡️${s(amount)}!`); } }, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2e1bb840..b6bbdb53 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,7 +2,8 @@ import { browser } from "$app/environment"; import { goto } from "$app/navigation"; import { page } from "$app/stores"; import { PUBLIC_COINOS_NETWORK, PUBLIC_COINOS_URL } from "$env/static/public"; -import { bytesToHex } from "@noble/hashes/utils"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { fail as svelteFail, redirect } from "@sveltejs/kit"; import { toast } from "@zerodevx/svelte-toast"; import { differenceInDays, @@ -20,11 +21,22 @@ export function scroll(section: any) { const base = browser ? "" : PUBLIC_COINOS_URL; +let getIp: (() => string | undefined) | undefined; + +export const setIpGetter = (fn: () => string | undefined) => { + getIp = fn; +}; + +const ipHeaders = (): Record<string, string> => { + const ip = getIp?.(); + return ip ? { "cf-connecting-ip": ip } : {}; +}; + export const punk = (k: string) => `${Math.floor((parseInt(k.slice(-2), 16) / 256) * 64) + 1}.webp`; export const g = (url: string, fetch: any, headers: object) => - fetch(base + url, { headers }) + fetch(base + url, { headers: { ...ipHeaders(), ...headers } }) .then((r: Response) => r.text()) .then((body: string) => { try { @@ -35,7 +47,7 @@ export const g = (url: string, fetch: any, headers: object) => }); export const get = (url: string, headers = {}) => { - return fetch(base + url, { headers }) + return fetch(base + url, { headers: { ...ipHeaders(), ...headers } }) .then(async (r) => { if (r.ok) return r.text(); throw new Error(await r.text()); @@ -57,6 +69,7 @@ export const post = async ( headers = { "content-type": "application/json", accept: "application/json", + ...ipHeaders(), ...headers, }; @@ -147,29 +160,54 @@ export const info = (m: string) => { }; export const login = async ( - user: { username: string; password: string }, + user: { + username: string; + password: string; + token?: string; + challenge?: string; + recaptcha?: string; + }, cookies, ip: string, + host?: string, + extraHeaders?: Record<string, string>, ) => { - const maxAge = 380 * 24 * 60 * 60; + const headers: Record<string, string> = { + "content-type": "application/json", + accept: "application/json", + "cf-connecting-ip": ip, + ...extraHeaders, + }; + if (host) headers["x-forwarded-host"] = host; const res = await fetch(`${base}/login`, { method: "POST", body: JSON.stringify(user), - headers: { - "content-type": "application/json", - accept: "application/json", - "cf-connecting-ip": ip, - }, + headers, }); + + const text = await res.text(); + if (res.status === 401) { - const text = await res.text(); if (text.startsWith("2fa")) throw new Error("2fa"); + throw new Error(text); } - const { user: u, token } = await res.json(); + const { user: u, token } = JSON.parse(text); if (!token) throw new Error("Login failed"); + setSession(cookies, user, u, token); +}; + +// Session cookies for a freshly issued JWT — shared by login and register, +// which both get a token back from the server. +const setSession = ( + cookies, + user: { username: string; password: string }, + u: { language?: string; nsec?: string }, + token: string, +) => { + const maxAge = 380 * 24 * 60 * 60; const expires = new Date(); expires.setSeconds(expires.getSeconds() + maxAge); @@ -192,6 +230,20 @@ export const auth = (cookies) => ({ authorization: `Bearer ${cookies.get("token")}`, }); +export const isInvalidTokenError = (e: unknown) => { + const message = (e as Error)?.message || ""; + if (!message) return false; + const normalized = message.toLowerCase(); + return ( + normalized.includes("jwt") || + normalized.includes("invalid token") || + normalized.includes("token expired") || + normalized.includes("expired token") || + normalized.includes("unauthorized") || + normalized.includes("not authorized") + ); +}; + export const btc = (fiat: number, rate: number): number => Math.round((fiat * sats) / rate); @@ -456,3 +508,50 @@ export const ago = (t) => { const days = differenceInDays(now, date); return `${days}d`; }; + +export const register = async (user, ip, cookies, loginRedirect, host?, extraHeaders?: Record<string, string>) => { + let error; + + const headers: Record<string, string> = { + ...extraHeaders, + }; + if (ip) headers["cf-connecting-ip"] = ip; + + let sk, token, u; + try { + ({ sk, token, ...u } = await post("/register", { user }, headers)); + } catch (e) { + ({ message: error } = e as Error); + } + + // /register verifies the recaptcha token itself now, and Google tokens + // are single-use: a follow-up /login carrying the same token comes back + // "failed captcha" for an account that was just created. The register + // response already includes the JWT, so start the session from it and + // only fall back to /login when registration failed (e.g. the username + // already exists and this is really a login). + if (token) { + setSession(cookies, user, u, token); + error = null; + } else { + try { + await login(user, cookies, ip, host, extraHeaders); + error = null; + } catch (e) { + const { message } = e as Error; + error ||= message; + } + } + + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + 21000000); + cookies.set("sk", sk, { + path: "/", + expires, + httpOnly: false, + sameSite: "lax", + }); + + if (error) return svelteFail(400, { error }); + redirect(303, loginRedirect || `/${user.username}`); +}; |
