Skip to content

Commit f672c2b

Browse files
committed
fix(account-deletion): queue the account's avatar and let the guard keep shared ones
An ownership lookup left avatars uploaded more than a week before deletion in storage, since their upload sessions are gone by then. The key user.image names is queued again, and the run-time reference guard keeps it whenever another account's user.image or a stored asset URL still names it.
1 parent 0e93240 commit f672c2b

4 files changed

Lines changed: 31 additions & 101 deletions

File tree

‎apps/sim/lib/users/storage-reference-guard.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,12 @@ async function loadKeysNamedByUrls(runs: readonly string[], named: Set<string>):
207207
* - it sits under a world-readable root (`isPublicStorageKey`, the same prefixes the file-serve
208208
* route's `isPublicByKeyPrefix` answers without a session) and a stored asset URL names it.
209209
* Only URL rows that mention the key's {@link ownerRun} are read, on every call so a reference
210-
* written mid-sweep still counts; a public key without one is kept unread. A URL that hand-encodes characters of that
211-
* run is missed. Account deletion asks about public keys only under a deleted workspace's
212-
* prefix (its logos) and for an avatar the account is proven to have uploaded, so such a URL can
213-
* cost an object of a deleted workspace or of the erased account's own uploads, never one a
214-
* surviving workspace, organization, or user uploaded.
210+
* written mid-sweep still counts; a public key without one is kept unread. A URL that
211+
* hand-encodes characters of that run is missed. Account deletion asks about public keys only
212+
* under a deleted workspace's prefix (its logos) and for the avatar the erased account's
213+
* `user.image` names, so such a URL can cost one of those, and only when no surviving reference
214+
* names it the way this app writes URLs; another user's own `user.image` naming their avatar
215+
* always keeps it.
215216
*
216217
* No URL column is read for any other key: `verifyFileAccess` serves it only through a row, by its
217218
* key path under a workspace that still exists (kept above), or by object metadata to a signed-in holder of a hand-pasted URL to

‎apps/sim/lib/users/storage-sweep.integration.ts‎

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
copilotChats,
88
outboxEvent,
99
permissions,
10-
uploadSession,
1110
user,
1211
workspace,
1312
workspaceFiles,
@@ -104,26 +103,6 @@ async function insertAdminWorkspace(id: string, ownerId: string) {
104103
})
105104
}
106105

107-
/** The ownership record a finished profile-picture upload leaves behind. */
108-
function completedAvatarUpload(userId: string, key: string) {
109-
return {
110-
id: generateId(),
111-
tokenHash: generateId(),
112-
userId,
113-
purpose: 'profile_picture' as const,
114-
method: 'put' as const,
115-
storageContext: 'profile-pictures',
116-
finalKey: key,
117-
storageProvider: 'local' as const,
118-
fileName: 'avatar.png',
119-
contentType: 'image/png',
120-
fileSize: 1,
121-
status: 'completed' as const,
122-
expiresAt: new Date(),
123-
completedAt: new Date(),
124-
}
125-
}
126-
127106
function avatarUrl(key: string): string {
128107
return `/api/files/serve/${encodeURIComponent(key)}?context=profile-pictures`
129108
}
@@ -251,12 +230,6 @@ async function seed() {
251230
logoUrl: `/api/files/serve/s3/${encodeURIComponent(keys.logoNamedByEncodedUrl)}`,
252231
})
253232
.where(eq(workspace.id, survivingId))
254-
await db
255-
.insert(uploadSession)
256-
.values([
257-
completedAvatarUpload(departingId, keys.ownAvatar),
258-
completedAvatarUpload(ownerId, keys.othersAvatar),
259-
])
260233
await db
261234
.update(user)
262235
.set({ image: avatarUrl(keys.ownAvatar) })
@@ -359,20 +332,15 @@ describe('account deletion storage sweep', () => {
359332
}
360333
)
361334

362-
check('never purges an avatar the account points at but did not upload', async () => {
335+
check("keeps another account's avatar the departing image points at", async () => {
363336
const f = await seed()
364337
await db
365338
.update(user)
366339
.set({ image: avatarUrl(f.keys.othersAvatar) })
367-
.where(eq(user.id, f.departingId))
340+
.where(inArray(user.id, [f.ownerId, f.departingId]))
368341
await deleteUserAccount(f.departingId)
369342

370-
const objects = await objectEvents(f.departingId)
371-
const queuedKeys = objects.flatMap((event) =>
372-
(event.payload as { objects: { key: string }[] }).objects.map((object) => object.key)
373-
)
374-
expect(queuedKeys).not.toContain(f.keys.othersAvatar)
375-
await runNow(objects.map((event) => event.id))
343+
await runNow((await objectEvents(f.departingId)).map((event) => event.id))
376344

377345
expect(await readFile(join(storageRoot, f.keys.othersAvatar), 'utf8')).toBe('x')
378346
})
@@ -403,7 +371,6 @@ afterAll(async () => {
403371
)
404372
)
405373
await db.delete(workspaceFiles).where(inArray(workspaceFiles.userId, userIds))
406-
await db.delete(uploadSession).where(inArray(uploadSession.userId, userIds))
407374
await deleteWorkspaceFixture(db, inArray(workspace.id, workspaceIds))
408375
await db.delete(user).where(inArray(user.id, userIds))
409376
await rm(storageRoot, { recursive: true, force: true })

‎apps/sim/lib/users/storage-sweep.test.ts‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,19 @@ describe('account storage object cleanup', () => {
361361
expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock[table])
362362
})
363363

364+
it("keeps an avatar another account's image still names", async () => {
365+
const key = 'profile-pictures/upload-0002-owner-avatar.png'
366+
await put(key)
367+
queueTableRows(schemaMock.user, [])
368+
queueTableRows(schemaMock.user, [
369+
{ id: 'owner-1', value: `/api/files/serve/s3/${encodeURIComponent(key)}` },
370+
])
371+
372+
await cleanup(payload([{ key, context: 'profile-pictures' }]), outboxContext())
373+
374+
expect(await exists(key)).toBe(true)
375+
})
376+
364377
it("deletes the account's own uploaded avatar", async () => {
365378
await put('profile-pictures/upload-0001-avatar.png')
366379

@@ -505,28 +518,16 @@ describe('queueing account storage cleanup', () => {
505518
queueTableRows(schemaMock.user, [
506519
{ image: '/api/files/serve/s3/profile-pictures%2Fupload-0001-avatar.png' },
507520
])
508-
queueTableRows(schemaMock.uploadSession, [{ id: 'upload-0001' }])
509521

510522
await queueCleanup()
511523

512524
expect(queuedObjectKeys()).toEqual(['profile-pictures/upload-0001-avatar.png'])
513525
})
514526

515-
it('never queues an avatar key the account did not upload', async () => {
516-
queueTableRows(schemaMock.user, [
517-
{ image: '/api/files/serve/s3/profile-pictures%2Fupload-0002-someone-else.png' },
518-
])
519-
520-
await queueCleanup()
521-
522-
expect(queuedObjectKeys()).toEqual([])
523-
})
524-
525527
it('never queues an avatar key that could escape its root', async () => {
526528
queueTableRows(schemaMock.user, [
527529
{ image: '/api/files/serve/profile-pictures%2Fx%2F..%2F..%2Fworkspace%2Fother.png' },
528530
])
529-
queueTableRows(schemaMock.uploadSession, [{ id: 'upload-0003' }])
530531

531532
await queueCleanup()
532533

‎apps/sim/lib/users/storage-sweep.ts‎

Lines changed: 9 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
copilotChats,
44
document,
55
knowledgeBase,
6-
uploadSession,
76
user,
87
workspaceFile,
98
workspaceFiles,
@@ -80,9 +79,8 @@ const OBJECT_CONTEXTS: ReadonlySet<string> = new Set<StorageContext>([
8079
/**
8180
* Keys no row collected here may name: Project prefix cleanup, the Assistant upload-session
8281
* sweep, organization logos, and profile pictures — which account deletion takes only from the
83-
* account's own `user.image`, and only once {@link uploadedProfilePicture} proves the account
84-
* uploaded it, because whitelabel logos were once uploaded as `profile-pictures` rows under an
85-
* admin's account.
82+
* account's own `user.image` (see {@link extractProfilePictureKey}), because whitelabel logos were
83+
* once uploaded as `profile-pictures` rows under an admin's account.
8684
*/
8785
const FOREIGN_LIFECYCLE_ROOTS = [
8886
'project/',
@@ -143,10 +141,12 @@ function isAccountProfilePicture(key: string, context: string): boolean {
143141
}
144142

145143
/**
146-
* The account's own uploaded avatar, as a storage key. `user.image` holds either a
147-
* `/api/files/serve/...` path (an upload this app owns) or an absolute URL from an OAuth provider,
148-
* which must not be deleted; only the former yields a key, and only under `profile-pictures/`.
149-
* The image is personal data, so an erasure that leaves it in the bucket is not an erasure.
144+
* The account's avatar, as a storage key. `user.image` holds either a `/api/files/serve/...` path
145+
* (an upload this app owns) or an absolute URL from an OAuth provider, which must not be deleted;
146+
* only the former yields a key, and only under `profile-pictures/`. The image is personal data, so
147+
* an erasure that leaves it in the bucket is not an erasure. `user.image` is user-settable, so the
148+
* key can name someone else's avatar; the cleanup's reference guard keeps any key another
149+
* account's `user.image` or a stored asset URL still names.
150150
*/
151151
export function extractProfilePictureKey(image: string | null): string | null {
152152
if (!image) return null
@@ -471,44 +471,6 @@ async function collectVersionsOf(
471471
)
472472
}
473473

474-
/**
475-
* Whether the account itself uploaded `key`. `user.image` is settable to any serve URL, so it
476-
* names a key without proving whose it is; the proof is the account's completed `profile_picture`
477-
* upload session (kept for a week after completion), or the `workspace_files` row an older
478-
* profile-picture upload wrote. Without either the avatar is left in place.
479-
*/
480-
async function uploadedProfilePicture(
481-
executor: DbOrTx,
482-
userId: string,
483-
key: string
484-
): Promise<boolean> {
485-
const [session] = await executor
486-
.select({ id: uploadSession.id })
487-
.from(uploadSession)
488-
.where(
489-
and(
490-
eq(uploadSession.finalKey, key),
491-
eq(uploadSession.userId, userId),
492-
eq(uploadSession.purpose, 'profile_picture'),
493-
eq(uploadSession.status, 'completed')
494-
)
495-
)
496-
.limit(1)
497-
if (session) return true
498-
const [row] = await executor
499-
.select({ id: workspaceFiles.id })
500-
.from(workspaceFiles)
501-
.where(
502-
and(
503-
eq(workspaceFiles.key, key),
504-
eq(workspaceFiles.userId, userId),
505-
eq(workspaceFiles.context, 'profile-pictures')
506-
)
507-
)
508-
.limit(1)
509-
return Boolean(row)
510-
}
511-
512474
interface CollectionScope {
513475
userId: string
514476
workspaceIds: string[]
@@ -534,8 +496,7 @@ async function collectInto(
534496
.where(eq(user.id, userId))
535497
.limit(1)
536498
const profilePicture = extractProfilePictureKey(account?.image ?? null)
537-
if (profilePicture && (await uploadedProfilePicture(executor, userId, profilePicture)))
538-
collector.addProfilePicture(profilePicture)
499+
if (profilePicture) collector.addProfilePicture(profilePicture)
539500

540501
const filesSince = since
541502
? and(eq(workspaceFiles.userId, userId), gte(workspaceFiles.uploadedAt, since))

0 commit comments

Comments
 (0)