Skip to content

Commit 7f68209

Browse files
committed
Merge remote-tracking branch 'origin/staging' into codex/audit-pr-7488
2 parents adc82a3 + c762c57 commit 7f68209

11 files changed

Lines changed: 100 additions & 17 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ describe('extractFilename', () => {
178178
expect(response.headers.get('Content-Security-Policy')).toBeNull()
179179
})
180180

181+
it('appends the content-type extension to an extensionless download name', () => {
182+
const response = createFileResponse({
183+
buffer: Buffer.from('fake-image-data'),
184+
contentType: 'image/png',
185+
filename: 'navbar_2',
186+
})
187+
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="navbar_2.png"')
188+
})
189+
181190
it('defaults to a PRIVATE cache so access-verified content is never shared-cached', () => {
182191
const response = createFileResponse({
183192
buffer: Buffer.from('fake-image-data'),

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
isPayloadSizeLimitError,
55
readNodeStreamToBufferWithLimit,
66
} from '@/lib/core/utils/stream-limits'
7-
import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
7+
import { ensureFileNameExtension, sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
88

99
const logger = createLogger('FilesUtils')
1010

@@ -240,16 +240,13 @@ export function encodeFilenameForHeader(storageKey: string): string {
240240
return `filename="${asciiSafe}"; filename*=UTF-8''${encodeExtValue(filename)}`
241241
}
242242

243+
/**
244+
* Derives the served filename from the CALLER's content type (`getSecureFileHeaders`
245+
* downgrades `text/html`) before the header decision, so a derived `.html` name gets the
246+
* same forced-attachment treatment a stored `.html` file gets.
247+
*/
243248
export function createFileResponse(file: FileResponse): NextResponse {
244-
// Sim pages store an extensionless name and serve/download as compiled
245-
// HTML — re-append the extension so the saved file opens in a browser.
246-
// Decided from the CALLER's content type (getSecureFileHeaders downgrades
247-
// text/html), and BEFORE the header decision, so the .html name gets the
248-
// same forced-attachment treatment a legacy .html file gets.
249-
const servedFilename =
250-
file.contentType === 'text/html' && !/\.[A-Za-z0-9]{1,8}$/.test(file.filename)
251-
? `${file.filename}.html`
252-
: file.filename
249+
const servedFilename = ensureFileNameExtension(file.filename, file.contentType)
253250

254251
const { contentType, disposition } = getSecureFileHeaders(servedFilename, file.contentType)
255252

apps/sim/app/api/v2/files/bulk-download/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const GET = defineV2BinaryRoute({
5959
filesToZip.map((file) => ({
6060
name: file.name,
6161
folderPath: file.folderId ? folderPaths.get(file.folderId) : null,
62+
contentType: file.type,
6263
}))
6364
)
6465
const archive = new ZipArchive({ store: true })

apps/sim/app/api/workspaces/[id]/files/download/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const GET = defineInternalBinaryRoute({
4646
filesToZip.map((file) => ({
4747
name: file.name,
4848
folderPath: file.folderId ? folderPaths.get(file.folderId) : null,
49+
contentType: file.type,
4950
}))
5051
)
5152
const archive = new ZipArchive({ store: true })

apps/sim/lib/internal/file/operations.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1750,7 +1750,11 @@ export async function executeFileManageOperation(
17501750
// Mirror the workspace folder layout, dropping the ancestor chain the whole
17511751
// selection shares so archiving one folder does not nest it under its parents.
17521752
const entryPaths = buildZipEntryPaths(
1753-
archiveEntries.map((entry) => ({ name: entry.file.name, folderPath: entry.folderPath })),
1753+
archiveEntries.map((entry) => ({
1754+
name: entry.file.name,
1755+
folderPath: entry.folderPath,
1756+
contentType: entry.file.type,
1757+
})),
17541758
{ rebaseOnCommonFolder: true }
17551759
)
17561760

apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,25 @@ describe('fetchExternalUrlToWorkspace', () => {
234234
)
235235
})
236236

237+
it('names a bare download path by the response content type', async () => {
238+
secureFetchWithPinnedIPSpy.mockResolvedValueOnce(makeResponse('jpeg bytes', 'image/jpeg'))
239+
240+
const result = await fetchExternalUrlToWorkspace({
241+
url: 'https://cdn.example.com/assets/8f1c/download',
242+
userId: 'user-1',
243+
workspaceId: 'workspace-1',
244+
})
245+
246+
expect(result.filename).toBe('download.jpg')
247+
expect(uploadWorkspaceFileSpy).toHaveBeenCalledWith(
248+
'workspace-1',
249+
'user-1',
250+
expect.any(Buffer),
251+
'download.jpg',
252+
'image/jpeg'
253+
)
254+
})
255+
237256
it('forwards custom headers to the fetch', async () => {
238257
secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain'))
239258

apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
readResponseToBufferWithLimit,
1313
} from '@/lib/core/utils/stream-limits'
1414
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
15-
import { getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
15+
import { ensureFileNameExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
1616
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1717
import type { UserFile } from '@/executor/types'
1818

@@ -92,8 +92,8 @@ export async function fetchExternalUrlToWorkspace(
9292
throw new ExternalUrlValidationError(urlValidation.error)
9393
}
9494

95-
const filename = new URL(url).pathname.split('/').pop() || 'download'
96-
const extension = path.extname(filename).toLowerCase().substring(1)
95+
const pathFilename = new URL(url).pathname.split('/').pop() || 'download'
96+
const extension = path.extname(pathFilename).toLowerCase().substring(1)
9797

9898
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, {
9999
profile: 'contentFetch',
@@ -119,6 +119,7 @@ export async function fetchExternalUrlToWorkspace(
119119
})
120120

121121
const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension)
122+
const filename = ensureFileNameExtension(pathFilename, mimeType)
122123

123124
let savedWorkspaceFile: UserFile | undefined
124125
if (workspaceId && saveToWorkspace) {

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import { createLogger } from '@sim/logger'
55
import { describe, expect, it } from 'vitest'
66
import {
7+
ensureFileNameExtension,
78
extractStorageKey,
89
extractWorkspaceIdFromStorageKey,
10+
getExtensionFromMimeType,
911
getMimeTypeFromExtension,
1012
inferContextFromKey,
1113
isAbortError,
@@ -311,3 +313,25 @@ describe('resolveMediaMimeType', () => {
311313
expect(resolveMediaMimeType(null, 'weird.bin', 'video')).toBe('video/mp4')
312314
})
313315
})
316+
317+
describe('getExtensionFromMimeType', () => {
318+
it('ignores content-type parameters', () => {
319+
expect(getExtensionFromMimeType('image/png')).toBe('png')
320+
expect(getExtensionFromMimeType('text/html; charset=utf-8')).toBe('html')
321+
expect(getExtensionFromMimeType('application/octet-stream')).toBeNull()
322+
})
323+
})
324+
325+
describe('ensureFileNameExtension', () => {
326+
it('appends the content-type extension only when the name has none', () => {
327+
expect(ensureFileNameExtension('navbar_2', 'image/png')).toBe('navbar_2.png')
328+
expect(ensureFileNameExtension('download (641)', 'image/jpeg; charset=binary')).toBe(
329+
'download (641).jpg'
330+
)
331+
expect(ensureFileNameExtension('hero.png', 'image/jpeg')).toBe('hero.png')
332+
expect(ensureFileNameExtension('site.webmanifest', 'application/json')).toBe('site.webmanifest')
333+
expect(ensureFileNameExtension('Sim.ai <> RVTech', 'text/html')).toBe('Sim.ai <> RVTech.html')
334+
expect(ensureFileNameExtension('blob', 'application/octet-stream')).toBe('blob')
335+
expect(ensureFileNameExtension('blob', null)).toBe('blob')
336+
})
337+
})

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { omit } from '@sim/utils/object'
33
import type { StorageContext } from '@/lib/uploads'
44
import {
55
ACCEPTED_FILE_TYPES,
6+
isAlphanumericExtension,
67
SUPPORTED_ARCHIVE_EXTENSIONS,
78
SUPPORTED_DOCUMENT_EXTENSIONS,
89
} from '@/lib/uploads/utils/validation'
@@ -609,12 +610,25 @@ const MIME_TO_EXTENSION: Record<string, string> = {
609610
}
610611

611612
/**
612-
* Get file extension from MIME type
613+
* Get file extension from MIME type. Parameters such as `; charset=utf-8` are ignored.
613614
* @param mimeType - MIME type string
614615
* @returns File extension without dot, or null if not found
615616
*/
616617
export function getExtensionFromMimeType(mimeType: string): string | null {
617-
return MIME_TO_EXTENSION[mimeType.toLowerCase()] || null
618+
return MIME_TO_EXTENSION[mimeType.split(';')[0].trim().toLowerCase()] || null
619+
}
620+
621+
/**
622+
* Appends the extension the content type implies when a file name carries none, so a
623+
* saved copy opens in the right application.
624+
*/
625+
export function ensureFileNameExtension(
626+
fileName: string,
627+
contentType: string | null | undefined
628+
): string {
629+
if (!contentType || isAlphanumericExtension(getFileExtension(fileName))) return fileName
630+
const extension = getExtensionFromMimeType(contentType)
631+
return extension ? `${fileName}.${extension}` : fileName
618632
}
619633

620634
/**

apps/sim/lib/uploads/zip-entry-path.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ describe('buildZipEntryPaths', () => {
1919
])
2020
})
2121

22+
it('appends the content-type extension to an extensionless name', () => {
23+
expect(
24+
buildZipEntryPaths([
25+
{ name: 'navbar_2', folderPath: 'Screenshots', contentType: 'image/png' },
26+
{ name: 'navbar_2', folderPath: 'Screenshots', contentType: 'image/png' },
27+
{ name: 'readme', folderPath: null },
28+
])
29+
).toEqual(['Screenshots/navbar_2.png', 'Screenshots/navbar_2 (1).png', 'readme'])
30+
})
31+
2232
it('sanitizes a slash within one escaped folder name instead of nesting it', () => {
2333
expect(
2434
buildZipEntryPaths([{ name: 'contract.pdf', folderPath: 'Finance\\/Legal/Quarterly' }])

0 commit comments

Comments
 (0)