Skip to content

Commit 1ebdccd

Browse files
authored
fix(files): resolve chat uploads whose names are not in VFS form (#8566)
* fix(files): resolve chat uploads whose names are not in VFS form * fix(files): match chat upload names exactly in SQL with one bounded row
1 parent c58697d commit 1ebdccd

4 files changed

Lines changed: 121 additions & 15 deletions

File tree

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

Lines changed: 96 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,78 @@ 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+
'trail.png \u0007',
230+
]
231+
for (const name of names) {
232+
const row = await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, name)
233+
const noticePath = workspaceFileVfsPath({ folderPath: null, name, vfsNamespace: 'uploads' })
234+
for (const reference of [noticePath, `uploads/${name}`]) {
235+
for (const options of [{ chatId }, {}]) {
236+
const record = await resolveWorkspaceFileReference(fixture.workspaceId, reference, {
237+
includeChatUploads: true,
238+
...options,
239+
})
240+
expect(record?.id).toBe(row.id)
241+
expect(record?.name).toBe(name)
242+
}
243+
}
244+
expect(
245+
await resolveWorkspaceFileReference(fixture.workspaceId, noticePath, {
246+
includeChatUploads: true,
247+
chatId: otherChatId,
248+
})
249+
).toBeNull()
250+
}
251+
})
252+
253+
it('matches a chat upload name exactly, never as a pattern or a fragment', async () => {
254+
const fixture = await seedWorkspace()
255+
const chatId = await seedChat(fixture.workspaceId, fixture.aliceId)
256+
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'axb.png')
257+
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'my notes.png.bak')
258+
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'notes 100%.png')
259+
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'back\\slash.png')
260+
261+
for (const reference of [
262+
'uploads/a_b.png',
263+
'uploads/a.b.png',
264+
'uploads/notes.png',
265+
'uploads/notes%20%25.png',
266+
]) {
267+
expect(
268+
await resolveWorkspaceFileReference(fixture.workspaceId, reference, {
269+
includeChatUploads: true,
270+
chatId,
271+
})
272+
).toBeNull()
273+
}
274+
expect(
275+
(
276+
await resolveWorkspaceFileReference(fixture.workspaceId, 'uploads/notes%20100%25.png', {
277+
includeChatUploads: true,
278+
chatId,
279+
})
280+
)?.name
281+
).toBe('notes 100%.png')
282+
expect(
283+
(
284+
await resolveWorkspaceFileReference(fixture.workspaceId, 'uploads/back%5Cslash.png', {
285+
includeChatUploads: true,
286+
chatId,
287+
})
288+
)?.name
289+
).toBe('back\\slash.png')
290+
})
197291
})

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
101101
import { isMarkdownFile } from '@/lib/uploads/utils/file-utils'
102102
import type { ServableFile } from '@/lib/uploads/utils/file-utils.server'
103+
import { displaySegmentPattern } from '@/lib/vfs/path'
103104
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
104105
import {
105106
MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES,
@@ -1577,12 +1578,19 @@ export function parseChatUploadReference(fileReference: string): string | null {
15771578
/**
15781579
* Display names are unique per chat. Mothership supplies that namespace; callers
15791580
* without a chat scope retain the workspace-wide newest-name lookup.
1581+
*
1582+
* `name` is decoded from the path the upload notice prints, which VFS encoding normalizes
1583+
* (NFC, control characters removed, whitespace runs collapsed and trimmed), while the stored
1584+
* name keeps the uploaded spelling: a macOS screenshot carries U+202F before AM/PM. The stored
1585+
* name is composed and stripped of control characters in SQL, and
1586+
* {@link displaySegmentPattern} matches its whitespace the way the encoding collapses it.
15801587
*/
15811588
async function getChatUploadByName(
15821589
workspaceId: string,
15831590
name: string,
15841591
chatId?: string
15851592
): Promise<WorkspaceFileRecord | null> {
1593+
const storedName = sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`
15861594
const [file] = await db
15871595
.select()
15881596
.from(workspaceFiles)
@@ -1591,10 +1599,7 @@ async function getChatUploadByName(
15911599
eq(workspaceFiles.workspaceId, workspaceId),
15921600
eq(workspaceFiles.context, 'mothership'),
15931601
chatId === undefined ? undefined : eq(workspaceFiles.chatId, chatId),
1594-
or(
1595-
eq(workspaceFiles.displayName, name),
1596-
and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, name))
1597-
),
1602+
sql`regexp_replace(normalize(${storedName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') ~ ${displaySegmentPattern(name)}`,
15981603
isNull(workspaceFiles.deletedAt)
15991604
)
16001605
)

‎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: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { escapeRegExp } from '@sim/utils/string'
2+
13
const CONTROL_CHARS = /[\x00-\x1f\x7f]/g
2-
const WHITESPACE = /\s+/g
4+
/** The characters `\s` matches, spelled out so a PostgreSQL pattern can share the class. */
5+
const WHITESPACE_CLASS =
6+
'[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]'
7+
const WHITESPACE = new RegExp(`${WHITESPACE_CLASS}+`, 'g')
38

49
export class VfsPathError extends Error {
510
constructor(message: string) {
@@ -12,6 +17,16 @@ function normalizeDisplaySegment(segment: string): string {
1217
return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ')
1318
}
1419

20+
/**
21+
* Anchored regular expression, valid in JavaScript and PostgreSQL, that matches every
22+
* NFC-composed, control-character-free name whose segment decodes to `name`: the words of
23+
* `name` (already in decoded form) separated by whitespace runs, with any whitespace around them.
24+
*/
25+
export function displaySegmentPattern(name: string): string {
26+
const words = name.split(' ').map(escapeRegExp)
27+
return `^${WHITESPACE_CLASS}*${words.join(`${WHITESPACE_CLASS}+`)}${WHITESPACE_CLASS}*$`
28+
}
29+
1530
export function encodeVfsSegment(segment: string): string {
1631
const normalized = normalizeDisplaySegment(segment)
1732
if (!normalized || normalized === '.' || normalized === '..') {

0 commit comments

Comments
 (0)