diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index b495df731d5..7a01716b04e 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -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'), diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index c74679197b0..c7bd642db8e 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -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') @@ -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) diff --git a/apps/sim/app/api/v2/files/bulk-download/route.ts b/apps/sim/app/api/v2/files/bulk-download/route.ts index 06c7a9096af..b2d51b44d21 100644 --- a/apps/sim/app/api/v2/files/bulk-download/route.ts +++ b/apps/sim/app/api/v2/files/bulk-download/route.ts @@ -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 }) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index 6d4220fd1a2..f1ccd1ce2bd 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -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 }) diff --git a/apps/sim/lib/internal/file/operations.ts b/apps/sim/lib/internal/file/operations.ts index 1f432057b97..5ae07140097 100644 --- a/apps/sim/lib/internal/file/operations.ts +++ b/apps/sim/lib/internal/file/operations.ts @@ -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 } ) diff --git a/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts b/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts index d00eceb0fae..e13fbefd7df 100644 --- a/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.test.ts @@ -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')) diff --git a/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts b/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts index 23b04edbd3c..b290b20b78b 100644 --- a/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts +++ b/apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts @@ -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' @@ -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', @@ -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) { diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index b51a2d78102..fbc7a69c376 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -4,8 +4,10 @@ import { createLogger } from '@sim/logger' import { describe, expect, it } from 'vitest' import { + ensureFileNameExtension, extractStorageKey, extractWorkspaceIdFromStorageKey, + getExtensionFromMimeType, getMimeTypeFromExtension, inferContextFromKey, isAbortError, @@ -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') + }) +}) diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 3f0df1c73c8..b00ac1ab7ee 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -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' @@ -609,12 +610,25 @@ const MIME_TO_EXTENSION: Record = { } /** - * 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 } /** diff --git a/apps/sim/lib/uploads/zip-entry-path.test.ts b/apps/sim/lib/uploads/zip-entry-path.test.ts index c51d6080891..c8d8aae178c 100644 --- a/apps/sim/lib/uploads/zip-entry-path.test.ts +++ b/apps/sim/lib/uploads/zip-entry-path.test.ts @@ -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' }]) diff --git a/apps/sim/lib/uploads/zip-entry-path.ts b/apps/sim/lib/uploads/zip-entry-path.ts index 5f7cc254f3b..d3f4e895abd 100644 --- a/apps/sim/lib/uploads/zip-entry-path.ts +++ b/apps/sim/lib/uploads/zip-entry-path.ts @@ -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. */ @@ -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 { @@ -109,7 +112,7 @@ export function buildZipEntryPaths( const usedPaths = new Set() 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