diff options
| -rw-r--r-- | lib/deluser.ts | 154 | ||||
| -rw-r--r-- | routes/users.ts | 90 |
2 files changed, 165 insertions, 79 deletions
diff --git a/lib/deluser.ts b/lib/deluser.ts new file mode 100644 index 00000000..55d05386 --- /dev/null +++ b/lib/deluser.ts @@ -0,0 +1,154 @@ +import { archive, db, g } from "$lib/db"; +import ln from "$lib/ln"; + +// Delete an account and everything hanging off it — the same sweep as +// scripts/delall.ts on the prod host. Beyond the plain keys this also: +// - disables the account's bolt12 offers on the node, so a payer (e.g. an +// OCEAN payout) fails instead of settling into the house wallet; +// - removes the invoice pointer keys (invoice:<bolt11|bolt12|offer_id>) so a +// late settlement resolves to "no invoice" and is skipped; +// - deletes the same keys from the archive, which gf()/getUser fall back +// to, so nothing resurrects from there. +// Without those, a deleted user's offer kept receiving and wedged the lightning +// listener for two hours on 2026-09-05 (pay_index 1068006). +// +// Offers are disabled before any key is deleted, so if the node can't be +// reached this throws with the account still intact. +export default async function deleteUser(user) { + const { id, username, pubkey } = user; + + // Fail up front rather than halfway through the sweep. + await archive.ping(); + + const ga = async (k: string) => { + const v = await archive.get(k); + try { + return JSON.parse(v); + } catch { + return v; + } + }; + const getInv = async (iid: string) => + (await g(`invoice:${iid}`)) ?? (await ga(`invoice:${iid}`)); + + const aids = [ + id, + ...(await db.lRange(`${id}:accounts`, 0, -1)).filter((a) => a !== id), + ]; + const keys = new Set<string>(); + const add = (...ks: (string | false | undefined | null)[]) => + ks.forEach((k) => k && keys.add(k)); + + // username->uid mapping is stored lowercased+despaced (see register.ts); + // include both forms so a mixed-case username can't leave a dangling alias. + add( + `user:${id}`, + `user:${username}`, + `user:${username.toLowerCase().replace(/\s/g, "")}`, + ); + add( + ...[ + "payments", + "payments:last", + "apps", + "lastlen", + "accounts", + "contacts", + "invoices", + "items", + "pins", + "subscriptions", + "trust", + "square", + "codeVerifier", + ].map((s) => `${id}:${s}`), + ); + add( + `account:${id}`, + `balance:${id}`, + `pending:${id}`, + `credit:bitcoin:${id}`, + `credit:lightning:${id}`, + `credit:liquid:${id}`, + ); + if (pubkey) + add( + `user:${pubkey}`, + `${pubkey}:follows`, + `${pubkey}:follows:n`, + `${pubkey}:followers`, + `${pubkey}:followers:n`, + `${pubkey}:pubkeys`, + ); + + // Connected NWC apps. `${id}:apps` is a SET (sAdd in register.ts). + for (const ap of await db.sMembers(`${id}:apps`)) + add(`app:${ap}`, `${ap}:payments`); + + // Invoices and payments for the main account and every sub-account. + const invoiceIds = new Set<string>(); + const bolt12s = new Set<string>(); + for (const aid of aids) { + if (aid !== id) + add( + `account:${aid}`, + `${aid}:payments`, + `${aid}:payments:last`, + `${aid}:invoices`, + `balance:${aid}`, + `pending:${aid}`, + ); + for (const pid of await db.lRange(`${aid}:payments`, 0, -1)) + add(`payment:${pid}`); + for (const iid of await db.lRange(`${aid}:invoices`, 0, -1)) { + invoiceIds.add(iid); + add(`invoice:${iid}`); + const inv = await getInv(iid); + if (!inv) continue; + add(inv.hash && `invoice:${inv.hash}`, inv.text && `invoice:${inv.text}`); + if (inv.type === "bolt12" && inv.hash) bolt12s.add(inv.hash); + } + } + + // bolt12 offers: match by bolt12 string, or through the invoice:<offer_id> + // pointer (which also catches offers whose :invoices entry is already gone). + const { offers } = await ln.call("listoffers", {}); + let disabled = 0; + for (const o of offers) { + let mine = bolt12s.has(o.bolt12); + if (!mine) { + let iid = await g(`invoice:${o.offer_id}`); + if (iid && typeof iid === "object") iid = iid.id; + if (iid) { + if (invoiceIds.has(iid)) mine = true; + else { + const inv = await getInv(iid); + if (inv && aids.includes(inv.uid)) { + mine = true; + add(`invoice:${iid}`, inv.hash && `invoice:${inv.hash}`); + } + } + } + } + if (!mine) continue; + add(`invoice:${o.offer_id}`); + if (o.active) { + await ln.call("disableoffer", { offer_id: o.offer_id }); + disabled++; + } + } + + if (pubkey && user.nip5) await db.sRem("nip5", `${username}:${pubkey}`); + + let deleted = 0; + for (const k of keys) { + const inDb = await db.exists(k); + const inArc = await archive.exists(k); + if (!inDb && !inArc) continue; + deleted++; + if (inDb) await db.del(k); + if (inArc) await archive.del(k); + } + + return { deleted, disabled }; +} diff --git a/routes/users.ts b/routes/users.ts index 9f730b6f..400b554b 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -9,6 +9,7 @@ import { isDefaultName } from "$lib/default-names"; import { upgradeLegacyNsec } from "$lib/legacynsec"; import { mail, templates } from "$lib/mail"; import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr"; +import deleteUser from "$lib/deluser"; import register from "$lib/register"; import { assertNameFree } from "$lib/names"; import { isReserved } from "$lib/reserved"; @@ -880,22 +881,24 @@ export default { res.send({}); }, - // Authenticated self-delete: wipes every key tied to the calling user, - // mirroring scripts/delall-legacy.ts. Gated by typing the username (works for - // password and nostr accounts alike) and refuses while a balance remains so a - // user can't accidentally destroy funds. + // Authenticated self-delete: wipes every key tied to the calling user via + // lib/deluser (the same sweep as scripts/delall.ts, offers and archive + // included). Gated by typing the username (works for password and nostr + // accounts alike) plus the PIN when one is set, and refuses while a balance + // remains so a user can't accidentally destroy funds. async deleteSelf(req, res) { try { const { user } = req; - const { id, username, pubkey } = user; + const { id, username } = user; const confirm = (req.body?.confirm || "").toString().trim().toLowerCase(); if (confirm !== username.toLowerCase()) fail("Type your username to confirm account deletion"); + await requirePin(req); // Allow deletion through dust (often too small to withdraw); only block // a meaningful balance so nobody destroys real funds by accident. Sum - // EVERY account (main + sub-accounts) — the deletion loop below wipes each + // EVERY account (main + sub-accounts) — the sweep wipes each // sub-account's balance:/pending: keys unconditionally, so a guard that // only checked the main account let a user with funds parked in a // sub-account pass and silently destroy them. @@ -909,80 +912,9 @@ export default { if (total > 10000) fail("Withdraw your balance before deleting your account"); - const keys = [ - `user:${id}`, - // username->uid mapping is stored lowercased+despaced (see register.ts); - // include both forms so a mixed-case username can't leave a dangling alias. - `user:${username.toLowerCase().replace(/\s/g, "")}`, - `user:${username}`, - `${id}:payments`, - `${id}:payments:last`, - `${id}:apps`, - `${id}:lastlen`, - `${id}:accounts`, - `${id}:contacts`, - `${id}:invoices`, - `${id}:items`, - `${id}:pins`, - `${id}:subscriptions`, - `${id}:trust`, - `${id}:square`, - `${id}:codeVerifier`, - `account:${id}`, - `balance:${id}`, - `pending:${id}`, - `credit:bitcoin:${id}`, - `credit:lightning:${id}`, - `credit:liquid:${id}`, - ]; - - if (pubkey) { - keys.push( - `user:${pubkey}`, - `${pubkey}:follows`, - `${pubkey}:follows:n`, - `${pubkey}:followers`, - `${pubkey}:followers:n`, - `${pubkey}:pubkeys`, - ); - if (user.nip5) await db.sRem("nip5", `${username}:${pubkey}`); - } - - // Connected NWC apps: drop each app record + its payment index. - // `${id}:apps` is a SET (sAdd in register.ts), so use sMembers, not lRange. - for (const ap of await db.sMembers(`${id}:apps`)) - keys.push(`app:${ap}`, `${ap}:payments`); - - // Sub-accounts and their payment records. - for (const aid of await db.lRange(`${id}:accounts`, 0, -1)) { - if (aid === id) continue; - for (const pid of await db.lRange(`${aid}:payments`, 0, -1)) - keys.push(`payment:${pid}`); - keys.push( - `account:${aid}`, - `${aid}:payments`, - `${aid}:payments:last`, - `${aid}:invoices`, - `balance:${aid}`, - `pending:${aid}`, - ); - } - - // Main-account payment + invoice records. - for (const pid of await db.lRange(`${id}:payments`, 0, -1)) - keys.push(`payment:${pid}`); - for (const iid of await db.lRange(`${id}:invoices`, 0, -1)) - keys.push(`invoice:${iid}`); - - let deleted = 0; - for (const k of keys) { - if (await db.exists(k)) { - await db.del(k); - deleted++; - } - } + const { deleted, disabled } = await deleteUser(user); - l("user self-deleted", username, id, `${deleted} keys`); + l("user self-deleted", username, id, `${deleted} keys, ${disabled} offers`); try { res.clearCookie("token"); } catch {} |
