aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/invoice.ts2
-rw-r--r--src/lib/locales.ts62
-rw-r--r--src/lib/nostr.ts42
-rw-r--r--src/lib/parse.ts50
-rw-r--r--src/lib/random.ts20
-rw-r--r--src/lib/samples.ts14
-rw-r--r--src/lib/server/ip.ts2
-rw-r--r--src/lib/settings.ts2
-rw-r--r--src/lib/socket.ts8
-rw-r--r--src/lib/utils.ts123
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}`);
+};