From d86d8b3baccb0a6ed406ee52beda1b1092d09882 Mon Sep 17 00:00:00 2001 From: rleed <101502594+rleed@users.noreply.github.com> Date: Thu, 9 Nov 2023 14:50:43 -0300 Subject: [PATCH] Provide option to clear withdrawal invoices (#591) * add settings option * add auto-drop worker * add manual delete option * add warning and note * cleanup * incorporate most review feedback * add warning to settings option * remove debugging tweaks and simplify * refine auto delete bolt11s * refine UI --------- Co-authored-by: rleed Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> --- api/resolvers/wallet.js | 29 +++++++ api/typeDefs/user.js | 3 +- api/typeDefs/wallet.js | 6 +- components/bolt11-info.js | 8 +- components/delete.js | 2 +- fragments/users.js | 6 +- fragments/wallet.js | 2 + lib/apollo.js | 6 ++ lib/constants.js | 2 + pages/satistics.js | 5 +- pages/settings.js | 51 ++++++++++++ pages/withdrawals/[id].js | 77 ++++++++++++++++++- .../migration.sql | 23 ++++++ prisma/schema.prisma | 5 +- worker/index.js | 3 +- worker/wallet.js | 18 +++++ 16 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/20231026142112_auto_drop_bolt11s/migration.sql diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 41d6867f..89f4ee9c 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -80,6 +80,18 @@ export default { return wdrwl }, + numBolt11s: async (parent, args, { me, models, lnd }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + } + + return await models.withdrawl.count({ + where: { + userId: me.id, + hash: { not: null } + } + }) + }, connectAddress: async (parent, args, { lnd }) => { return process.env.LND_CONNECT_ADDRESS }, @@ -359,6 +371,23 @@ export default { } })) return inv + }, + dropBolt11: async (parent, { id }, { me, models }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + } + + await models.withdrawl.update({ + where: { + userId: me.id, + id: Number(id), + createdAt: { + lte: datePivot(new Date(), { days: -7 }) + } + }, + data: { bolt11: null, hash: null } + }) + return { id } } }, diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 48738475..9880058e 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -22,7 +22,7 @@ export default gql` setName(name: String!): String setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, withdrawMaxFeeDefault: Int!, noteItemSats: Boolean!, noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, - noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!, + noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!, autoDropBolt11s: Boolean!, hideFromTopUsers: Boolean!, hideCowboyHat: Boolean!, imgproxyOnly: Boolean!, wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrCrossposting: Boolean, nostrRelays: [String!], hideBookmarks: Boolean!, noteForwardedSats: Boolean!, hideWalletBalance: Boolean!, hideIsContributor: Boolean!, diagnostics: Boolean!): User @@ -97,6 +97,7 @@ export default gql` noteCowboyHat: Boolean! noteForwardedSats: Boolean! hideInvoiceDesc: Boolean! + autoDropBolt11s: Boolean! hideFromTopUsers: Boolean! hideCowboyHat: Boolean! hideBookmarks: Boolean! diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index e7fa200f..137c61b8 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -4,6 +4,7 @@ export default gql` extend type Query { invoice(id: ID!): Invoice! withdrawl(id: ID!): Withdrawl! + numBolt11s: Int! connectAddress: String! walletHistory(cursor: String, inc: String): History } @@ -13,6 +14,7 @@ export default gql` createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl! cancelInvoice(hash: String!, hmac: String!): Invoice! + dropBolt11(id: ID): Withdrawl } type Invoice { @@ -36,8 +38,8 @@ export default gql` type Withdrawl { id: ID! createdAt: Date! - hash: String! - bolt11: String! + hash: String + bolt11: String satsPaying: Int! satsPaid: Int satsFeePaying: Int! diff --git a/components/bolt11-info.js b/components/bolt11-info.js index 4867572e..bdfa7d5f 100644 --- a/components/bolt11-info.js +++ b/components/bolt11-info.js @@ -2,8 +2,11 @@ import { decode } from 'bolt11' import AccordianItem from './accordian-item' import { CopyInput } from './form' -export default ({ bolt11, preimage }) => { - const { tagsObject: { description, payment_hash: paymentHash } } = decode(bolt11) +export default ({ bolt11, preimage, children }) => { + let description, paymentHash + if (bolt11) { + ({ tagsObject: { description, payment_hash: paymentHash } } = decode(bolt11)) + } if (!description && !paymentHash && !preimage) { return null } @@ -41,6 +44,7 @@ export default ({ bolt11, preimage }) => { noForm placeholder={preimage} />} + {children} } /> diff --git a/components/delete.js b/components/delete.js index 3df30089..463c0d04 100644 --- a/components/delete.js +++ b/components/delete.js @@ -61,7 +61,7 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) { ) } -function DeleteConfirm ({ onConfirm, type }) { +export function DeleteConfirm ({ onConfirm, type }) { const [error, setError] = useState() const toaster = useToast() diff --git a/fragments/users.js b/fragments/users.js index cff94edc..6033bf9d 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -30,6 +30,7 @@ export const ME = gql` noteCowboyHat noteForwardedSats hideInvoiceDesc + autoDropBolt11s hideFromTopUsers hideCowboyHat imgproxyOnly @@ -60,6 +61,7 @@ export const SETTINGS_FIELDS = gql` noteCowboyHat noteForwardedSats hideInvoiceDesc + autoDropBolt11s hideFromTopUsers hideCowboyHat hideBookmarks @@ -94,14 +96,14 @@ gql` ${SETTINGS_FIELDS} mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: String!, $withdrawMaxFeeDefault: Int!, $noteItemSats: Boolean!, $noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, - $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!, + $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!, $autoDropBolt11s: Boolean!, $hideFromTopUsers: Boolean!, $hideCowboyHat: Boolean!, $imgproxyOnly: Boolean!, $wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrCrossposting: Boolean!, $nostrRelays: [String!], $hideBookmarks: Boolean!, $noteForwardedSats: Boolean!, $hideWalletBalance: Boolean!, $hideIsContributor: Boolean!, $diagnostics: Boolean!) { setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency, withdrawMaxFeeDefault: $withdrawMaxFeeDefault, noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, - noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc, + noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc, autoDropBolt11s: $autoDropBolt11s, hideFromTopUsers: $hideFromTopUsers, hideCowboyHat: $hideCowboyHat, imgproxyOnly: $imgproxyOnly, wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrCrossposting: $nostrCrossposting, nostrRelays: $nostrRelays, hideBookmarks: $hideBookmarks, noteForwardedSats: $noteForwardedSats, hideWalletBalance: $hideWalletBalance, hideIsContributor: $hideIsContributor, diagnostics: $diagnostics) { diff --git a/fragments/wallet.js b/fragments/wallet.js index 477a6803..4956e610 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -24,6 +24,7 @@ export const WITHDRAWL = gql` query Withdrawl($id: ID!) { withdrawl(id: $id) { id + createdAt bolt11 satsPaid satsFeePaying @@ -40,6 +41,7 @@ export const WALLET_HISTORY = gql` facts { id factId + bolt11 type createdAt sats diff --git a/lib/apollo.js b/lib/apollo.js index 59d0b9e6..1cdeec4c 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -127,6 +127,12 @@ function getClient (uri) { } } }, + numBolt11s: { + keyArgs: [], + merge (existing, incoming) { + return incoming + } + }, walletHistory: { keyArgs: ['inc'], merge (existing, incoming) { diff --git a/lib/constants.js b/lib/constants.js index f006a947..a6a1dec3 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -79,3 +79,5 @@ export const ITEM_ALLOW_EDITS = [ // FAQ, privacy policy, changelog, content guidelines 349, 76894, 78763, 81862 ] + +export const INVOICE_RETENTION_DAYS = 7 diff --git a/pages/satistics.js b/pages/satistics.js index a260728f..e2a57fc2 100644 --- a/pages/satistics.js +++ b/pages/satistics.js @@ -115,11 +115,12 @@ function Detail ({ fact }) { return (
- {(zap && nostr zap{zap.content && `: ${zap.content}`}) || + {(!fact.bolt11 && invoice deleted) || + (zap && nostr zap{zap.content && `: ${zap.content}`}) || (fact.description && {fact.description})} {fact.invoiceComment && sender says: {fact.invoiceComment}} - {!fact.invoiceComment && !fact.description && no description} + {!fact.invoiceComment && !fact.description && fact.bolt11 && no description}
diff --git a/pages/settings.js b/pages/settings.js index 2c85ee26..46fe14fd 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -25,6 +25,7 @@ import { NostrAuth } from '../components/nostr-auth' import { useToast } from '../components/toast' import { useLogger } from '../components/logger' import { useMe } from '../components/me' +import { INVOICE_RETENTION_DAYS } from '../lib/constants' export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true }) @@ -74,6 +75,7 @@ export default function Settings ({ ssrData }) { noteCowboyHat: settings?.noteCowboyHat, noteForwardedSats: settings?.noteForwardedSats, hideInvoiceDesc: settings?.hideInvoiceDesc, + autoDropBolt11s: settings?.autoDropBolt11s, hideFromTopUsers: settings?.hideFromTopUsers, hideCowboyHat: settings?.hideCowboyHat, imgproxyOnly: settings?.imgproxyOnly, @@ -236,6 +238,23 @@ export default function Settings ({ ssrData }) { name='hideInvoiceDesc' groupClassName='mb-0' /> + autodelete withdrawal invoices + +
    +
  • use this to protect receiver privacy
  • +
  • applies retroactively, cannot be reversed
  • +
  • withdrawal invoices are kept at least {INVOICE_RETENTION_DAYS} days for security and debugging purposes
  • +
  • autodeletions are run a daily basis at night
  • +
+
+ + } + name='autoDropBolt11s' + groupClassName='mb-0' + /> hide me from top stackers} name='hideFromTopUsers' @@ -377,6 +396,38 @@ export default function Settings ({ ssrData }) { ) } +const DropBolt11sCheckbox = ({ ssrData, ...props }) => { + const showModal = useShowModal() + const { data } = useQuery(gql`{ numBolt11s }`) + const { numBolt11s } = data || ssrData + + return ( + { + if (e.target.checked) { + showModal(onClose => { + return ( + <> +

{numBolt11s} withdrawal invoices will be deleted with this setting.

+

You sure? This is a gone forever kind of delete.

+
+ +
+ + ) + }) + } + }} + {...props} + /> + ) +} + function QRLinkButton ({ provider, unlink, status }) { const showModal = useShowModal() const text = status ? 'Unlink' : 'Link' diff --git a/pages/withdrawals/[id].js b/pages/withdrawals/[id].js index 2b259c9e..9c4d0bde 100644 --- a/pages/withdrawals/[id].js +++ b/pages/withdrawals/[id].js @@ -1,4 +1,4 @@ -import { useQuery } from '@apollo/client' +import { useQuery, useMutation } from '@apollo/client' import { CenterLayout } from '../../components/layout' import { CopyInput, Input, InputSkeleton } from '../../components/form' import InputGroup from 'react-bootstrap/InputGroup' @@ -6,9 +6,15 @@ import InvoiceStatus from '../../components/invoice-status' import { useRouter } from 'next/router' import { WITHDRAWL } from '../../fragments/wallet' import Link from 'next/link' -import { SSR } from '../../lib/constants' +import { SSR, INVOICE_RETENTION_DAYS } from '../../lib/constants' import { numWithUnits } from '../../lib/format' import Bolt11Info from '../../components/bolt11-info' +import { datePivot, timeLeft } from '../../lib/time' +import { useMe } from '../../components/me' +import { useToast } from '../../components/toast' +import { gql } from 'graphql-tag' +import { useShowModal } from '../../components/modal' +import { DeleteConfirm } from '../../components/delete' export default function Withdrawl () { return ( @@ -87,7 +93,7 @@ function LoadWithdrawl () {
@@ -98,7 +104,70 @@ function LoadWithdrawl () { />
- + + + ) } + +function PrivacyOption ({ wd }) { + if (!wd.bolt11) return + + const me = useMe() + const keepUntil = datePivot(new Date(wd.createdAt), { days: INVOICE_RETENTION_DAYS }) + const oldEnough = new Date() >= keepUntil + if (!oldEnough) { + return ( + + {`this invoice ${me.autoDropBolt11s ? 'will be auto-deleted' : 'can be deleted'} in ${timeLeft(keepUntil)}`} + + ) + } + + const showModal = useShowModal() + const toaster = useToast() + const [dropBolt11] = useMutation( + gql` + mutation dropBolt11($id: ID!) { + dropBolt11(id: $id) { + id + } + }`, { + update (cache) { + cache.modify({ + id: `Withdrawl:${wd.id}`, + fields: { + bolt11: () => null, + hash: () => null + } + }) + } + }) + + return ( + { + showModal(onClose => { + return ( + { + if (me) { + try { + await dropBolt11({ variables: { id: wd.id } }) + } catch (err) { + console.error(err) + toaster.danger('unable to delete invoice') + } + } + onClose() + }} + /> + ) + }) + }} + >delete invoice + + ) +} diff --git a/prisma/migrations/20231026142112_auto_drop_bolt11s/migration.sql b/prisma/migrations/20231026142112_auto_drop_bolt11s/migration.sql new file mode 100644 index 00000000..69b4ca94 --- /dev/null +++ b/prisma/migrations/20231026142112_auto_drop_bolt11s/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "autoDropBolt11s" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Withdrawl" ALTER COLUMN "hash" DROP NOT NULL; +ALTER TABLE "Withdrawl" ALTER COLUMN "bolt11" DROP NOT NULL; + +-- hack ... prisma doesn't know about our other schemas (e.g. pgboss) +-- and this is only really a problem on their "shadow database" +-- so we catch the exception it throws and ignore it +CREATE OR REPLACE FUNCTION create_autodrop_bolt11s_job() +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE +BEGIN + INSERT INTO pgboss.schedule (name, cron, timezone) VALUES ('autoDropBolt11s', '1 1 * * *', 'America/Chicago') ON CONFLICT DO NOTHING; + return 0; +EXCEPTION WHEN OTHERS THEN + return 0; +END; +$$; + +SELECT create_autodrop_bolt11s_job(); +DROP FUNCTION create_autodrop_bolt11s_job(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0cc13fc0..ab2babd7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { greeterMode Boolean @default(false) fiatCurrency String @default("USD") withdrawMaxFeeDefault Int @default(10) + autoDropBolt11s Boolean @default(false) hideFromTopUsers Boolean @default(false) turboTipping Boolean @default(false) imgproxyOnly Boolean @default(false) @@ -493,8 +494,8 @@ model Withdrawl { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") userId Int - hash String - bolt11 String + hash String? + bolt11 String? msatsPaying BigInt msatsPaid BigInt? msatsFeePaying BigInt diff --git a/worker/index.js b/worker/index.js index 401ed46f..94491136 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,7 +1,7 @@ import PgBoss from 'pg-boss' import nextEnv from '@next/env' import { PrismaClient } from '@prisma/client' -import { checkInvoice, checkWithdrawal } from './wallet.js' +import { checkInvoice, checkWithdrawal, autoDropBolt11s } from './wallet.js' import { repin } from './repin.js' import { trust } from './trust.js' import { auction } from './auction.js' @@ -57,6 +57,7 @@ async function work () { await boss.start() await boss.work('checkInvoice', checkInvoice(args)) await boss.work('checkWithdrawal', checkWithdrawal(args)) + await boss.work('autoDropBolt11s', autoDropBolt11s(args)) await boss.work('repin-*', repin(args)) await boss.work('trust', trust(args)) await boss.work('timestampItem', timestampItem(args)) diff --git a/worker/wallet.js b/worker/wallet.js index dd9c14c7..8492290d 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -3,6 +3,7 @@ import { getInvoice, getPayment, cancelHodlInvoice } from 'ln-service' import { datePivot } from '../lib/time.js' import { sendUserNotification } from '../api/webPush/index.js' import { msatsToSats, numWithUnits } from '../lib/format' +import { INVOICE_RETENTION_DAYS } from '../lib/constants' const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true } @@ -118,3 +119,20 @@ export function checkWithdrawal ({ boss, models, lnd }) { } } } + +export function autoDropBolt11s ({ models }) { + return async function () { + console.log('deleting invoices') + try { + await serialize(models, models.$executeRaw` + UPDATE "Withdrawl" + SET hash = NULL, bolt11 = NULL + WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s") + AND now() > created_at + interval '${INVOICE_RETENTION_DAYS} days' + AND hash IS NOT NULL;` + ) + } catch (err) { + console.log(err) + } + } +}