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
970 lines
28 KiB
TypeScript
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}`
|
|
}
|