Skip to content

Commit 13a02bd

Browse files
committed
fix(storage): validation pass — gcs serve-prefix parity in key parsers + CORS doc fix
- extractStorageKey, extractFilename, and extractEmbeddedFileRef now strip the gcs/ serve prefix like s3/ and blob/, so direct-uploaded files on GCS parse, delete, download, and embed correctly (previously only the serve route knew the prefix) - file-download storageProvider union includes 'gcs' - completeGcsMultipartUpload defensively rejects a 200 response carrying an XML error document - docs: CORS example lists concrete x-goog-meta-* header names (GCS matches responseHeader entries exactly; wildcards are only supported for origin)
1 parent d6feccb commit 13a02bd

10 files changed

Lines changed: 245 additions & 11 deletions

File tree

apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,17 @@ cat > /tmp/cors.json <<'EOF'
237237
{
238238
"origin": ["https://your-sim-domain.com"],
239239
"method": ["GET", "PUT"],
240-
"responseHeader": ["Content-Type", "ETag", "x-goog-meta-*"],
240+
"responseHeader": [
241+
"Content-Type",
242+
"ETag",
243+
"x-goog-meta-originalname",
244+
"x-goog-meta-uploadedat",
245+
"x-goog-meta-purpose",
246+
"x-goog-meta-userid",
247+
"x-goog-meta-workspaceid",
248+
"x-goog-meta-workflowid",
249+
"x-goog-meta-executionid"
250+
],
241251
"maxAgeSeconds": 3600
242252
}
243253
]
@@ -250,7 +260,7 @@ done
250260
```
251261

252262
<Callout type="info">
253-
`ETag` must be in `responseHeader` large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise.
263+
Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `ETag` is required because large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise.
254264
</Callout>
255265

256266
</Step>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
5+
const CONFIG = {
6+
/** Debounce window before flushing buffered events to the server. */
7+
FLUSH_MS: 1500,
8+
/** Max buffered events before forcing an early flush. */
9+
MAX_BUFFER: 500,
10+
/** Ancestor display-name pattern that defines the in-scope subtree. */
11+
SCOPE: /copilot|panel/i,
12+
/** How far up the fiber tree to look for an in-scope ancestor. */
13+
MAX_DEPTH: 80,
14+
/** Poll attempts while waiting for the React Scan global to load. */
15+
HOOK_RETRIES: 50,
16+
HOOK_RETRY_MS: 200,
17+
} as const
18+
19+
interface RenderEvent {
20+
t: number
21+
scope: string
22+
name: string
23+
phase?: string
24+
count: number
25+
time: number | null
26+
unnecessary: boolean
27+
}
28+
29+
interface ScanFiber {
30+
type?: { displayName?: string; name?: string } | null
31+
return?: ScanFiber | null
32+
}
33+
34+
interface ScanRender {
35+
phase?: string
36+
count?: number
37+
time?: number
38+
unnecessary?: boolean
39+
}
40+
41+
type ScanFn = (options: {
42+
onRender?: (fiber: ScanFiber, renders: ScanRender | ScanRender[]) => void
43+
}) => void
44+
45+
function fiberName(fiber: ScanFiber | null | undefined): string | null {
46+
const type = fiber?.type
47+
if (!type) return null
48+
return type.displayName || type.name || null
49+
}
50+
51+
/** Returns the matching ancestor name if this fiber is within the copilot subtree. */
52+
function scopeOf(fiber: ScanFiber): string | null {
53+
let current: ScanFiber | null | undefined = fiber
54+
let depth = 0
55+
while (current && depth < CONFIG.MAX_DEPTH) {
56+
const name = fiberName(current)
57+
if (name && CONFIG.SCOPE.test(name)) return name
58+
current = current.return
59+
depth += 1
60+
}
61+
return null
62+
}
63+
64+
/**
65+
* Subscribes to React Scan's global render stream, filters to the copilot
66+
* panel subtree, and ships batched render telemetry to the dev-only collector
67+
* route. Rendered only when `isReactScanEnabled` (dev + `REACT_SCAN_ENABLED`),
68+
* so it is a no-op in production builds.
69+
*/
70+
export function ReactScanCollector() {
71+
useEffect(() => {
72+
let buffer: RenderEvent[] = []
73+
let timer: number | null = null
74+
let stopped = false
75+
76+
const flush = () => {
77+
timer = null
78+
if (buffer.length === 0) return
79+
const batch = buffer
80+
buffer = []
81+
// boundary-raw-fetch: dev-only debug telemetry, schema-less, must not run through a contract
82+
void fetch('/api/_dev/react-scan', {
83+
method: 'POST',
84+
headers: { 'content-type': 'application/json' },
85+
body: JSON.stringify({ events: batch }),
86+
keepalive: true,
87+
}).catch(() => {})
88+
}
89+
90+
const schedule = () => {
91+
if (buffer.length >= CONFIG.MAX_BUFFER) {
92+
if (timer !== null) window.clearTimeout(timer)
93+
flush()
94+
return
95+
}
96+
if (timer === null) timer = window.setTimeout(flush, CONFIG.FLUSH_MS)
97+
}
98+
99+
const onRender = (fiber: ScanFiber, renders: ScanRender | ScanRender[]) => {
100+
if (stopped) return
101+
const scope = scopeOf(fiber)
102+
if (!scope) return
103+
const name = fiberName(fiber) ?? 'unknown'
104+
const list = Array.isArray(renders) ? renders : [renders]
105+
for (const render of list) {
106+
buffer.push({
107+
t: Date.now(),
108+
scope,
109+
name,
110+
phase: render?.phase,
111+
count: render?.count ?? 1,
112+
time: typeof render?.time === 'number' ? Math.round(render.time * 100) / 100 : null,
113+
unnecessary: Boolean(render?.unnecessary),
114+
})
115+
}
116+
schedule()
117+
}
118+
119+
let attempts = 0
120+
const hook = () => {
121+
if (stopped) return
122+
const scan = (window as unknown as { reactScan?: ScanFn }).reactScan
123+
if (typeof scan !== 'function') {
124+
if (attempts < CONFIG.HOOK_RETRIES) {
125+
attempts += 1
126+
window.setTimeout(hook, CONFIG.HOOK_RETRY_MS)
127+
}
128+
return
129+
}
130+
scan({ onRender })
131+
}
132+
hook()
133+
134+
return () => {
135+
stopped = true
136+
if (timer !== null) window.clearTimeout(timer)
137+
flush()
138+
}
139+
}, [])
140+
141+
return null
142+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { promises as fs } from 'fs'
2+
import path from 'path'
3+
import { createLogger } from '@sim/logger'
4+
import type { NextRequest } from 'next/server'
5+
import { NextResponse } from 'next/server'
6+
import { isReactScanEnabled } from '@/lib/core/config/env-flags'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
9+
const logger = createLogger('ReactScanCollector')
10+
11+
const OUT_DIR = path.join(process.cwd(), '.react-scan')
12+
const OUT_FILE = path.join(OUT_DIR, 'copilot-renders.jsonl')
13+
14+
/**
15+
* Dev-only sink for React Scan render telemetry collected from the browser.
16+
*
17+
* The client collector ({@link ReactScanCollector}) buffers per-render events
18+
* scoped to the copilot panel and POSTs them here; this handler appends them as
19+
* JSONL to `.react-scan/copilot-renders.jsonl` so the data can be inspected
20+
* directly from disk instead of the browser toolbar. Gated on
21+
* `isReactScanEnabled` (dev + `REACT_SCAN_ENABLED`); 404s otherwise.
22+
*/
23+
export const POST = withRouteHandler(async (request: NextRequest) => {
24+
if (!isReactScanEnabled) return new NextResponse(null, { status: 404 })
25+
26+
// boundary-raw-json: dev-only debug telemetry envelope, intentionally schema-less
27+
const body = (await request.json().catch(() => ({}))) as { events?: unknown[] }
28+
const events = Array.isArray(body.events) ? body.events : []
29+
if (events.length === 0) return NextResponse.json({ written: 0 })
30+
31+
await fs.mkdir(OUT_DIR, { recursive: true })
32+
const lines = `${events.map((event: unknown) => JSON.stringify(event)).join('\n')}\n`
33+
await fs.appendFile(OUT_FILE, lines, 'utf8')
34+
35+
logger.info('Appended React Scan events', { count: events.length })
36+
return NextResponse.json({ written: events.length })
37+
})
38+
39+
/** Reset the collection file so each investigation run starts clean. */
40+
export const DELETE = withRouteHandler(async () => {
41+
if (!isReactScanEnabled) return new NextResponse(null, { status: 404 })
42+
await fs.rm(OUT_FILE, { force: true })
43+
return NextResponse.json({ ok: true })
44+
})

apps/sim/app/api/files/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ export function extractFilename(path: string): string {
9999
.replace(/\/\.\./g, '')
100100
.replace(/\.\.\//g, '')
101101

102-
if (filename.startsWith('s3/') || filename.startsWith('blob/')) {
102+
if (filename.startsWith('s3/') || filename.startsWith('blob/') || filename.startsWith('gcs/')) {
103103
const parts = filename.split('/')
104-
const prefix = parts[0] // 's3' or 'blob'
104+
const prefix = parts[0] // 's3', 'blob', or 'gcs'
105105
const keyParts = parts.slice(1)
106106

107107
const sanitizedKeyParts = keyParts

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface FileData {
1515
type: string
1616
key: string
1717
url: string
18-
storageProvider?: 's3' | 'blob' | 'local'
18+
storageProvider?: 's3' | 'blob' | 'gcs' | 'local'
1919
bucketName?: string
2020
}
2121

apps/sim/lib/uploads/providers/gcs/client.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -538,10 +538,24 @@ export async function completeGcsMultipartUpload(
538538
.join('')
539539
const body = `<CompleteMultipartUpload>${partsXml}</CompleteMultipartUpload>`
540540

541-
await gcsXmlApiRequest('POST', config.bucket, key, `uploadId=${encodeURIComponent(uploadId)}`, {
542-
headers: { 'Content-Type': 'application/xml' },
543-
body,
544-
})
541+
const response = await gcsXmlApiRequest(
542+
'POST',
543+
config.bucket,
544+
key,
545+
`uploadId=${encodeURIComponent(uploadId)}`,
546+
{
547+
headers: { 'Content-Type': 'application/xml' },
548+
body,
549+
}
550+
)
551+
552+
// The S3 XML dialect permits a 200 response carrying an error document; GCS
553+
// does not document that behavior, but checking is cheap insurance against
554+
// reporting a failed completion as success.
555+
const responseXml = await response.text()
556+
if (responseXml.includes('<Error')) {
557+
throw new Error(`GCS multipart completion failed for ${key}: ${responseXml.slice(0, 500)}`)
558+
}
545559

546560
return {
547561
location: buildGcsObjectUrl(config.bucket, key),

apps/sim/lib/uploads/utils/embedded-image-ref.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('extractEmbeddedFileRef', () => {
1414
})
1515
expect(extractEmbeddedFileRef(`/api/files/serve/s3/${ENCODED}`)).toEqual({ key: KEY })
1616
expect(extractEmbeddedFileRef(`/api/files/serve/blob/${ENCODED}`)).toEqual({ key: KEY })
17+
expect(extractEmbeddedFileRef(`/api/files/serve/gcs/${ENCODED}`)).toEqual({ key: KEY })
1718
})
1819

1920
it('parses view-url and in-app-path embeds to the file id', () => {

apps/sim/lib/uploads/utils/embedded-image-ref.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const EMBED_URL_RE =
2424

2525
/**
2626
* Parse a single embed `src` into the workspace file it references, normalizing the spellings the
27-
* editor and file agent produce: `/api/files/serve/<key>` (incl. `s3/`/`blob/` prefixes), `/api/files/view/<id>`,
27+
* editor and file agent produce: `/api/files/serve/<key>` (incl. `s3/`/`blob/`/`gcs/` prefixes), `/api/files/view/<id>`,
2828
* and the in-app path `/workspace/<wsId>/files/<id>`. Returns null for absolute, `data:`, or non-workspace
2929
* URLs (e.g. public `profile-pictures/` assets), which render as-is.
3030
*/
@@ -35,7 +35,9 @@ export function extractEmbeddedFileRef(src: string): EmbeddedFileRef {
3535
const segs = parsed.pathname.split('/')
3636
if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'serve') {
3737
let keySegs = segs.slice(4)
38-
if (keySegs[0] === 's3' || keySegs[0] === 'blob') keySegs = keySegs.slice(1)
38+
if (keySegs[0] === 's3' || keySegs[0] === 'blob' || keySegs[0] === 'gcs') {
39+
keySegs = keySegs.slice(1)
40+
}
3941
const raw = keySegs.join('/')
4042
if (!raw) return null
4143
const key = decodeURIComponent(raw)

apps/sim/lib/uploads/utils/file-utils.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { createLogger } from '@sim/logger'
55
import { describe, expect, it } from 'vitest'
66
import {
7+
extractStorageKey,
78
inferContextFromKey,
89
isAbortError,
910
isInternalFileUrl,
@@ -14,6 +15,24 @@ import {
1415

1516
const logger = createLogger('FileUtilsTest')
1617

18+
describe('extractStorageKey', () => {
19+
it('strips every provider serve prefix', () => {
20+
expect(extractStorageKey('/api/files/serve/s3/workspace%2Fws-1%2Ffile.txt')).toBe(
21+
'workspace/ws-1/file.txt'
22+
)
23+
expect(extractStorageKey('/api/files/serve/blob/workspace%2Fws-1%2Ffile.txt')).toBe(
24+
'workspace/ws-1/file.txt'
25+
)
26+
expect(extractStorageKey('/api/files/serve/gcs/workspace%2Fws-1%2Ffile.txt')).toBe(
27+
'workspace/ws-1/file.txt'
28+
)
29+
})
30+
31+
it('returns unprefixed serve keys as-is', () => {
32+
expect(extractStorageKey('/api/files/serve/kb/123-doc.pdf')).toBe('kb/123-doc.pdf')
33+
})
34+
})
35+
1736
describe('isInternalFileUrl', () => {
1837
it('classifies relative serve paths as internal', () => {
1938
expect(isInternalFileUrl('/api/files/serve/kb/123-file.pdf')).toBe(true)

apps/sim/lib/uploads/utils/file-utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,8 @@ export function extractStorageKey(filePath: string): string {
532532
key = key.substring(3)
533533
} else if (key.startsWith('blob/')) {
534534
key = key.substring(5)
535+
} else if (key.startsWith('gcs/')) {
536+
key = key.substring(4)
535537
}
536538
return key
537539
}

0 commit comments

Comments
 (0)