Skip to content

Commit b2dec5d

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 b2dec5d

8 files changed

Lines changed: 59 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>

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)