diff --git a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts index f4baa51c973..e8f2d62cdc8 100644 --- a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts +++ b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts @@ -1,10 +1,13 @@ -/** Real PostgreSQL name allocation and name lookups for workspace files, plus the URL fetch path. */ +/** + * Real PostgreSQL name allocation and name lookups for workspace files and chat uploads, plus the + * URL fetch path. + */ import { mkdtempSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { db, dbFor } from '@sim/db' -import { organization, user, workspace, workspaceFiles } from '@sim/db/schema' +import { copilotChats, organization, user, workspace, workspaceFiles } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' @@ -29,8 +32,12 @@ import { workspaceFileNameFolderCondition, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { + generateWorkspaceFileKey, getWorkspaceFileByName, + resolveWorkspaceFileReference, + trackChatUpload, uploadWorkspaceFile, + workspaceFileVfsPath, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' @@ -66,6 +73,19 @@ describe('workspace file names in PostgreSQL', () => { }) } + async function seedChat(workspaceId: string, userId: string) { + const chatId = generateId() + await db.insert(copilotChats).values({ id: chatId, userId, workspaceId, type: 'mothership' }) + return chatId + } + + async function trackUpload(workspaceId: string, userId: string, chatId: string, name: string) { + const key = generateWorkspaceFileKey(workspaceId, name) + await trackChatUpload(workspaceId, userId, chatId, key, name, 'image/png', 10) + const [row] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.key, key)) + return row + } + async function parseExternalUrl(executionId?: string) { const fixture = await seedWorkspace() const url = 'https://example.com/page.txt' @@ -194,4 +214,78 @@ describe('workspace file names in PostgreSQL', () => { for (const name of names) expect(name).toMatch(shortIdSuffixed) expect(new Set([next.name, ...names]).size).toBe(names.length + 1) }) + + it('resolves a chat upload by the path its upload notice prints when the name is not in VFS form', async () => { + const fixture = await seedWorkspace() + const chatId = await seedChat(fixture.workspaceId, fixture.aliceId) + const otherChatId = await seedChat(fixture.workspaceId, fixture.aliceId) + // macOS screenshot names carry U+202F before AM/PM, pasted names keep doubled spaces, some + // pickers report decomposed (NFD) accents, and control characters drop out of VFS names. + const names = [ + 'Screenshot 2026-01-15 at 9.41.07\u202fAM.png', + 'Quarterly Report.pdf', + 'Cafe\u0301 menu.png', + 'ring\u0007ing.png', + 'trail.png \u0007', + ] + for (const name of names) { + const row = await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, name) + const noticePath = workspaceFileVfsPath({ folderPath: null, name, vfsNamespace: 'uploads' }) + for (const reference of [noticePath, `uploads/${name}`]) { + for (const options of [{ chatId }, {}]) { + const record = await resolveWorkspaceFileReference(fixture.workspaceId, reference, { + includeChatUploads: true, + ...options, + }) + expect(record?.id).toBe(row.id) + expect(record?.name).toBe(name) + } + } + expect( + await resolveWorkspaceFileReference(fixture.workspaceId, noticePath, { + includeChatUploads: true, + chatId: otherChatId, + }) + ).toBeNull() + } + }) + + it('matches a chat upload name exactly, never as a pattern or a fragment', async () => { + const fixture = await seedWorkspace() + const chatId = await seedChat(fixture.workspaceId, fixture.aliceId) + await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'axb.png') + await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'my notes.png.bak') + await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'notes 100%.png') + await trackUpload(fixture.workspaceId, fixture.aliceId, chatId, 'back\\slash.png') + + for (const reference of [ + 'uploads/a_b.png', + 'uploads/a.b.png', + 'uploads/notes.png', + 'uploads/notes%20%25.png', + ]) { + expect( + await resolveWorkspaceFileReference(fixture.workspaceId, reference, { + includeChatUploads: true, + chatId, + }) + ).toBeNull() + } + expect( + ( + await resolveWorkspaceFileReference(fixture.workspaceId, 'uploads/notes%20100%25.png', { + includeChatUploads: true, + chatId, + }) + )?.name + ).toBe('notes 100%.png') + expect( + ( + await resolveWorkspaceFileReference(fixture.workspaceId, 'uploads/back%5Cslash.png', { + includeChatUploads: true, + chatId, + }) + )?.name + ).toBe('back\\slash.png') + }) }) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 71af2ae2c9e..57161c2058c 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -100,6 +100,7 @@ import { import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import type { ServableFile } from '@/lib/uploads/utils/file-utils.server' +import { displaySegmentPattern } from '@/lib/vfs/path' import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' import { MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES, @@ -1577,12 +1578,19 @@ export function parseChatUploadReference(fileReference: string): string | null { /** * Display names are unique per chat. Mothership supplies that namespace; callers * without a chat scope retain the workspace-wide newest-name lookup. + * + * `name` is decoded from the path the upload notice prints, which VFS encoding normalizes + * (NFC, control characters removed, whitespace runs collapsed and trimmed), while the stored + * name keeps the uploaded spelling: a macOS screenshot carries U+202F before AM/PM. The stored + * name is composed and stripped of control characters in SQL, and + * {@link displaySegmentPattern} matches its whitespace the way the encoding collapses it. */ async function getChatUploadByName( workspaceId: string, name: string, chatId?: string ): Promise { + const storedName = sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})` const [file] = await db .select() .from(workspaceFiles) @@ -1591,10 +1599,7 @@ async function getChatUploadByName( eq(workspaceFiles.workspaceId, workspaceId), eq(workspaceFiles.context, 'mothership'), chatId === undefined ? undefined : eq(workspaceFiles.chatId, chatId), - or( - eq(workspaceFiles.displayName, name), - and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, name)) - ), + sql`regexp_replace(normalize(${storedName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') ~ ${displaySegmentPattern(name)}`, isNull(workspaceFiles.deletedAt) ) ) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts index 123a034d2dc..c13b773d230 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts @@ -129,18 +129,10 @@ describe('resolveWorkspaceFileReference', () => { expect(conditions).toContainEqual( expect.objectContaining({ type: 'isNull', column: schemaMock.workspaceFiles.deletedAt }) ) - const nameMatch = conditions.find((condition) => condition.type === 'or') - expect(nameMatch).toMatchObject({ - conditions: [ - { type: 'eq', left: schemaMock.workspaceFiles.displayName, right: 'face (2).png' }, - expect.anything(), - ], - }) expect(dbChainMockFns.orderBy).toHaveBeenCalledWith({ type: 'desc', column: schemaMock.workspaceFiles.uploadedAt, }) - expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) /** Found by its own query: the listing fallback never ran. */ expect(dbChainMockFns.from).toHaveBeenCalledTimes(1) }) diff --git a/apps/sim/lib/vfs/path.ts b/apps/sim/lib/vfs/path.ts index 2b8848b2ed3..483b2c1a30e 100644 --- a/apps/sim/lib/vfs/path.ts +++ b/apps/sim/lib/vfs/path.ts @@ -1,5 +1,10 @@ +import { escapeRegExp } from '@sim/utils/string' + const CONTROL_CHARS = /[\x00-\x1f\x7f]/g -const WHITESPACE = /\s+/g +/** The characters `\s` matches, spelled out so a PostgreSQL pattern can share the class. */ +const WHITESPACE_CLASS = + '[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]' +const WHITESPACE = new RegExp(`${WHITESPACE_CLASS}+`, 'g') export class VfsPathError extends Error { constructor(message: string) { @@ -12,6 +17,16 @@ function normalizeDisplaySegment(segment: string): string { return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') } +/** + * Anchored regular expression, valid in JavaScript and PostgreSQL, that matches every + * NFC-composed, control-character-free name whose segment decodes to `name`: the words of + * `name` (already in decoded form) separated by whitespace runs, with any whitespace around them. + */ +export function displaySegmentPattern(name: string): string { + const words = name.split(' ').map(escapeRegExp) + return `^${WHITESPACE_CLASS}*${words.join(`${WHITESPACE_CLASS}+`)}${WHITESPACE_CLASS}*$` +} + export function encodeVfsSegment(segment: string): string { const normalized = normalizeDisplaySegment(segment) if (!normalized || normalized === '.' || normalized === '..') {