Skip to content

Commit dd6ae55

Browse files
committed
fix(files): match chat upload names exactly in SQL with one bounded row
1 parent e8d7f7f commit dd6ae55

3 files changed

Lines changed: 33 additions & 20 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ describe('workspace file names in PostgreSQL', () => {
226226
'Quarterly Report.pdf',
227227
'Cafe\u0301 menu.png',
228228
'ring\u0007ing.png',
229+
'trail.png \u0007',
229230
]
230231
for (const name of names) {
231232
const row = await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, name)
@@ -257,7 +258,12 @@ describe('workspace file names in PostgreSQL', () => {
257258
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'notes 100%.png')
258259
await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'back\\slash.png')
259260

260-
for (const reference of ['uploads/a_b.png', 'uploads/notes.png', 'uploads/notes%20%25.png']) {
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+
]) {
261267
expect(
262268
await resolveWorkspaceFileReference(fixture.workspaceId, reference, {
263269
includeChatUploads: true,

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

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ 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'
2019
import { and, desc, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm'
2120
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
2221
import type { V2FileSortBy } from '@/lib/api/contracts/v2/files'
@@ -101,7 +100,7 @@ import {
101100
import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
102101
import { isMarkdownFile } from '@/lib/uploads/utils/file-utils'
103102
import type { ServableFile } from '@/lib/uploads/utils/file-utils.server'
104-
import { normalizeDisplaySegment } from '@/lib/vfs/path'
103+
import { displaySegmentPattern } from '@/lib/vfs/path'
105104
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
106105
import {
107106
MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES,
@@ -1580,36 +1579,33 @@ export function parseChatUploadReference(fileReference: string): string | null {
15801579
* Display names are unique per chat. Mothership supplies that namespace; callers
15811580
* without a chat scope retain the workspace-wide newest-name lookup.
15821581
*
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.
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.
15881587
*/
15891588
async function getChatUploadByName(
15901589
workspaceId: string,
15911590
name: string,
15921591
chatId?: string
15931592
): Promise<WorkspaceFileRecord | null> {
1594-
const storedName = sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`
1595-
const wordsInOrder = `%${name.split(' ').map(escapeLikePattern).join('%')}%`
1596-
const candidates = await db
1593+
const storedName = sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`
1594+
const [file] = await db
15971595
.select()
15981596
.from(workspaceFiles)
15991597
.where(
16001598
and(
16011599
eq(workspaceFiles.workspaceId, workspaceId),
16021600
eq(workspaceFiles.context, 'mothership'),
16031601
chatId === undefined ? undefined : eq(workspaceFiles.chatId, chatId),
1604-
sql`regexp_replace(normalize(${storedName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') LIKE ${wordsInOrder} ESCAPE '\\'`,
1602+
sql`regexp_replace(normalize(${storedName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') ~ ${displaySegmentPattern(name)}`,
16051603
isNull(workspaceFiles.deletedAt)
16061604
)
16071605
)
16081606
.orderBy(desc(workspaceFiles.uploadedAt))
1607+
.limit(1)
16091608

1610-
const file = candidates.find(
1611-
(candidate) => normalizeDisplaySegment(candidate.displayName ?? candidate.originalName) === name
1612-
)
16131609
return file ? mapChatUploadRecord(file, workspaceId) : null
16141610
}
16151611

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

Lines changed: 16 additions & 5 deletions
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) {
@@ -8,12 +13,18 @@ export class VfsPathError extends Error {
813
}
914
}
1015

16+
function normalizeDisplaySegment(segment: string): string {
17+
return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ')
18+
}
19+
1120
/**
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.
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.
1424
*/
15-
export function normalizeDisplaySegment(segment: string): string {
16-
return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ')
25+
export function displaySegmentPattern(name: string): string {
26+
const words = name.split(' ').map(escapeRegExp)
27+
return `^${WHITESPACE_CLASS}*${words.join(`${WHITESPACE_CLASS}+`)}${WHITESPACE_CLASS}*$`
1728
}
1829

1930
export function encodeVfsSegment(segment: string): string {

0 commit comments

Comments
 (0)