Skip to content

Commit e8d7f7f

Browse files
committed
fix(files): resolve chat uploads whose names are not in VFS form
1 parent 588b8ce commit e8d7f7f

4 files changed

Lines changed: 110 additions & 17 deletions

File tree

‎apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts‎

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
/** Real PostgreSQL name allocation and name lookups for workspace files, plus the URL fetch path. */
1+
/**
2+
* Real PostgreSQL name allocation and name lookups for workspace files and chat uploads, plus the
3+
* URL fetch path.
4+
*/
25
import { mkdtempSync } from 'node:fs'
36
import { rm } from 'node:fs/promises'
47
import { tmpdir } from 'node:os'
58
import path from 'node:path'
69
import { db, dbFor } from '@sim/db'
7-
import { organization, user, workspace, workspaceFiles } from '@sim/db/schema'
10+
import { copilotChats, organization, user, workspace, workspaceFiles } from '@sim/db/schema'
811
import { generateId } from '@sim/utils/id'
912
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
1013
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
@@ -29,8 +32,12 @@ import {
2932
workspaceFileNameFolderCondition,
3033
} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
3134
import {
35+
generateWorkspaceFileKey,
3236
getWorkspaceFileByName,
37+
resolveWorkspaceFileReference,
38+
trackChatUpload,
3339
uploadWorkspaceFile,
40+
workspaceFileVfsPath,
3441
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
3542
import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
3643

@@ -66,6 +73,19 @@ describe('workspace file names in PostgreSQL', () => {
6673
})
6774
}
6875

76+
async function seedChat(workspaceId: string, userId: string) {
77+
const chatId = generateId()
78+
await db.insert(copilotChats).values({ id: chatId, userId, workspaceId, type: 'mothership' })
79+
return chatId
80+
}
81+
82+
async function trackUpload(workspaceId: string, userId: string, chatId: string, name: string) {
83+
const key = generateWorkspaceFileKey(workspaceId, name)
84+
await trackChatUpload(workspaceId, userId, chatId, key, name, 'image/png', 10)
85+
const [row] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.key, key))
86+
return row
87+
}
88+
6989
async function parseExternalUrl(executionId?: string) {
7090
const fixture = await seedWorkspace()
7191
const url = 'https://example.com/page.txt'
@@ -194,4 +214,72 @@ describe('workspace file names in PostgreSQL', () => {
194214
for (const name of names) expect(name).toMatch(shortIdSuffixed)
195215
expect(new Set([next.name, ...names]).size).toBe(names.length + 1)
196216
})
217+
218+
it('resolves a chat upload by the path its upload notice prints when the name is not in VFS form', async () => {
219+
const fixture = await seedWorkspace()
220+
const chatId = await seedChat(fixture.workspaceId, fixture.aliceId)
221+
const otherChatId = await seedChat(fixture.workspaceId, fixture.aliceId)
222+
// macOS screenshot names carry U+202F before AM/PM, pasted names keep doubled spaces, some
223+
// pickers report decomposed (NFD) accents, and control characters drop out of VFS names.
224+
const names = [
225+
'Screenshot 2026-01-15 at 9.41.07\u202fAM.png',
226+
'Quarterly Report.pdf',
227+
'Cafe\u0301 menu.png',
228+
'ring\u0007ing.png',
229+
]
230+
for (const name of names) {
231+
const row = await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, name)
232+
const noticePath = workspaceFileVfsPath({ folderPath: null, name, vfsNamespace: 'uploads' })
233+
for (const reference of [noticePath, `uploads/${name}`]) {
234+
for (const options of [{ chatId }, {}]) {
235+
const record = await resolveWorkspaceFileReference(fixture.workspaceId, reference, {
236+
includeChatUploads: true,
237+
...options,
238+
})
239+
expect(record?.id).toBe(row.id)
240+
expect(record?.name).toBe(name)
241+
}
242+
}
243+
expect(
244+
await resolveWorkspaceFileReference(fixture.workspaceId, noticePath, {
245+
includeChatUploads: true,
246+
chatId: otherChatId,
247+
})
248+
).toBeNull()
249+
}
250+
})
251+
252+
it('matches a chat upload name exactly, never as a pattern or a fragment', async () => {
253+
const fixture = await seedWorkspace()
254+
const chatId = await seedChat(fixture.workspaceId, fixture.aliceId)
255+
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'axb.png')
256+
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'my notes.png.bak')
257+
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'notes 100%.png')
258+
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'back\\slash.png')
259+
260+
for (const reference of ['uploads/a_b.png', 'uploads/notes.png', 'uploads/notes%20%25.png']) {
261+
expect(
262+
await resolveWorkspaceFileReference(fixture.workspaceId, reference, {
263+
includeChatUploads: true,
264+
chatId,
265+
})
266+
).toBeNull()
267+
}
268+
expect(
269+
(
270+
await resolveWorkspaceFileReference(fixture.workspaceId, 'uploads/notes%20100%25.png', {
271+
includeChatUploads: true,
272+
chatId,
273+
})
274+
)?.name
275+
).toBe('notes 100%.png')
276+
expect(
277+
(
278+
await resolveWorkspaceFileReference(fixture.workspaceId, 'uploads/back%5Cslash.png', {
279+
includeChatUploads: true,
280+
chatId,
281+
})
282+
)?.name
283+
).toBe('back\\slash.png')
284+
})
197285
})

‎apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@sim/utils/errors'
1717
import { generateShortId } from '@sim/utils/id'
1818
import { omit } from '@sim/utils/object'
19+
import { escapeLikePattern } from '@sim/utils/string'
1920
import { and, desc, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm'
2021
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
2122
import type { V2FileSortBy } from '@/lib/api/contracts/v2/files'
@@ -100,6 +101,7 @@ import {
100101
import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
101102
import { isMarkdownFile } from '@/lib/uploads/utils/file-utils'
102103
import type { ServableFile } from '@/lib/uploads/utils/file-utils.server'
104+
import { normalizeDisplaySegment } from '@/lib/vfs/path'
103105
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
104106
import {
105107
MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES,
@@ -1577,30 +1579,37 @@ export function parseChatUploadReference(fileReference: string): string | null {
15771579
/**
15781580
* Display names are unique per chat. Mothership supplies that namespace; callers
15791581
* without a chat scope retain the workspace-wide newest-name lookup.
1582+
*
1583+
* `name` is in VFS display form ({@link normalizeDisplaySegment}), since that is what the
1584+
* upload notice's path encodes, while the stored name keeps the uploaded spelling (a macOS
1585+
* screenshot carries U+202F before AM/PM). The query narrows to names that, composed and
1586+
* stripped of control characters, hold each of `name`'s words in order — a superset of the
1587+
* exact match, which then compares the stored name in display form.
15801588
*/
15811589
async function getChatUploadByName(
15821590
workspaceId: string,
15831591
name: string,
15841592
chatId?: string
15851593
): Promise<WorkspaceFileRecord | null> {
1586-
const [file] = await db
1594+
const storedName = sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`
1595+
const wordsInOrder = `%${name.split(' ').map(escapeLikePattern).join('%')}%`
1596+
const candidates = await db
15871597
.select()
15881598
.from(workspaceFiles)
15891599
.where(
15901600
and(
15911601
eq(workspaceFiles.workspaceId, workspaceId),
15921602
eq(workspaceFiles.context, 'mothership'),
15931603
chatId === undefined ? undefined : eq(workspaceFiles.chatId, chatId),
1594-
or(
1595-
eq(workspaceFiles.displayName, name),
1596-
and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, name))
1597-
),
1604+
sql`regexp_replace(normalize(${storedName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') LIKE ${wordsInOrder} ESCAPE '\\'`,
15981605
isNull(workspaceFiles.deletedAt)
15991606
)
16001607
)
16011608
.orderBy(desc(workspaceFiles.uploadedAt))
1602-
.limit(1)
16031609

1610+
const file = candidates.find(
1611+
(candidate) => normalizeDisplaySegment(candidate.displayName ?? candidate.originalName) === name
1612+
)
16041613
return file ? mapChatUploadRecord(file, workspaceId) : null
16051614
}
16061615

‎apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,10 @@ describe('resolveWorkspaceFileReference', () => {
129129
expect(conditions).toContainEqual(
130130
expect.objectContaining({ type: 'isNull', column: schemaMock.workspaceFiles.deletedAt })
131131
)
132-
const nameMatch = conditions.find((condition) => condition.type === 'or')
133-
expect(nameMatch).toMatchObject({
134-
conditions: [
135-
{ type: 'eq', left: schemaMock.workspaceFiles.displayName, right: 'face (2).png' },
136-
expect.anything(),
137-
],
138-
})
139132
expect(dbChainMockFns.orderBy).toHaveBeenCalledWith({
140133
type: 'desc',
141134
column: schemaMock.workspaceFiles.uploadedAt,
142135
})
143-
expect(dbChainMockFns.limit).toHaveBeenCalledWith(1)
144136
/** Found by its own query: the listing fallback never ran. */
145137
expect(dbChainMockFns.from).toHaveBeenCalledTimes(1)
146138
})

‎apps/sim/lib/vfs/path.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ export class VfsPathError extends Error {
88
}
99
}
1010

11-
function normalizeDisplaySegment(segment: string): string {
11+
/**
12+
* The display form every VFS path segment is built from and decoded to: NFC, trimmed,
13+
* control characters removed, and each whitespace run (including U+202F) one space.
14+
*/
15+
export function normalizeDisplaySegment(segment: string): string {
1216
return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ')
1317
}
1418

0 commit comments

Comments
 (0)