Skip to content

Commit 81ca970

Browse files
fix(files): validate fileId in csv-preview route, guard double-import, fix sniff perf and toggle flash
1 parent 0321342 commit 81ca970

4 files changed

Lines changed: 35 additions & 14 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice'
8-
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
8+
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
99
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1010

1111
const logger = createLogger('WorkspaceCsvPreviewAPI')
@@ -23,21 +23,27 @@ export const GET = withRouteHandler(
2323

2424
const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context)
2525
if (!parsed.success) return parsed.response
26-
const { id: workspaceId } = parsed.data.params
26+
const { id: workspaceId, fileId } = parsed.data.params
2727
const { key } = parsed.data.query
2828

2929
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
3030
if (!permission) {
3131
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
3232
}
3333

34-
// The key is client-supplied — confine it to this workspace's storage prefix so a caller
35-
// can't read another workspace's object.
36-
if (parseWorkspaceFileKey(key) !== workspaceId) {
37-
return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 })
34+
// Resolve the file record (active, in this workspace) and read from its authoritative key —
35+
// never the client-supplied one. This rejects archived/deleted files and keys with no live
36+
// row, matching the access guarantees of /api/files/serve.
37+
const record = await getWorkspaceFile(workspaceId, fileId)
38+
if (!record || record.key !== key) {
39+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
3840
}
3941

40-
const slice = await getCsvPreviewSlice({ key, context: 'workspace', signal: request.signal })
42+
const slice = await getCsvPreviewSlice({
43+
key: record.key,
44+
context: 'workspace',
45+
signal: request.signal,
46+
})
4147

4248
logger.info('CSV preview served', {
4349
workspaceId,

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ export function useCsvTruncationImport(
2424
const router = useRouter()
2525
const importFile = useImportFileAsTable()
2626

27+
// Guards against a double-tap on the toast action kicking off two parallel imports of the same
28+
// file. Reset once the kickoff settles so a failed import can be retried.
29+
const importingRef = useRef(false)
30+
2731
const importAsTable = useCallback(() => {
32+
if (importingRef.current) return
33+
importingRef.current = true
2834
const pendingId = `pending_${generateId()}`
2935
useImportTrayStore
3036
.getState()
@@ -39,8 +45,10 @@ export function useCsvTruncationImport(
3945
importFile.mutate(
4046
{ workspaceId, fileKey: file.key, fileName: file.name },
4147
{
42-
onSuccess: () => useImportTrayStore.getState().endUpload(pendingId),
43-
onError: () => useImportTrayStore.getState().endUpload(pendingId),
48+
onSettled: () => {
49+
importingRef.current = false
50+
useImportTrayStore.getState().endUpload(pendingId)
51+
},
4452
}
4553
)
4654
// importFile.mutate and router are stable references

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const MothershipView = memo(
8888

8989
// A large CSV renders read-only (streamed) with no editor, so it must not offer the
9090
// edit/split/preview toggle. Its size lives on the file record, not the resource tab.
91-
const { data: files } = useWorkspaceFiles(workspaceId, 'active', {
91+
const { data: files, isLoading: filesLoading } = useWorkspaceFiles(workspaceId, 'active', {
9292
enabled: active?.type === 'file',
9393
})
9494
const activeFile = active?.type === 'file' ? files?.find((f) => f.id === active.id) : undefined
@@ -97,6 +97,9 @@ export const MothershipView = memo(
9797
canEdit &&
9898
active?.type === 'file' &&
9999
RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title)) &&
100+
// Wait for the record before deciding — otherwise the toggle flashes on for a large CSV
101+
// until its size loads and we can tell it's read-only.
102+
!filesLoading &&
100103
!(activeFile && isCsvStreamOnly(activeFile))
101104

102105
return (

apps/sim/lib/file-parsers/csv-preview-slice.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export async function getCsvPreviewSlice({
5959

6060
try {
6161
// Pull chunks until the first newline so the delimiter can be sniffed before parsing.
62+
// Accumulate the header line incrementally — appending each chunk's decoded text rather than
63+
// re-concatenating the whole buffer each iteration (which would be O(n²) for a header split
64+
// across many small chunks). The delimiter chars (`,` `\t` `;`) are ASCII, so a multi-byte
65+
// character split at a chunk boundary can't introduce a false delimiter into the count.
6266
const sniffed: Buffer[] = []
6367
let firstLine = ''
6468
let sniffedBytes = 0
@@ -68,13 +72,13 @@ export async function getCsvPreviewSlice({
6872
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
6973
sniffed.push(chunk)
7074
sniffedBytes += chunk.length
71-
const combined = Buffer.concat(sniffed).toString('utf-8')
72-
const nl = combined.indexOf('\n')
75+
const text = chunk.toString('utf-8')
76+
const nl = text.indexOf('\n')
7377
if (nl !== -1) {
74-
firstLine = combined.slice(0, nl)
78+
firstLine += text.slice(0, nl)
7579
break
7680
}
77-
firstLine = combined
81+
firstLine += text
7882
if (sniffedBytes >= DELIMITER_SNIFF_MAX_BYTES) break
7983
}
8084

0 commit comments

Comments
 (0)