diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index 66b4d6a0c..96eb0b3be 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -57,6 +57,11 @@ export async function getSybilFeePercent () { } export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) { + const item = await tx.item.findUnique({ where: { id: parseInt(itemId) } }) + if (item.userId === USER_ID.delete) { + throw new Error('cannot zap deleted content') + } + const feeMsats = cost * sybilFeePercent / 100n const zapMsats = cost - feeMsats itemId = parseInt(itemId) @@ -144,7 +149,40 @@ export async function onPaid ({ invoice, actIds }, { tx }) { "stackedMsats" = users."stackedMsats" + recipients.mcredits, "stackedMcredits" = users."stackedMcredits" + recipients.mcredits FROM recipients - WHERE users.id = recipients."userId"` + WHERE users.id = recipients."userId" AND users."deletedAt" IS NULL` + + // 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/item.js b/api/resolvers/item.js index a03a0cf9c..c84053297 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -971,7 +971,7 @@ export default { FROM "Item" WHERE id = $1`, Number(id)) - if (item.deletedAt) { + if (item.deletedAt || item.userId === USER_ID.delete) { throw new GqlInputError('item is deleted') } diff --git a/api/resolvers/user.js b/api/resolvers/user.js index f73f6bc34..2475a5514 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' @@ -12,6 +13,7 @@ import { hashEmail } from '@/lib/crypto' import { isMuted } from '@/lib/user' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { processCrop } from '@/worker/imgproxy' +import { deleteItemByAuthor } from '@/lib/item' const contributors = new Set() @@ -656,6 +658,101 @@ export default { }, Mutation: { + deleteAccount: async (parent, { confirmation, donateBalance }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + if (confirmation !== 'DELETE MY ACCOUNT') { + throw new GqlInputError('incorrect confirmation text') + } + + return await models.$transaction(async (tx) => { + await tx.user.upsert({ + where: { id: USER_ID.delete }, + update: {}, + create: { + id: USER_ID.delete, + name: 'delete' + } + }) + + const user = await tx.user.findUnique({ + where: { id: me.id }, + select: { + msats: true, + mcredits: true, + name: true + } + }) + + 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 + } + }) + } + + const items = await tx.item.findMany({ where: { userId: me.id } }) + for (const item of items) { + await deleteItemByAuthor({ models: tx, id: item.id, item }) + } + + // Always move user's content to the @delete user + await tx.item.updateMany({ + where: { userId: me.id }, + data: { userId: USER_ID.delete } + }) + + // 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()}` + 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..5fd9ccbf6 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -19,6 +19,12 @@ 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)) + + let meUser = me + if (session?.user) { + meUser = session.user + } + const client = new ApolloClient({ ssrMode: true, link: new SchemaLink({ @@ -28,9 +34,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) { }), context: { models, - me: session - ? session.user - : me, + me: meUser, lnd, search } diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 1e7155914..298a13840 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(confirmation: String!, donateBalance: Boolean, deleteContent: Boolean): 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.js b/components/item.js index 0ae1d0afb..91b142d68 100644 --- a/components/item.js +++ b/components/item.js @@ -115,7 +115,9 @@ export default function Item ({ ? : Number(item.user?.id) === USER_ID.ad ? - : } + : Number(item.user?.id) === USER_ID.delete + ? null + : }
({ ...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..25f0e2cc8 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 })) @@ -139,8 +152,18 @@ function getCallbacks (req, res) { async session ({ session, token }) { // note: this function takes the current token (result of running jwt above) // and returns a new object session that's returned whenever get|use[Server]Session is called - session.user.id = token.id + if (token?.id) { + const dbUser = await prisma.user.findUnique({ + where: { id: token.id }, + select: { deletedAt: true } + }) + + if (dbUser?.deletedAt) { + return null + } + } + session.user.id = token.id return session } } diff --git a/pages/settings/index.js b/pages/settings/index.js index 93cbc5996..30f3163ef 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, 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' @@ -18,6 +15,7 @@ import AccordianItem from '@/components/accordian-item' import { bech32 } from 'bech32' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr' import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '@/lib/validate' +import * as Yup from 'yup' import { SUPPORTED_CURRENCIES } from '@/lib/currency' import PageLoading from '@/components/page-loading' import { useShowModal } from '@/components/modal' @@ -26,7 +24,6 @@ import { NostrAuth } from '@/components/nostr-auth' import { useToast } from '@/components/toast' 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' @@ -86,6 +83,113 @@ export function SettingsHeader () { ) } +const DELETE_ACCOUNT = gql` + mutation deleteAccount($confirmation: String!, $donateBalance: Boolean) { + deleteAccount(confirmation: $confirmation, donateBalance: $donateBalance) + } +` + +const GET_USER_BALANCE = gql` + { me { privates { sats } } } +` + +function DeleteAccount () { + const [showConfirmation, setShowConfirmation] = useState(false) + const [deleteAccount] = useMutation(DELETE_ACCOUNT) + const toaster = useToast() + const { data } = useQuery(GET_USER_BALANCE) + const userBalance = data?.me?.privates?.sats || 0 + + const handleDelete = async (values) => { + try { + await deleteAccount({ variables: { ...values } }) + signOut({ callbackUrl: '/' }) + toaster.success('Your account has been deleted') + } catch (error) { + console.error(error) + toaster.danger(error.message || 'Failed to delete account') + } + } + + const deleteAccountSchema = Yup.object({ + confirmation: Yup.string().oneOf(['DELETE MY ACCOUNT'], 'incorrect confirmation text').required('incorrect confirmation text'), + donateBalance: Yup.boolean() + .when('userBalance', { + is: (balance) => balance > 0, + then: schema => schema.oneOf([true], 'please withdraw your balance before deleting your account or confirm donation to rewards pool'), + otherwise: schema => schema + }) + }) + + 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 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
  • +
+
+ + + + + +
+ + Permanently delete my account +
+ + )} +
+
+ + ) +} + export default function Settings ({ ssrData }) { const toaster = useToast() const { me } = useMe() @@ -620,6 +724,8 @@ export default function Settings ({ ssrData }) {
saturday newsletter
{settings?.authMethods && } + +
diff --git a/prisma/migrations/20250604211427_user_deletion/migration.sql b/prisma/migrations/20250604211427_user_deletion/migration.sql new file mode 100644 index 000000000..36f772181 --- /dev/null +++ b/prisma/migrations/20250604211427_user_deletion/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "deletedAt" TIMESTAMP(3); +INSERT INTO "users" (id, name) VALUES (106, 'delete') ON CONFLICT (id) DO NOTHING; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a3922145a..24513216e 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[] diff --git a/worker/earn.js b/worker/earn.js index 8fde43ba5..5ce35dc43 100644 --- a/worker/earn.js +++ b/worker/earn.js @@ -182,7 +182,10 @@ function earnStmts (data, { models }) { return [ models.earn.create({ data }), models.user.update({ - where: { id: userId }, + where: { + id: userId, + deletedAt: null // Only update if user is not deleted + }, data: { msats: { increment: msats }, stackedMsats: { increment: msats }