Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<WorkspaceFileRecord | null> {
const storedName = sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`
const [file] = await db
.select()
.from(workspaceFiles)
Expand All @@ -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)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
17 changes: 16 additions & 1 deletion apps/sim/lib/vfs/path.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 === '..') {
Expand Down
Loading