From 22144aa186f7570c5da9801dd95caf0c58214e28 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Fri, 26 Sep 2025 22:04:18 +0200 Subject: [PATCH] Fully delete profile in database and Firebase auth + storage --- backend/api/src/create-user.ts | 10 ++-- backend/api/src/delete-me.ts | 40 ++++++++++----- backend/shared/src/firebase-utils.ts | 28 +++++++++- .../generate-and-update-avatar-urls.ts | 8 ++- common/src/envs/constants.ts | 6 ++- web/components/auth-context.tsx | 51 ++++++++++--------- web/components/profile/delete-yourself.tsx | 30 +++++------ web/components/profile/profile-header.tsx | 25 +++++++-- web/lib/util/{constants.tsx => constants.ts} | 0 web/lib/util/delete.ts | 15 ++++++ 10 files changed, 141 insertions(+), 72 deletions(-) rename web/lib/util/{constants.tsx => constants.ts} (100%) create mode 100644 web/lib/util/delete.ts diff --git a/backend/api/src/create-user.ts b/backend/api/src/create-user.ts index 8c908344..9c1c31e3 100644 --- a/backend/api/src/create-user.ts +++ b/backend/api/src/create-user.ts @@ -7,12 +7,12 @@ import {APIError, APIHandler} from './helpers/endpoint' import {getDefaultNotificationPreferences} from 'common/user-notification-preferences' import {removeUndefinedProps} from 'common/util/object' import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls' -import {getStorage} from 'firebase-admin/storage' -import {ENV_CONFIG, RESERVED_PATHS} from 'common/envs/constants' +import {RESERVED_PATHS} from 'common/envs/constants' import {getUser, getUserByUsername, log} from 'shared/utils' import {createSupabaseDirectClient} from 'shared/supabase/init' import {insert} from 'shared/supabase/utils' import {convertPrivateUser, convertUser} from 'common/supabase/users' +import {getBucket} from "shared/firebase-utils"; export const createUser: APIHandler<'create-user'> = async ( props, @@ -50,7 +50,7 @@ export const createUser: APIHandler<'create-user'> = async ( const rawName = fbUser.displayName || emailName || 'User' + randomString(4) const name = cleanDisplayName(rawName) - const bucket = getStorage().bucket(getStorageBucketId()) + const bucket = getBucket() const avatarUrl = fbUser.photoURL ? fbUser.photoURL : await generateAvatarUrl(auth.uid, name, bucket) @@ -135,10 +135,6 @@ export const createUser: APIHandler<'create-user'> = async ( } } -function getStorageBucketId() { - return ENV_CONFIG.firebaseConfig.storageBucket -} - // Automatically ban users with these device tokens or ip addresses. const bannedDeviceTokens = [ 'fa807d664415', diff --git a/backend/api/src/delete-me.ts b/backend/api/src/delete-me.ts index ac7f6d49..c35930e0 100644 --- a/backend/api/src/delete-me.ts +++ b/backend/api/src/delete-me.ts @@ -1,11 +1,11 @@ -import { getUser } from 'shared/utils' -import { APIError, APIHandler } from './helpers/endpoint' -import { updatePrivateUser, updateUser } from 'shared/supabase/users' -import { createSupabaseDirectClient } from 'shared/supabase/init' -import { FieldVal } from 'shared/supabase/utils' +import {getUser} from 'shared/utils' +import {APIError, APIHandler} from './helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import * as admin from "firebase-admin"; +import {deleteUserFiles} from "shared/firebase-utils"; export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => { - const { username } = body + const {username} = body const user = await getUser(auth.uid) if (!user) { throw new APIError(401, 'Your account was not found') @@ -16,13 +16,27 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => { `Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?` ) } + const userId = user.id + if (!userId) { + throw new APIError(400, 'Invalid user ID') + } + // Remove user data from Supabase const pg = createSupabaseDirectClient() - await updateUser(pg, auth.uid, { - userDeleted: true, - isBannedFromPosting: true, - }) - await updatePrivateUser(pg, auth.uid, { - email: FieldVal.delete(), - }) + await pg.none('DELETE FROM users WHERE id = $1', [userId]) + await pg.none('DELETE FROM private_users WHERE id = $1', [userId]) + await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId]) + // May need to also delete from other tables in the future (such as messages, compatibility responses, etc.) + + // Delete user files from Firebase Storage + await deleteUserFiles(user.username) + + // Remove user from Firebase Auth + try { + const auth = admin.auth() + await auth.deleteUser(userId) + console.log(`Deleted user ${userId} from Firebase Auth and Supabase`) + } catch (e) { + console.error('Error deleting user from Firebase Auth:', e) + } } diff --git a/backend/shared/src/firebase-utils.ts b/backend/shared/src/firebase-utils.ts index 6db03f39..41290d96 100644 --- a/backend/shared/src/firebase-utils.ts +++ b/backend/shared/src/firebase-utils.ts @@ -1,5 +1,7 @@ import {readFileSync} from "fs"; -import {ENV_CONFIG} from "common/envs/constants"; +import {getStorage, Storage} from 'firebase-admin/storage' + +import {ENV_CONFIG, getStorageBucketId} from "common/envs/constants"; export const getServiceAccountCredentials = () => { let keyPath = ENV_CONFIG.googleApplicationCredentials @@ -22,4 +24,28 @@ export const getServiceAccountCredentials = () => { } catch (e) { throw new Error(`Failed to load service account key from ${keyPath}: ${e}`) } +} + +export function getBucket() { + return getStorage().bucket(getStorageBucketId()) +} + + +export type Bucket = ReturnType['bucket']> + +export async function deleteUserFiles(username: string) { + const path = `user-images/${username}` + + // Delete all files in the directory + const bucket = getBucket() + const [files] = await bucket.getFiles({prefix: path}); + + if (files.length === 0) { + console.log(`No files found in bucket for user ${username}`); + return; + } + + await Promise.all(files.map(file => file.delete())); + console.log(`Deleted ${files.length} files for user ${username}`); + } \ No newline at end of file diff --git a/backend/shared/src/helpers/generate-and-update-avatar-urls.ts b/backend/shared/src/helpers/generate-and-update-avatar-urls.ts index f4982740..45007456 100644 --- a/backend/shared/src/helpers/generate-and-update-avatar-urls.ts +++ b/backend/shared/src/helpers/generate-and-update-avatar-urls.ts @@ -1,7 +1,5 @@ -import { Storage } from 'firebase-admin/storage' -import { DOMAIN } from 'common/envs/constants' - -type Bucket = ReturnType['bucket']> +import {DOMAIN} from 'common/envs/constants' +import {Bucket} from "shared/firebase-utils"; export const generateAvatarUrl = async ( userId: string, @@ -44,7 +42,7 @@ async function upload(userId: string, buffer: Buffer, bucket: Bucket) { await file.save(buffer, { private: false, public: true, - metadata: { contentType: 'image/png' }, + metadata: {contentType: 'image/png'}, }) return `https://storage.googleapis.com/${bucket.cloudStorageURI.hostname}/${filename}` } diff --git a/common/src/envs/constants.ts b/common/src/envs/constants.ts index ba0af0c1..c47124ee 100644 --- a/common/src/envs/constants.ts +++ b/common/src/envs/constants.ts @@ -129,4 +129,8 @@ export const RESERVED_PATHS = [ 'users', 'web', 'welcome', -] \ No newline at end of file +] + +export function getStorageBucketId() { + return ENV_CONFIG.firebaseConfig.storageBucket +} \ No newline at end of file diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 6ca942d9..f6af5480 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -1,24 +1,19 @@ 'use client' -import { createContext, ReactNode, useEffect, useState } from 'react' -import { pickBy } from 'lodash' -import { onIdTokenChanged, User as FirebaseUser } from 'firebase/auth' -import { auth } from 'web/lib/firebase/users' -import { api } from 'web/lib/api' -import { randomString } from 'common/util/random' -import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' -import { AUTH_COOKIE_NAME, TEN_YEARS_SECS } from 'common/envs/constants' -import { getCookie, setCookie } from 'web/lib/util/cookie' -import { - type PrivateUser, - type User, - type UserAndPrivateUser, -} from 'common/user' -import { safeLocalStorage } from 'web/lib/util/local' -import { updateSupabaseAuth } from 'web/lib/supabase/db' -import { useEffectCheckEquality } from 'web/hooks/use-effect-check-equality' -import { getPrivateUserSafe, getUserSafe } from 'web/lib/supabase/users' -import { useWebsocketPrivateUser, useWebsocketUser } from 'web/hooks/use-user' -import { identifyUser, setUserProperty } from 'web/lib/service/analytics' +import {createContext, ReactNode, useEffect, useState} from 'react' +import {pickBy} from 'lodash' +import {onIdTokenChanged, User as FirebaseUser} from 'firebase/auth' +import {auth} from 'web/lib/firebase/users' +import {api} from 'web/lib/api' +import {randomString} from 'common/util/random' +import {useStateCheckEquality} from 'web/hooks/use-state-check-equality' +import {AUTH_COOKIE_NAME, TEN_YEARS_SECS} from 'common/envs/constants' +import {getCookie, setCookie} from 'web/lib/util/cookie' +import {type PrivateUser, type User, type UserAndPrivateUser,} from 'common/user' +import {safeLocalStorage} from 'web/lib/util/local' +import {useEffectCheckEquality} from 'web/hooks/use-effect-check-equality' +import {getPrivateUserSafe, getUserSafe} from 'web/lib/supabase/users' +import {useWebsocketPrivateUser, useWebsocketUser} from 'web/hooks/use-user' +import {identifyUser, setUserProperty} from 'web/lib/service/analytics' // Either we haven't looked up the logged-in user yet (undefined), or we know // the user is not logged in (null), or we know the user is logged in. @@ -70,13 +65,23 @@ const setUserCookie = (data: object | undefined) => { ]) } +export const clearUserCookie = () => { + setCookie(AUTH_COOKIE_NAME, '', [ + ['path', '/'], + ['max-age', '0'], + ['samesite', 'lax'], + ['secure'], + ]) +} + + export const AuthContext = createContext(undefined) export function AuthProvider(props: { children: ReactNode serverUser?: AuthUser }) { - const { children, serverUser } = props + const {children, serverUser} = props const [user, setUser] = useStateCheckEquality( serverUser ? serverUser.user : serverUser @@ -89,8 +94,8 @@ export function AuthProvider(props: { const authUser = !user ? user : !privateUser - ? privateUser - : { user, privateUser, authLoaded } + ? privateUser + : {user, privateUser, authLoaded} useEffect(() => { if (serverUser === undefined) { diff --git a/web/components/profile/delete-yourself.tsx b/web/components/profile/delete-yourself.tsx index 1f813754..5e7dd678 100644 --- a/web/components/profile/delete-yourself.tsx +++ b/web/components/profile/delete-yourself.tsx @@ -1,21 +1,15 @@ -import { TrashIcon } from '@heroicons/react/solid' +import {TrashIcon} from '@heroicons/react/solid' import router from 'next/router' -import { useState } from 'react' +import {useState} from 'react' import toast from 'react-hot-toast' -import { auth } from 'web/lib/firebase/users' -import { ConfirmationButton } from '../buttons/confirmation-button' -import { Col } from '../layout/col' -import { Input } from '../widgets/input' -import { Title } from '../widgets/title' -import { api } from 'web/lib/api' +import {ConfirmationButton} from '../buttons/confirmation-button' +import {Col} from '../layout/col' +import {Input} from '../widgets/input' +import {Title} from '../widgets/title' +import {deleteAccount} from "web/lib/util/delete"; export function DeleteYourselfButton(props: { username: string }) { - const { username } = props - - const deleteAccount = async () => { - await api('me/delete', { username }) - await auth.signOut() - } + const {username} = props const [deleteAccountConfirmation, setDeleteAccountConfirmation] = useState('') @@ -24,7 +18,7 @@ export function DeleteYourselfButton(props: { username: string }) { openModalBtn={{ className: 'p-2', label: 'Permanently delete this account', - icon: , + icon: , color: 'red', }} submitBtn={{ @@ -35,14 +29,14 @@ export function DeleteYourselfButton(props: { username: string }) { onSubmitWithSuccess={async () => { if (deleteAccountConfirmation == 'delete my account') { toast - .promise(deleteAccount(), { + .promise(deleteAccount(username), { loading: 'Deleting account...', success: () => { router.push('/') - return 'Account deleted' + return 'Your account has been deleted.' }, error: () => { - return 'Failed to delete account' + return 'Failed to delete account.' }, }) .then(() => { diff --git a/web/components/profile/profile-header.tsx b/web/components/profile/profile-header.tsx index 1ac3344d..72e7c628 100644 --- a/web/components/profile/profile-header.tsx +++ b/web/components/profile/profile-header.tsx @@ -1,6 +1,7 @@ import {DotsHorizontalIcon, EyeIcon, LockClosedIcon, PencilIcon} from '@heroicons/react/outline' import clsx from 'clsx' import Router from 'next/router' +import router from 'next/router' import Link from 'next/link' import {User} from 'common/user' import {Button} from 'web/components/buttons/button' @@ -17,9 +18,11 @@ import {Profile} from 'common/love/profile' import {useUser} from 'web/hooks/use-user' import {linkClass} from 'web/components/widgets/site-link' import {StarButton} from '../widgets/star-button' -import {api, updateProfile} from 'web/lib/api' +import {updateProfile} from 'web/lib/api' import React, {useState} from 'react' import {VisibilityConfirmationModal} from './visibility-confirmation-modal' +import {deleteAccount} from "web/lib/util/delete"; +import toast from "react-hot-toast"; export default function ProfileHeader(props: { user: User @@ -110,9 +113,23 @@ export default function ProfileHeader(props: { 'Are you sure you want to delete your profile? This cannot be undone.' ) if (confirmed) { - track('delete love profile') - await api('me/delete', {username: user.username}) - window.location.reload() + toast + .promise(deleteAccount(user.username), { + loading: 'Deleting account...', + success: () => { + router.push('/') + return 'Your account has been deleted.' + }, + error: () => { + return 'Failed to delete account.' + }, + }) + .then(() => { + // return true + }) + .catch(() => { + // return false + }) } }, }, diff --git a/web/lib/util/constants.tsx b/web/lib/util/constants.ts similarity index 100% rename from web/lib/util/constants.tsx rename to web/lib/util/constants.ts diff --git a/web/lib/util/delete.ts b/web/lib/util/delete.ts new file mode 100644 index 00000000..0b7aa110 --- /dev/null +++ b/web/lib/util/delete.ts @@ -0,0 +1,15 @@ +import {track} from "web/lib/service/analytics"; +import {api} from "web/lib/api"; +import {firebaseLogout} from "web/lib/firebase/users"; +import posthog from "posthog-js"; +import {clearUserCookie} from "web/components/auth-context"; + +export async function deleteAccount(username: string) { + track('delete profile') + await api('me/delete', {username}) + await firebaseLogout() + clearUserCookie() + localStorage.clear() + sessionStorage.clear() + posthog.reset() +} \ No newline at end of file