Skip to content

feat: user deletion functionality #2212

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions api/paidAction/zap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions api/resolvers/user.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -656,6 +657,111 @@ export default {
},

Mutation: {
deleteAccount: async (parent, { deleteContent, 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) => {
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
}
})
}

// 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
}
})
}
}

// 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()
Expand Down
33 changes: 30 additions & 3 deletions api/ssrApollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -28,9 +45,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
}),
context: {
models,
me: session
? session.user
: me,
me: meUser,
lnd,
search
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions api/typeDefs/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default gql`
generateApiKey(id: ID!): String
deleteApiKey(id: ID!): User
disableFreebies: Boolean
deleteAccount(deleteContent: Boolean!, confirmation: String!, donateBalance: Boolean): Boolean
}

type User {
Expand All @@ -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
Expand Down
15 changes: 10 additions & 5 deletions components/item-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,16 @@ export default function ItemInfo ({
<span> \ </span>
<span>
{showUser &&
<Link href={`/${item.user.name}`}>
<UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
{embellishUser}
</Link>}
(item.user.deletedAt
? <span className='text-muted'>[deleted]</span>
: (
<Link href={`/${item.user.name}`}>
<UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
{embellishUser}
</Link>
)
)}
<span> </span>
<Link href={`/items/${item.id}`} title={item.invoicePaidAt || item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.invoicePaidAt || item.createdAt))}
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions fragments/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const ITEM_FIELDS = gql`
id
name
meMute
deletedAt
...StreakFields
}
sub {
Expand Down Expand Up @@ -100,6 +101,7 @@ export const ITEM_FULL_FIELDS = gql`
user {
id
name
deletedAt
...StreakFields
}
sub {
Expand Down
12 changes: 12 additions & 0 deletions lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
13 changes: 13 additions & 0 deletions pages/api/auth/[...nextauth].js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down
Loading