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
9 changes: 9 additions & 0 deletions apps/sim/app/api/files/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ describe('extractFilename', () => {
expect(response.headers.get('Content-Security-Policy')).toBeNull()
})

it('appends the content-type extension to an extensionless download name', () => {
const response = createFileResponse({
buffer: Buffer.from('fake-image-data'),
contentType: 'image/png',
filename: 'navbar_2',
})
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="navbar_2.png"')
})

it('defaults to a PRIVATE cache so access-verified content is never shared-cached', () => {
const response = createFileResponse({
buffer: Buffer.from('fake-image-data'),
Expand Down
17 changes: 7 additions & 10 deletions apps/sim/app/api/files/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
isPayloadSizeLimitError,
readNodeStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
import { ensureFileNameExtension, sanitizeFileKey } from '@/lib/uploads/utils/file-utils'

const logger = createLogger('FilesUtils')

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

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

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

Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/v2/files/bulk-download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const GET = defineV2BinaryRoute({
filesToZip.map((file) => ({
name: file.name,
folderPath: file.folderId ? folderPaths.get(file.folderId) : null,
contentType: file.type,
}))
)
const archive = new ZipArchive({ store: true })
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/workspaces/[id]/files/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const GET = defineInternalBinaryRoute({
filesToZip.map((file) => ({
name: file.name,
folderPath: file.folderId ? folderPaths.get(file.folderId) : null,
contentType: file.type,
}))
)
const archive = new ZipArchive({ store: true })
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/lib/internal/file/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1750,7 +1750,11 @@ export async function executeFileManageOperation(
// Mirror the workspace folder layout, dropping the ancestor chain the whole
// selection shares so archiving one folder does not nest it under its parents.
const entryPaths = buildZipEntryPaths(
archiveEntries.map((entry) => ({ name: entry.file.name, folderPath: entry.folderPath })),
archiveEntries.map((entry) => ({
name: entry.file.name,
folderPath: entry.folderPath,
contentType: entry.file.type,
})),
{ rebaseOnCommonFolder: true }
)

Expand Down
19 changes: 19 additions & 0 deletions apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,25 @@ describe('fetchExternalUrlToWorkspace', () => {
)
})

it('names a bare download path by the response content type', async () => {
secureFetchWithPinnedIPSpy.mockResolvedValueOnce(makeResponse('jpeg bytes', 'image/jpeg'))

const result = await fetchExternalUrlToWorkspace({
url: 'https://cdn.example.com/assets/8f1c/download',
userId: 'user-1',
workspaceId: 'workspace-1',
})

expect(result.filename).toBe('download.jpg')
expect(uploadWorkspaceFileSpy).toHaveBeenCalledWith(
'workspace-1',
'user-1',
expect.any(Buffer),
'download.jpg',
'image/jpeg'
)
})

it('forwards custom headers to the fetch', async () => {
secureFetchWithPinnedIPSpy.mockResolvedValue(makeResponse('bytes', 'text/plain'))

Expand Down
7 changes: 4 additions & 3 deletions apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
readResponseToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { ensureFileNameExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { UserFile } from '@/executor/types'

Expand Down Expand Up @@ -92,8 +92,8 @@ export async function fetchExternalUrlToWorkspace(
throw new ExternalUrlValidationError(urlValidation.error)
}

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

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

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

let savedWorkspaceFile: UserFile | undefined
if (workspaceId && saveToWorkspace) {
Expand Down
24 changes: 24 additions & 0 deletions apps/sim/lib/uploads/utils/file-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import { createLogger } from '@sim/logger'
import { describe, expect, it } from 'vitest'
import {
ensureFileNameExtension,
extractStorageKey,
extractWorkspaceIdFromStorageKey,
getExtensionFromMimeType,
getMimeTypeFromExtension,
inferContextFromKey,
isAbortError,
Expand Down Expand Up @@ -311,3 +313,25 @@ describe('resolveMediaMimeType', () => {
expect(resolveMediaMimeType(null, 'weird.bin', 'video')).toBe('video/mp4')
})
})

describe('getExtensionFromMimeType', () => {
it('ignores content-type parameters', () => {
expect(getExtensionFromMimeType('image/png')).toBe('png')
expect(getExtensionFromMimeType('text/html; charset=utf-8')).toBe('html')
expect(getExtensionFromMimeType('application/octet-stream')).toBeNull()
})
})

describe('ensureFileNameExtension', () => {
it('appends the content-type extension only when the name has none', () => {
expect(ensureFileNameExtension('navbar_2', 'image/png')).toBe('navbar_2.png')
expect(ensureFileNameExtension('download (641)', 'image/jpeg; charset=binary')).toBe(
'download (641).jpg'
)
expect(ensureFileNameExtension('hero.png', 'image/jpeg')).toBe('hero.png')
expect(ensureFileNameExtension('site.webmanifest', 'application/json')).toBe('site.webmanifest')
expect(ensureFileNameExtension('Sim.ai <> RVTech', 'text/html')).toBe('Sim.ai <> RVTech.html')
expect(ensureFileNameExtension('blob', 'application/octet-stream')).toBe('blob')
expect(ensureFileNameExtension('blob', null)).toBe('blob')
})
})
18 changes: 16 additions & 2 deletions apps/sim/lib/uploads/utils/file-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { omit } from '@sim/utils/object'
import type { StorageContext } from '@/lib/uploads'
import {
ACCEPTED_FILE_TYPES,
isAlphanumericExtension,
SUPPORTED_ARCHIVE_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
} from '@/lib/uploads/utils/validation'
Expand Down Expand Up @@ -609,12 +610,25 @@ const MIME_TO_EXTENSION: Record<string, string> = {
}

/**
* Get file extension from MIME type
* Get file extension from MIME type. Parameters such as `; charset=utf-8` are ignored.
* @param mimeType - MIME type string
* @returns File extension without dot, or null if not found
*/
export function getExtensionFromMimeType(mimeType: string): string | null {
return MIME_TO_EXTENSION[mimeType.toLowerCase()] || null
return MIME_TO_EXTENSION[mimeType.split(';')[0].trim().toLowerCase()] || null
}

/**
* Appends the extension the content type implies when a file name carries none, so a
* saved copy opens in the right application.
*/
export function ensureFileNameExtension(
fileName: string,
contentType: string | null | undefined
): string {
if (!contentType || isAlphanumericExtension(getFileExtension(fileName))) return fileName
const extension = getExtensionFromMimeType(contentType)
return extension ? `${fileName}.${extension}` : fileName
}

/**
Expand Down
10 changes: 10 additions & 0 deletions apps/sim/lib/uploads/zip-entry-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ describe('buildZipEntryPaths', () => {
])
})

it('appends the content-type extension to an extensionless name', () => {
expect(
buildZipEntryPaths([
{ name: 'navbar_2', folderPath: 'Screenshots', contentType: 'image/png' },
{ name: 'navbar_2', folderPath: 'Screenshots', contentType: 'image/png' },
{ name: 'readme', folderPath: null },
])
).toEqual(['Screenshots/navbar_2.png', 'Screenshots/navbar_2 (1).png', 'readme'])
})

it('sanitizes a slash within one escaped folder name instead of nesting it', () => {
expect(
buildZipEntryPaths([{ name: 'contract.pdf', folderPath: 'Finance\\/Legal/Quarterly' }])
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/lib/uploads/zip-entry-path.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ensureFileNameExtension } from '@/lib/uploads/utils/file-utils'
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'

/** Characters that are illegal in file names on common desktop platforms. */
Expand All @@ -9,6 +10,8 @@ export interface ZipEntrySource {
name: string
/** Workspace-root-relative folder path holding the file, or null when it sits at the root. */
folderPath?: string | null
/** Stored content type; supplies the extension when the name carries none. */
contentType?: string | null
}

export interface BuildZipEntryPathsOptions {
Expand Down Expand Up @@ -109,7 +112,7 @@ export function buildZipEntryPaths(
const usedPaths = new Set<string>()

return sources.map((source) => {
const leafName = toLeafName(source.name)
const leafName = ensureFileNameExtension(toLeafName(source.name), source.contentType)
const folderSegments = toSegments(source.folderPath).slice(rebaseLength)
const basePath = safeEntryPath([...folderSegments, leafName]) || leafName

Expand Down
Loading