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 7 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
40 changes: 39 additions & 1 deletion api/paidAction/zap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
97 changes: 97 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 All @@ -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()

Expand Down Expand Up @@ -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()
Expand Down
10 changes: 7 additions & 3 deletions api/ssrApollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -28,9 +34,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
}),
context: {
models,
me: session
? session.user
: me,
me: meUser,
lnd,
search
}
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(confirmation: String!, donateBalance: Boolean, deleteContent: 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
4 changes: 3 additions & 1 deletion components/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ export default function Item ({
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === USER_ID.ad
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />}
: Number(item.user?.id) === USER_ID.delete
? null
: <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}>
<Link
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
25 changes: 24 additions & 1 deletion 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 Expand Up @@ -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
}
}
Expand Down
Loading