From e38441253345885e9483b296c1854dd3d821c0cd Mon Sep 17 00:00:00 2001 From: m0wer Date: Sat, 7 Jun 2025 20:03:11 +0200 Subject: [PATCH 1/6] feat: user deletion functionality --- api/resolvers/user.js | 81 ++++++++++++ api/ssrApollo.js | 33 ++++- api/typeDefs/user.js | 2 + components/item-info.js | 13 +- docker-compose.yml | 2 +- fragments/items.js | 2 + lib/auth.js | 12 ++ pages/api/auth/[...nextauth].js | 13 ++ pages/settings/index.js | 117 +++++++++++++++++- .../migration.sql | 1 + prisma/schema.prisma | 1 + 11 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20250604211427_user_deletion/migration.sql diff --git a/api/resolvers/user.js b/api/resolvers/user.js index f73f6bc34..311702d3d 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { readFile } from 'fs/promises' import { join, resolve } from 'path' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' @@ -656,6 +657,86 @@ export default { }, Mutation: { + deleteAccount: async (parent, { deleteContent, confirmation }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + if (confirmation !== 'DELETE MY ACCOUNT') { + throw new GqlInputError('incorrect confirmation text') + } + + return await models.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: me.id }, + select: { + msats: true, + mcredits: true, + name: true + } + }) + + if ((user.msats + user.mcredits) > 0) { + throw new GqlInputError('please withdraw your balance before deleting your account') + } + + // If deleteContent is true, replace content with hash + if (deleteContent) { + // Get all items for this user + const items = await tx.item.findMany({ + where: { userId: me.id }, + select: { id: true, title: true, text: true, url: true } + }) + + // Update each item with hashed content + for (const item of items) { + const originalContent = JSON.stringify({ + title: item.title, + text: item.text, + url: item.url + }) + + const hash = createHash('sha256').update(originalContent).digest('hex') + const deletedContent = `[deleted] ${hash}` + + await tx.item.update({ + where: { id: item.id }, + data: { + title: item.title ? deletedContent : null, + text: item.text ? deletedContent : null, + url: item.url ? deletedContent : null + } + }) + } + } + + // Create deletion timestamp and hash the old username with it + const deletionTimestamp = new Date() + const usernameHashInput = `${user.name}${deletionTimestamp.toISOString()}` + const hashedUsername = createHash('sha256').update(usernameHashInput).digest('hex') + + // Mark user as deleted and anonymize data + await tx.user.update({ + where: { id: me.id }, + data: { + deletedAt: deletionTimestamp, + name: hashedUsername, + email: null, + emailVerified: null, + emailHash: null, + image: null, + pubkey: null, + apiKeyHash: null, + nostrPubkey: null, + nostrAuthPubkey: null, + twitterId: null, + githubId: null + } + }) + + return true + }) + }, disableFreebies: async (parent, args, { me, models }) => { if (!me) { throw new GqlAuthenticationError() diff --git a/api/ssrApollo.js b/api/ssrApollo.js index d5513b7d9..5a004a13e 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -19,6 +19,23 @@ import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth' export default async function getSSRApolloClient ({ req, res, me = null }) { const session = req && await getServerSession(req, res, getAuthOptions(req)) + + // If there's a session, check if the user is deleted + let meUser = me + if (session?.user) { + const user = await models.user.findUnique({ + where: { id: parseInt(session.user.id) }, // Convert string to int + select: { deletedAt: true } + }) + + // If the user is deleted, don't pass the session to the context + if (user?.deletedAt) { + meUser = null + } else { + meUser = session.user + } + } + const client = new ApolloClient({ ssrMode: true, link: new SchemaLink({ @@ -28,9 +45,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) { }), context: { models, - me: session - ? session.user - : me, + me: meUser, lnd, search } @@ -160,6 +175,18 @@ export function getGetServerSideProps ( me = null } + // Check if the user account is deleted + if (me) { + const user = await models.user.findUnique({ + where: { id: parseInt(me.id) }, // Convert string to int + select: { deletedAt: true } + }) + + if (user?.deletedAt) { + me = null + } + } + if (authRequired && !me) { let callback = process.env.NEXT_PUBLIC_URL + req.url // On client-side routing, the callback is a NextJS URL diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 03fe55519..a6437256d 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -56,6 +56,7 @@ export default gql` generateApiKey(id: ID!): String deleteApiKey(id: ID!): User disableFreebies: Boolean + deleteAccount(deleteContent: Boolean!, confirmation: String!): Boolean } type User { @@ -70,6 +71,7 @@ export default gql` bioId: Int photoId: Int since: Int + deletedAt: Date """ this is only returned when we sort stackers by value diff --git a/components/item-info.js b/components/item-info.js index ec0a09582..7a9d94b23 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -132,11 +132,14 @@ export default function ItemInfo ({ \ {showUser && - - @{item.user.name} - - {embellishUser} - } + (item.user.deletedAt + ? [deleted] + : + @{item.user.name} + + {embellishUser} + + )} {timeSince(new Date(item.invoicePaidAt || item.createdAt))} diff --git a/docker-compose.yml b/docker-compose.yml index ee09f0ccf..5369a7dc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -179,7 +179,7 @@ services: - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD} - plugins.security.disabled=true - discovery.type=single-node - - "_JAVA_OPTIONS=-Xms2g -Xmx2g -XX:UseSVE=0" + - "_JAVA_OPTIONS=-Xms2g -Xmx2g" ports: - 9200:9200 # REST API - 9600:9600 # Performance Analyzer diff --git a/fragments/items.js b/fragments/items.js index 151587a20..62c8210dd 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -26,6 +26,7 @@ export const ITEM_FIELDS = gql` id name meMute + deletedAt ...StreakFields } sub { @@ -100,6 +101,7 @@ export const ITEM_FULL_FIELDS = gql` user { id name + deletedAt ...StreakFields } sub { diff --git a/lib/auth.js b/lib/auth.js index 23da782a0..a2d4fa76d 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -35,6 +35,18 @@ export const cookieOptions = (args) => ({ ...args }) +// Add a function to handle checking for deleted accounts +export const checkDeletedAccount = async (user, prisma) => { + if (!user?.id) return false + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { deletedAt: true } + }) + + return !!dbUser?.deletedAt +} + export function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) { const httpOnlyOptions = cookieOptions() const jsOptions = { ...httpOnlyOptions, httpOnly: false } diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index 8070d6ea3..411bd5f66 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -93,6 +93,19 @@ function getCallbacks (req, res) { * @return {object} JSON Web Token that will be saved */ async jwt ({ token, user, account, profile, isNewUser }) { + // Check if the user account is deleted + if (user?.id) { + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { deletedAt: true } + }) + + if (dbUser?.deletedAt) { + // Return false to prevent sign in for deleted accounts + return false + } + } + if (user) { // reset signup cookie if any res.appendHeader('Set-Cookie', cookie.serialize('signin', '', { path: '/', expires: 0, maxAge: 0 })) diff --git a/pages/settings/index.js b/pages/settings/index.js index 201d8c3dd..75fe37331 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -1,15 +1,12 @@ import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput } from '@/components/form' -import Alert from 'react-bootstrap/Alert' -import Button from 'react-bootstrap/Button' -import InputGroup from 'react-bootstrap/InputGroup' -import Nav from 'react-bootstrap/Nav' +import { Alert, Button, InputGroup, Nav, Form as BootstrapForm, OverlayTrigger, Tooltip } from 'react-bootstrap' import Layout from '@/components/layout' import { useState, useMemo } from 'react' import { gql, useMutation, useQuery } from '@apollo/client' import { getGetServerSideProps } from '@/api/ssrApollo' import LoginButton from '@/components/login-button' -import { signIn } from 'next-auth/react' import { LightningAuthWithExplainer } from '@/components/lightning-auth' +import { signIn, signOut } from 'next-auth/react' import { SETTINGS, SET_SETTINGS } from '@/fragments/users' import { useRouter } from 'next/router' import Info from '@/components/info' @@ -27,7 +24,6 @@ import { useToast } from '@/components/toast' import { useServiceWorkerLogger } from '@/components/logger' import { useMe } from '@/components/me' import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants' -import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { useField } from 'formik' import styles from './settings.module.css' import { AuthBanner } from '@/components/banners' @@ -87,6 +83,112 @@ export function SettingsHeader () { ) } +const DELETE_ACCOUNT = gql` + mutation deleteAccount($deleteContent: Boolean!, $confirmation: String!) { + deleteAccount(deleteContent: $deleteContent, confirmation: $confirmation) + } +` + +function DeleteAccount () { + const [showConfirmation, setShowConfirmation] = useState(false) + const [deleteContent, setDeleteContent] = useState(false) + const [confirmation, setConfirmation] = useState('') + const [deleteAccount] = useMutation(DELETE_ACCOUNT) + const toaster = useToast() + + const handleDelete = async () => { + try { + await deleteAccount({ + variables: { + deleteContent, + confirmation + } + }) + + // Sign out the user after successful deletion + signOut({ callbackUrl: '/' }) + + // Show success message + toaster.success('Your account has been deleted') + } catch (error) { + console.error(error) + toaster.danger(error.message || 'Failed to delete account') + } + } + + return ( + <> +
danger zone
+
+
+
Delete Account
+

+ This will permanently delete your account. This action cannot be undone. +

+ + {!showConfirmation + ? ( + + ) + : ( + <> + +

Warning: Account deletion is permanent and cannot be reversed.

+

Before proceeding, please ensure:

+
    +
  • You have withdrawn all your sats (you cannot delete an account with a balance)
  • +
  • You understand that you will lose access to your account name
  • +
  • You have considered that this action affects your entire account history
  • +
+
+ + setDeleteContent(e.target.checked)} + className='mb-3' + /> + + + Type "DELETE MY ACCOUNT" to confirm: + setConfirmation(e.target.value)} + placeholder='DELETE MY ACCOUNT' + /> + + +
+ + +
+ + )} +
+
+ + ) +} + export default function Settings ({ ssrData }) { const toaster = useToast() const { me } = useMe() @@ -646,6 +748,9 @@ export default function Settings ({ ssrData }) {
saturday newsletter
{settings?.authMethods && } + + {/* Add the delete account section */} + diff --git a/prisma/migrations/20250604211427_user_deletion/migration.sql b/prisma/migrations/20250604211427_user_deletion/migration.sql new file mode 100644 index 000000000..a0fa7cd3c --- /dev/null +++ b/prisma/migrations/20250604211427_user_deletion/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "deletedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a1be3ede6..eb1f61df0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,6 +81,7 @@ model User { hasRecvWallet Boolean @default(false) subs String[] hideCowboyHat Boolean @default(false) + deletedAt DateTime? Bookmarks Bookmark[] Donation Donation[] Earn Earn[] From ede8938de5d261dbe6c66ad1bff196a4efab3009 Mon Sep 17 00:00:00 2001 From: m0wer Date: Tue, 10 Jun 2025 17:25:41 +0200 Subject: [PATCH 2/6] feat: option to donate sats for removed accounts --- api/paidAction/zap.js | 48 ++++++++++++- api/resolvers/user.js | 31 +++++++- api/typeDefs/user.js | 2 +- pages/settings/index.js | 19 +++-- .../migration.sql | 17 +++++ worker/deletedUserEarnings.js | 71 +++++++++++++++++++ worker/earn.js | 5 +- worker/index.js | 2 + 8 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 prisma/migrations/20250610152839_deleted_user_earnings_job/migration.sql create mode 100644 worker/deletedUserEarnings.js diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index 66b4d6a0c..7713b34de 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -140,11 +140,53 @@ export async function onPaid ({ invoice, actIds }, { tx }) { ) UPDATE users SET - mcredits = users.mcredits + recipients.mcredits, - "stackedMsats" = users."stackedMsats" + recipients.mcredits, - "stackedMcredits" = users."stackedMcredits" + recipients.mcredits + mcredits = CASE + WHEN users."deletedAt" IS NULL THEN users.mcredits + recipients.mcredits + ELSE users.mcredits + END, + "stackedMsats" = CASE + WHEN users."deletedAt" IS NULL THEN users."stackedMsats" + recipients.mcredits + ELSE users."stackedMsats" + END, + "stackedMcredits" = CASE + WHEN users."deletedAt" IS NULL THEN users."stackedMcredits" + recipients.mcredits + ELSE users."stackedMcredits" + END FROM recipients WHERE users.id = recipients."userId"` + + // Donate msats that would have gone to deleted users to the rewards pool + const deletedUserMsats = await tx.$queryRaw` + WITH forwardees AS ( + SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits + FROM "ItemForward" + WHERE "itemId" = ${itemAct.itemId}::INTEGER + ), total_forwarded AS ( + SELECT COALESCE(SUM(mcredits), 0) as mcredits + FROM forwardees + ), recipients AS ( + SELECT "userId", mcredits FROM forwardees + UNION + SELECT ${itemAct.item.userId}::INTEGER as "userId", + ${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits + ) + SELECT COALESCE(SUM(recipients.mcredits), 0)::BIGINT as msats + FROM recipients + JOIN users ON users.id = recipients."userId" + WHERE users."deletedAt" IS NOT NULL` + + if (deletedUserMsats.length > 0 && deletedUserMsats[0].msats > 0) { + // Convert msats to sats and donate to rewards pool + const donationSats = Number(deletedUserMsats[0].msats / 1000n) + if (donationSats > 0) { + await tx.donation.create({ + data: { + sats: donationSats, + userId: USER_ID.sn // System donation + } + }) + } + } } // perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 311702d3d..acec5c611 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -657,7 +657,7 @@ export default { }, Mutation: { - deleteAccount: async (parent, { deleteContent, confirmation }, { me, models }) => { + deleteAccount: async (parent, { deleteContent, confirmation, donateBalance }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } @@ -676,8 +676,28 @@ export default { } }) - if ((user.msats + user.mcredits) > 0) { - throw new GqlInputError('please withdraw your balance before deleting your account') + const totalBalance = user.msats + user.mcredits + if (totalBalance > 0 && !donateBalance) { + throw new GqlInputError('please withdraw your balance before deleting your account or confirm donation to rewards pool') + } + + // If user has balance and confirmed donation, add to donations + if (totalBalance > 0 && donateBalance) { + await tx.donation.create({ + data: { + sats: Number(totalBalance / 1000n), // Convert msats to sats + userId: me.id + } + }) + + // Zero out user balance + await tx.user.update({ + where: { id: me.id }, + data: { + msats: 0, + mcredits: 0 + } + }) } // If deleteContent is true, replace content with hash @@ -710,6 +730,11 @@ export default { } } + // Remove all attached wallets + await tx.wallet.deleteMany({ + where: { userId: me.id } + }) + // Create deletion timestamp and hash the old username with it const deletionTimestamp = new Date() const usernameHashInput = `${user.name}${deletionTimestamp.toISOString()}` diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index a6437256d..3343e8810 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -56,7 +56,7 @@ export default gql` generateApiKey(id: ID!): String deleteApiKey(id: ID!): User disableFreebies: Boolean - deleteAccount(deleteContent: Boolean!, confirmation: String!): Boolean + deleteAccount(deleteContent: Boolean!, confirmation: String!, donateBalance: Boolean): Boolean } type User { diff --git a/pages/settings/index.js b/pages/settings/index.js index 75fe37331..4b81086c3 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -84,14 +84,15 @@ export function SettingsHeader () { } const DELETE_ACCOUNT = gql` - mutation deleteAccount($deleteContent: Boolean!, $confirmation: String!) { - deleteAccount(deleteContent: $deleteContent, confirmation: $confirmation) + mutation deleteAccount($deleteContent: Boolean!, $confirmation: String!, $donateBalance: Boolean) { + deleteAccount(deleteContent: $deleteContent, confirmation: $confirmation, donateBalance: $donateBalance) } ` function DeleteAccount () { const [showConfirmation, setShowConfirmation] = useState(false) const [deleteContent, setDeleteContent] = useState(false) + const [donateBalance, setDonateBalance] = useState(false) const [confirmation, setConfirmation] = useState('') const [deleteAccount] = useMutation(DELETE_ACCOUNT) const toaster = useToast() @@ -101,7 +102,8 @@ function DeleteAccount () { await deleteAccount({ variables: { deleteContent, - confirmation + confirmation, + donateBalance } }) @@ -141,7 +143,7 @@ function DeleteAccount () {

Warning: Account deletion is permanent and cannot be reversed.

Before proceeding, please ensure:

    -
  • You have withdrawn all your sats (you cannot delete an account with a balance)
  • +
  • You have withdrawn all your sats or checked the box to donate your balance to the rewards pool
  • You understand that you will lose access to your account name
  • You have considered that this action affects your entire account history
@@ -156,6 +158,15 @@ function DeleteAccount () { className='mb-3' /> +