Files
simstudioai--sim/apps/sim/lib/uploads/utils/file-utils.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

970 lines
28 KiB
TypeScript

import type { Logger } from '@sim/logger'
import { omit } from '@sim/utils/object'
import type { StorageContext } from '@/lib/uploads'
import { ACCEPTED_FILE_TYPES, SUPPORTED_DOCUMENT_EXTENSIONS } from '@/lib/uploads/utils/validation'
import { isUuid } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
interface FileAttachment {
id: string
key: string
filename: string
media_type: string
size: number
}
export interface MessageContent {
type: 'text' | 'image' | 'document' | 'audio' | 'video'
text?: string
source?: {
type: 'base64'
media_type: string
data: string
}
}
/**
* Mapping of MIME types to content types
*/
export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document' | 'audio' | 'video'> = {
// Images
'image/jpeg': 'image',
'image/jpg': 'image',
'image/png': 'image',
'image/gif': 'image',
'image/webp': 'image',
'image/svg+xml': 'image', // SVG upload is allowed; createFileContent handles it separately for Claude API
'image/bmp': 'image',
'image/tiff': 'image',
'image/heic': 'image',
'image/heif': 'image',
'image/avif': 'image',
'image/x-icon': 'image',
'image/vnd.microsoft.icon': 'image',
// Documents
'application/pdf': 'document',
'text/plain': 'document',
'text/csv': 'document',
'application/json': 'document',
'application/xml': 'document',
'text/xml': 'document',
'text/html': 'document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', // .docx
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', // .xlsx
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document', // .pptx
'application/msword': 'document', // .doc
'application/vnd.ms-excel': 'document', // .xls
'application/vnd.ms-powerpoint': 'document', // .ppt
'text/markdown': 'document',
'application/rtf': 'document',
// Audio
'audio/mpeg': 'audio', // .mp3
'audio/mp3': 'audio',
'audio/mp4': 'audio', // .m4a
'audio/x-m4a': 'audio',
'audio/m4a': 'audio',
'audio/wav': 'audio',
'audio/wave': 'audio',
'audio/x-wav': 'audio',
'audio/webm': 'audio',
'audio/ogg': 'audio',
'audio/vorbis': 'audio',
'audio/flac': 'audio',
'audio/x-flac': 'audio',
'audio/aac': 'audio',
'audio/x-aac': 'audio',
'audio/opus': 'audio',
// Video
'video/mp4': 'video',
'video/mpeg': 'video',
'video/quicktime': 'video', // .mov
'video/x-quicktime': 'video',
'video/x-msvideo': 'video', // .avi
'video/avi': 'video',
'video/x-matroska': 'video', // .mkv
'video/webm': 'video',
}
/**
* Get the content type for a given MIME type
*/
export function getContentType(mimeType: string): 'image' | 'document' | 'audio' | 'video' | null {
return MIME_TYPE_MAPPING[mimeType.toLowerCase()] || null
}
/**
* Check if a MIME type is supported
*/
export function isSupportedFileType(mimeType: string): boolean {
return mimeType.toLowerCase() in MIME_TYPE_MAPPING
}
/**
* Check if a MIME type is an image type (for copilot uploads)
*/
const IMAGE_MIME_TYPES = new Set(
Object.entries(MIME_TYPE_MAPPING)
.filter(([, v]) => v === 'image')
.map(([k]) => k)
)
export function isImageFileType(mimeType: string): boolean {
return IMAGE_MIME_TYPES.has(mimeType.toLowerCase())
}
/**
* Check if a MIME type is an audio type
*/
export function isAudioFileType(mimeType: string): boolean {
return getContentType(mimeType) === 'audio'
}
/**
* Check if a MIME type is a video type
*/
export function isVideoFileType(mimeType: string): boolean {
return getContentType(mimeType) === 'video'
}
/**
* Check if a MIME type is an audio or video type
*/
export function isMediaFileType(mimeType: string): boolean {
const contentType = getContentType(mimeType)
return contentType === 'audio' || contentType === 'video'
}
/**
* Convert a file buffer to base64
*/
export function bufferToBase64(buffer: Buffer): string {
return buffer.toString('base64')
}
/**
* Create message content from file data
*/
export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null {
return createFileContentFromBase64(bufferToBase64(fileBuffer), mimeType)
}
/**
* Create message content from base64-encoded file data.
*/
export function createFileContentFromBase64(
base64: string,
mimeType: string
): MessageContent | null {
// SVG is XML text — Claude only supports raster image formats (JPEG, PNG, GIF, WebP),
// so send SVGs as an XML document instead
if (mimeType.toLowerCase() === 'image/svg+xml') {
return {
type: 'document',
source: {
type: 'base64',
media_type: 'text/xml',
data: base64,
},
}
}
const contentType = getContentType(mimeType)
if (!contentType) {
return null
}
if (contentType === 'image' && !MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase())) {
return null
}
return {
type: contentType,
source: {
type: 'base64',
media_type: mimeType,
data: base64,
},
}
}
export const MODEL_SUPPORTED_IMAGE_MIME_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
])
/**
* Extract file extension from filename
*/
export function getFileExtension(filename: string): string {
const lastDot = filename.lastIndexOf('.')
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
}
const EXTENSION_TO_MIME: Record<string, string> = {
// Images
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
bmp: 'image/bmp',
tif: 'image/tiff',
tiff: 'image/tiff',
heic: 'image/heic',
heif: 'image/heif',
avif: 'image/avif',
ico: 'image/x-icon',
// Documents
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
html: 'text/html',
htm: 'text/html',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
doc: 'application/msword',
xls: 'application/vnd.ms-excel',
ppt: 'application/vnd.ms-powerpoint',
md: 'text/markdown',
yaml: 'application/x-yaml',
yml: 'application/x-yaml',
rtf: 'application/rtf',
// Archives
zip: 'application/zip',
gz: 'application/gzip',
// Code / plain-text source
py: 'text/x-python',
js: 'text/javascript',
mjs: 'text/javascript',
cjs: 'text/javascript',
ts: 'text/typescript',
tsx: 'text/typescript',
jsx: 'text/javascript',
go: 'text/x-go',
rs: 'text/x-rust',
java: 'text/x-java',
kt: 'text/x-kotlin',
c: 'text/x-c',
cpp: 'text/x-c++',
h: 'text/x-c',
hpp: 'text/x-c++',
cs: 'text/x-csharp',
rb: 'text/x-ruby',
php: 'text/x-php',
swift: 'text/x-swift',
sh: 'text/x-shellscript',
bash: 'text/x-shellscript',
zsh: 'text/x-shellscript',
r: 'text/x-r',
sql: 'text/x-sql',
scala: 'text/x-scala',
lua: 'text/x-lua',
pl: 'text/x-perl',
toml: 'text/x-toml',
ini: 'text/plain',
cfg: 'text/plain',
conf: 'text/plain',
env: 'text/plain',
log: 'text/plain',
makefile: 'text/x-makefile',
dockerfile: 'text/x-dockerfile',
css: 'text/css',
scss: 'text/x-scss',
less: 'text/x-less',
graphql: 'text/x-graphql',
gql: 'text/x-graphql',
proto: 'text/x-protobuf',
// Audio
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
webm: 'audio/webm',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
opus: 'audio/opus',
// Video
mp4: 'video/mp4',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
}
/**
* Get MIME type from file extension (fallback if not provided)
*/
export function getMimeTypeFromExtension(extension: string): string {
return EXTENSION_TO_MIME[extension.toLowerCase()] || 'application/octet-stream'
}
/**
* Resolve a reliable MIME type from a file, falling back to the extension map
* when the browser reports an empty type. By default treats
* `application/octet-stream` as "unknown" and falls back to the extension —
* pass `{ preserveOctetStream: true }` for direct PUT uploads where the
* browser-supplied content-type must match the presigned handshake exactly.
*/
export function resolveFileType(
file: { type: string; name: string },
options?: { preserveOctetStream?: boolean }
): string {
const browserType = file.type?.trim()
if (browserType) {
if (options?.preserveOctetStream || browserType !== 'application/octet-stream') {
return browserType
}
}
return getMimeTypeFromExtension(getFileExtension(file.name))
}
/**
* Upload `Content-Type` for direct PUT — preserves the browser's reported type
* verbatim (including `application/octet-stream`) so it matches the presigned
* URL's signed Content-Type header.
*/
export function getFileContentType(file: File): string {
return resolveFileType(file, { preserveOctetStream: true })
}
/**
* Whether `error` is a DOM `AbortError` (XHR `abort()`, fetch `signal.aborted`,
* etc). Used in upload retry loops so aborts short-circuit instead of retrying.
*/
export function isAbortError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
String((error as { name?: unknown }).name) === 'AbortError'
)
}
/**
* Heuristic: whether `error` is a transient network/connection failure that's
* worth retrying (vs. a deterministic 4xx/auth/validation error). Sniffs the
* message because browsers and servers report these without standardized codes.
*/
export function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return (
message.includes('network') ||
message.includes('fetch') ||
message.includes('connection') ||
message.includes('timeout') ||
message.includes('timed out') ||
message.includes('econnreset')
)
}
const MIME_TO_EXTENSION: Record<string, string> = {
// Images
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'image/tiff': 'tiff',
'image/heic': 'heic',
'image/heif': 'heif',
'image/avif': 'avif',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico',
// Documents
'application/pdf': 'pdf',
'text/plain': 'txt',
'text/csv': 'csv',
'application/json': 'json',
'application/xml': 'xml',
'text/xml': 'xml',
'text/html': 'html',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/msword': 'doc',
'application/vnd.ms-excel': 'xls',
'application/vnd.ms-powerpoint': 'ppt',
'text/markdown': 'md',
'application/rtf': 'rtf',
// Audio
'audio/mpeg': 'mp3',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a',
'audio/m4a': 'm4a',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/x-wav': 'wav',
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/vorbis': 'ogg',
'audio/flac': 'flac',
'audio/x-flac': 'flac',
'audio/aac': 'aac',
'audio/x-aac': 'aac',
'audio/opus': 'opus',
// Video
'video/mp4': 'mp4',
'video/mpeg': 'mpg',
'video/quicktime': 'mov',
'video/x-quicktime': 'mov',
'video/x-msvideo': 'avi',
'video/avi': 'avi',
'video/x-matroska': 'mkv',
'video/webm': 'webm',
// Archives
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/gzip': 'gz',
}
/**
* Get file extension from MIME type
* @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
}
/**
* Format bytes to human-readable file size
* @param bytes - File size in bytes
* @param options - Formatting options
* @returns Formatted string (e.g., "1.5 MB", "500 KB")
*/
export function formatFileSize(
bytes: number,
options?: { includeBytes?: boolean; precision?: number }
): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const precision = options?.precision ?? 1
const includeBytes = options?.includeBytes ?? false
const i = Math.floor(Math.log(bytes) / Math.log(k))
if (i === 0 && !includeBytes) {
return '0 Bytes'
}
const value = bytes / k ** i
const formattedValue = Number.parseFloat(value.toFixed(precision))
return `${formattedValue} ${sizes[i]}`
}
/**
* Validate file size and type for knowledge base uploads (client-side)
* @param file - File object to validate
* @param maxSizeBytes - Maximum file size in bytes (default: 100MB)
* @returns Error message string if validation fails, null if valid
*/
export function validateKnowledgeBaseFile(
file: File,
maxSizeBytes: number = 100 * 1024 * 1024
): string | null {
if (file.size > maxSizeBytes) {
const maxSizeMB = Math.round(maxSizeBytes / (1024 * 1024))
return `File "${file.name}" is too large. Maximum size is ${maxSizeMB}MB.`
}
// Check MIME type first
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
return null
}
// Fallback: check file extension (browsers often misidentify file types like .md)
const extension = getFileExtension(file.name)
if (
SUPPORTED_DOCUMENT_EXTENSIONS.includes(
extension as (typeof SUPPORTED_DOCUMENT_EXTENSIONS)[number]
)
) {
return null
}
return `File "${file.name}" has an unsupported format. Please use PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, JSONL, YAML, or YML files.`
}
/**
* Extract storage key from a file path
* Handles URLs like /api/files/serve/s3/key or /api/files/serve/blob/key
*/
export function extractStorageKey(filePath: string): string {
let pathWithoutQuery = filePath.split('?')[0]
try {
if (pathWithoutQuery.startsWith('http://') || pathWithoutQuery.startsWith('https://')) {
const url = new URL(pathWithoutQuery)
pathWithoutQuery = url.pathname
}
} catch {
// If URL parsing fails, use the original path
}
if (pathWithoutQuery.startsWith('/api/files/serve/')) {
let key = decodeURIComponent(pathWithoutQuery.substring('/api/files/serve/'.length))
if (key.startsWith('s3/')) {
key = key.substring(3)
} else if (key.startsWith('blob/')) {
key = key.substring(5)
}
return key
}
return pathWithoutQuery
}
/**
* Whether a URL targets the internal file-serve endpoint (`/api/files/serve/`).
*
* The marker is matched only in the URL's path component, so it cannot be
* smuggled through a query string or fragment (e.g.
* `https://evil.com/x?next=/api/files/serve/...`) to skip DNS/SSRF validation.
*
* The raw path is inspected without URL normalization on purpose: callers such
* as the files parse route rely on traversal sequences (`..`) surviving this
* check so they are rejected downstream rather than collapsed away. A path-only
* marker still classifies any host as internal (e.g.
* `https://other-host/api/files/serve/<key>`); cross-tenant reads are prevented
* at the storage sink by {@link verifyFileAccess}, not by host matching, which
* would break self-hosted and multi-domain deployments.
*/
export function isInternalFileUrl(fileUrl: string): boolean {
if (typeof fileUrl !== 'string') {
return false
}
let path = fileUrl
const scheme = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i.exec(path)
if (scheme) {
path = path.slice(scheme[0].length)
}
path = path.split(/[?#]/, 1)[0]
return path.startsWith('/api/files/serve/')
}
/**
* Infer storage context from a file key using its prefix.
*
* All stored files use prefixed keys. Knowledge-base objects carry one of two
* prefixes: `kb/` (server-side uploads) or `knowledge-base/` (direct/presigned
* uploads, whose default key is `${context}/...`). Both map to the same
* `knowledge-base` context.
*/
export function inferContextFromKey(key: string): StorageContext {
if (!key) {
throw new Error('Cannot infer context from empty key')
}
if (key.startsWith('kb/') || key.startsWith('knowledge-base/')) return 'knowledge-base'
if (key.startsWith('chat/')) return 'chat'
if (key.startsWith('copilot/')) return 'copilot'
if (key.startsWith('execution/')) return 'execution'
if (key.startsWith('workspace/')) return 'workspace'
if (key.startsWith('profile-pictures/')) return 'profile-pictures'
if (key.startsWith('og-images/')) return 'og-images'
if (key.startsWith('workspace-logos/')) return 'workspace-logos'
if (key.startsWith('logs/')) return 'logs'
throw new Error(
`File key must start with a context prefix (kb/, knowledge-base/, chat/, copilot/, execution/, workspace/, profile-pictures/, og-images/, workspace-logos/, or logs/). Got: ${key}`
)
}
/**
* World-readable storage contexts. Reads for these short-circuit file
* authorization and can resolve to the shared bucket, so a caller-supplied
* context must never select one for a key that does not carry the matching
* prefix.
*/
const PUBLIC_STORAGE_CONTEXTS = new Set<StorageContext>([
'profile-pictures',
'og-images',
'workspace-logos',
])
/**
* Resolve the storage context for a stored file from its trusted key prefix.
*
* The storage key is written server-side at upload time and cannot be forged to
* change tenant, whereas a file's `context` field is attacker-authorable in a
* workflow. When the key carries a recognized prefix that prefix is
* authoritative and the caller-supplied `context` is ignored — this prevents a
* private `workspace/…` key from being relabeled with a world-readable context
* to bypass authorization and read the shared bucket.
*
* Legacy keys predating context-prefixed keys cannot be inferred; for those the
* persisted `context` is honored so existing files stay resolvable — except a
* world-readable context, which would reopen the bypass on an un-inferrable key.
*/
export function resolveTrustedFileContext(key: string, context?: string): StorageContext {
try {
return inferContextFromKey(key)
} catch (error) {
if (context && !PUBLIC_STORAGE_CONTEXTS.has(context as StorageContext)) {
return context as StorageContext
}
throw error
}
}
/**
* Extract storage key and context from an internal file URL
* @param fileUrl - Internal file URL (e.g., /api/files/serve/key?context=workspace)
* @returns Object with storage key and context
*/
export function parseInternalFileUrl(fileUrl: string): { key: string; context: StorageContext } {
const key = extractStorageKey(fileUrl)
if (!key) {
throw new Error('Could not extract storage key from internal file URL')
}
const url = new URL(fileUrl.startsWith('http') ? fileUrl : `http://localhost${fileUrl}`)
const contextParam = url.searchParams.get('context')
const context = (contextParam as StorageContext) || inferContextFromKey(key)
return { key, context }
}
/**
* Raw file input that can be converted to UserFile
* Supports various file object formats from different sources
*/
export interface RawFileInput {
id?: string
key?: string
path?: string
url?: string
name: string
size: number
type?: string
uploadedAt?: string | Date
expiresAt?: string | Date
context?: string
base64?: string
}
/**
* Type guard to check if a RawFileInput has all UserFile required properties
*/
function isCompleteUserFile(file: RawFileInput): file is UserFile {
return (
typeof file.id === 'string' &&
typeof file.name === 'string' &&
typeof file.url === 'string' &&
typeof file.size === 'number' &&
typeof file.type === 'string' &&
typeof file.key === 'string'
)
}
function isUrlLike(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
}
/**
* Extracts HTTPS URL from a file input object (UserFile or RawFileInput)
* Returns null if no valid HTTPS URL is found
*/
export function resolveHttpsUrlFromFileInput(fileInput: unknown): string | null {
if (!fileInput || typeof fileInput !== 'object') {
return null
}
const record = fileInput as Record<string, unknown>
const url =
typeof record.url === 'string'
? record.url.trim()
: typeof record.path === 'string'
? record.path.trim()
: ''
if (!url || !url.startsWith('https://')) {
return null
}
return url
}
function resolveStorageKeyFromRawFile(file: RawFileInput): string | null {
if (file.key) {
return file.key
}
if (file.path) {
if (isUrlLike(file.path)) {
return isInternalFileUrl(file.path) ? extractStorageKey(file.path) : null
}
return file.path
}
if (file.url) {
return isInternalFileUrl(file.url) ? extractStorageKey(file.url) : null
}
return null
}
function resolveInternalFileUrl(file: RawFileInput): string {
if (file.url && isInternalFileUrl(file.url)) {
return file.url
}
if (file.path && isInternalFileUrl(file.path)) {
return file.path
}
return ''
}
/**
* Provider large-file handles are populated by the server pipeline and must never be
* accepted from untrusted file input (they drive server-side fetch/upload).
*/
const PROVIDER_FILE_HANDLE_FIELDS: Array<'providerFileId' | 'providerFileUri' | 'remoteUrl'> = [
'providerFileId',
'providerFileUri',
'remoteUrl',
]
/**
* Core conversion logic from RawFileInput to UserFile
*/
function convertToUserFile(file: RawFileInput, requestId: string, logger: Logger): UserFile | null {
if (isCompleteUserFile(file)) {
return {
...omit(file, PROVIDER_FILE_HANDLE_FIELDS),
url: resolveInternalFileUrl(file) || file.url,
}
}
const storageKey = resolveStorageKeyFromRawFile(file)
if (!storageKey) {
return null
}
const userFile: UserFile = {
id: file.id || `file-${Date.now()}`,
name: file.name,
url: resolveInternalFileUrl(file),
size: file.size,
type: file.type || 'application/octet-stream',
key: storageKey,
context: file.context,
base64: file.base64,
}
logger.info(`[${requestId}] Converted file to UserFile: ${userFile.name} (key: ${userFile.key})`)
return userFile
}
/**
* Converts a single raw file object to UserFile format
* @throws Error if file is an array or has no storage key
*/
export function processSingleFileToUserFile(
file: RawFileInput,
requestId: string,
logger: Logger
): UserFile {
if (Array.isArray(file)) {
const errorMsg = `Expected a single file but received an array with ${file.length} file(s). Use a file input that accepts multiple files, or select a specific file from the array (e.g., {{block.files[0]}}).`
logger.error(`[${requestId}] ${errorMsg}`)
throw new Error(errorMsg)
}
const userFile = convertToUserFile(file, requestId, logger)
if (!userFile) {
const errorMsg = `File has no storage key: ${file.name || 'unknown'}`
logger.warn(`[${requestId}] ${errorMsg}`)
throw new Error(errorMsg)
}
return userFile
}
/**
* Converts raw file objects to UserFile format, accepting single or array input
*/
export function processFilesToUserFiles(
files: RawFileInput | RawFileInput[],
requestId: string,
logger: Logger
): UserFile[] {
const filesArray = Array.isArray(files) ? files : [files]
const userFiles: UserFile[] = []
for (const file of filesArray) {
if (Array.isArray(file)) {
logger.warn(`[${requestId}] Skipping nested array in file input`)
continue
}
const userFile = convertToUserFile(file, requestId, logger)
if (userFile) {
userFiles.push(userFile)
} else {
logger.warn(`[${requestId}] Skipping file without storage key: ${file.name || 'unknown'}`)
}
}
return userFiles
}
/**
* Sanitize a filename for use in storage metadata headers
* Storage metadata headers must contain only ASCII printable characters (0x20-0x7E)
* and cannot contain certain special characters
*/
export function sanitizeFilenameForMetadata(filename: string): string {
return (
filename
// Remove non-ASCII characters (keep only printable ASCII 0x20-0x7E)
.replace(/[^\x20-\x7E]/g, '')
// Remove characters that are problematic in HTTP headers
.replace(/["\\]/g, '')
// Replace multiple spaces with single space
.replace(/\s+/g, ' ')
// Trim whitespace
.trim() ||
// Provide fallback if completely sanitized
'file'
)
}
/**
* Sanitize metadata values for storage providers
* Removes non-printable ASCII characters and limits length
* @param metadata Original metadata object
* @param maxLength Maximum length per value (Azure Blob: 8000, S3: 2000)
* @returns Sanitized metadata object
*/
export function sanitizeStorageMetadata(
metadata: Record<string, string>,
maxLength: number
): Record<string, string> {
const sanitized: Record<string, string> = {}
for (const [key, value] of Object.entries(metadata)) {
const sanitizedValue = String(value)
.replace(/[^\x20-\x7E]/g, '')
.replace(/["\\]/g, '')
.substring(0, maxLength)
if (sanitizedValue) {
sanitized[key] = sanitizedValue
}
}
return sanitized
}
/**
* Sanitize a file key/path for local storage
* Removes dangerous characters and prevents path traversal
* Preserves forward slashes for structured paths (e.g., kb/file.json, workspace/id/file.json)
* All keys must have a context prefix structure
* @param key Original file key/path
* @returns Sanitized key safe for filesystem use
*/
export function sanitizeFileKey(key: string): string {
if (!key.includes('/')) {
throw new Error('File key must include a context prefix (e.g., kb/, workspace/, execution/)')
}
const segments = key.split('/')
const sanitizedSegments = segments.map((segment, index) => {
if (segment === '..' || segment === '.') {
throw new Error('Path traversal detected in file key')
}
if (index === segments.length - 1) {
return segment.replace(/[^a-zA-Z0-9.-]/g, '_')
}
return segment.replace(/[^a-zA-Z0-9-]/g, '_')
})
return sanitizedSegments.join('/')
}
/**
* Extract clean filename from URL or path, stripping query parameters
* Handles both internal serve URLs (/api/files/serve/...) and external URLs
* @param urlOrPath URL or path string that may contain query parameters
* @returns Clean filename without query parameters
*/
export function extractCleanFilename(urlOrPath: string): string {
const withoutQuery = urlOrPath.split('?')[0]
try {
const url = new URL(
withoutQuery.startsWith('http') ? withoutQuery : `http://localhost${withoutQuery}`
)
const pathname = url.pathname
const filename = pathname.split('/').pop() || 'unknown'
return decodeURIComponent(filename)
} catch {
const filename = withoutQuery.split('/').pop() || 'unknown'
return decodeURIComponent(filename)
}
}
/**
* Extract workspaceId from execution file key pattern
* Format: execution/workspaceId/workflowId/executionId/filename
* @param key File storage key
* @returns workspaceId if key matches execution file pattern, null otherwise
*/
export function extractWorkspaceIdFromExecutionKey(key: string): string | null {
const segments = key.split('/')
if (segments[0] === 'execution' && segments.length >= 5) {
const workspaceId = segments[1]
if (workspaceId && isUuid(workspaceId)) {
return workspaceId
}
}
return null
}
/**
* Construct viewer URL for a file
* Viewer URL format: /workspace/{workspaceId}/files/{fileKey}
* @param fileKey File storage key
* @param workspaceId Optional workspace ID (will be extracted from key if not provided)
* @returns Viewer URL string or null if workspaceId cannot be determined
*/
export function getViewerUrl(fileKey: string, workspaceId?: string): string | null {
const resolvedWorkspaceId = workspaceId || extractWorkspaceIdFromExecutionKey(fileKey)
if (!resolvedWorkspaceId) {
return null
}
return `/workspace/${resolvedWorkspaceId}/files/${fileKey}`
}