chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
extractEmbeddedFileRef,
extractEmbeddedFileRefs,
} from '@/lib/uploads/utils/embedded-image-ref'
const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png'
const ENCODED = encodeURIComponent(KEY)
describe('extractEmbeddedFileRef', () => {
it('parses serve-url embeds (encoded, raw, and s3/blob prefixed) to the workspace key', () => {
expect(extractEmbeddedFileRef(`/api/files/serve/${ENCODED}?context=workspace`)).toEqual({
key: KEY,
})
expect(extractEmbeddedFileRef(`/api/files/serve/s3/${ENCODED}`)).toEqual({ key: KEY })
expect(extractEmbeddedFileRef(`/api/files/serve/blob/${ENCODED}`)).toEqual({ key: KEY })
})
it('parses view-url and in-app-path embeds to the file id', () => {
expect(extractEmbeddedFileRef('/api/files/view/wf_YwDXi8eWOkTxn0sbgChlB')).toEqual({
fileId: 'wf_YwDXi8eWOkTxn0sbgChlB',
})
expect(extractEmbeddedFileRef('/workspace/W1/files/wf_abc')).toEqual({ fileId: 'wf_abc' })
})
it('returns null for external, data, and non-workspace serve urls', () => {
expect(extractEmbeddedFileRef('https://cdn.example.com/a.png')).toBeNull()
expect(extractEmbeddedFileRef('data:image/png;base64,AAAA')).toBeNull()
expect(extractEmbeddedFileRef('/api/files/serve/profile-pictures%2Fu1%2Favatar.png')).toBeNull()
})
})
describe('extractEmbeddedFileRefs', () => {
it('collects de-duplicated keys and ids from a document via the shared parser', () => {
const content = `
![a](/api/files/serve/${ENCODED}?context=workspace)
![b](/api/files/view/wf_abc)
![c](/workspace/W1/files/4bdaf6c4-072e-464e-891d-b6af3b5fe2cc)
![dup](/api/files/serve/s3/${ENCODED})
![ext](https://cdn.example.com/x.png)
![pub](/api/files/serve/profile-pictures%2Fu1%2Favatar.png)
`
const { keys, ids } = extractEmbeddedFileRefs(content)
expect(keys).toEqual([KEY])
expect(ids.sort()).toEqual(['4bdaf6c4-072e-464e-891d-b6af3b5fe2cc', 'wf_abc'].sort())
})
it('caps total references (keys + ids) at 50 combined', () => {
const ids = Array.from(
{ length: 40 },
(_, i) => `/api/files/view/wf_${String(i).padStart(6, '0')}`
)
const keys = Array.from(
{ length: 40 },
(_, i) => `/api/files/serve/${encodeURIComponent(`workspace/W1/k${i}.png`)}`
)
const { keys: k, ids: d } = extractEmbeddedFileRefs([...ids, ...keys].join(' '))
expect(k.length + d.length).toBe(50)
})
})
@@ -0,0 +1,73 @@
/**
* The grammar of a markdown-embedded workspace image reference, shared by the frontend renderer
* (which rewrites one `src` at a time) and the server (which scans a whole document for the
* referenced-by-doc gate and the export bundler). Both go through {@link extractEmbeddedFileRef} so
* the set the client links and the set the server authorizes can never drift apart.
*
* Pure and isomorphic — no DOM, Node, or DB imports — so it is safe to import from both client and
* server code.
*/
/** A reference parsed from an embed `src`: a workspace storage key, a workspace file id, or neither. */
export type EmbeddedFileRef = { key: string } | { fileId: string } | null
/** Hard cap on embedded images resolved from one document — bounds export bundles and the cascade. */
export const MAX_EMBEDDED_IMAGES = 50
/**
* Candidate embed URL substrings in document text: a serve URL, a view URL, or the in-app workspace
* path. The captured run stops at whitespace/quote/paren/angle/query so authoritative parsing is left
* to {@link extractEmbeddedFileRef}.
*/
const EMBED_URL_RE =
/(?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>?]*/g
/**
* Parse a single embed `src` into the workspace file it references, normalizing the spellings the
* editor and file agent produce: `/api/files/serve/<key>` (incl. `s3/`/`blob/` prefixes), `/api/files/view/<id>`,
* and the in-app path `/workspace/<wsId>/files/<id>`. Returns null for absolute, `data:`, or non-workspace
* URLs (e.g. public `profile-pictures/` assets), which render as-is.
*/
export function extractEmbeddedFileRef(src: string): EmbeddedFileRef {
try {
const parsed = new URL(src, 'http://placeholder')
if (parsed.origin !== 'http://placeholder') return null
const segs = parsed.pathname.split('/')
if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'serve') {
let keySegs = segs.slice(4)
if (keySegs[0] === 's3' || keySegs[0] === 'blob') keySegs = keySegs.slice(1)
const raw = keySegs.join('/')
if (!raw) return null
const key = decodeURIComponent(raw)
return key.startsWith('workspace/') ? { key } : null
}
if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'view' && segs[4]) {
return { fileId: segs[4] }
}
if (segs[1] === 'workspace' && segs[3] === 'files' && segs[4]) {
return { fileId: segs[4] }
}
return null
} catch {
return null
}
}
/**
* The de-duplicated keys and ids embedded in `content`, bounded to {@link MAX_EMBEDDED_IMAGES} unique
* references **combined** (keys + ids). Every candidate URL is interpreted by {@link extractEmbeddedFileRef},
* so this is exactly the set the frontend rewrites — the server's referenced-by-doc gate and the export
* bundler share one grammar.
*/
export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: string[] } {
const keys = new Set<string>()
const ids = new Set<string>()
for (const match of content.matchAll(EMBED_URL_RE)) {
const ref = extractEmbeddedFileRef(match[0])
if (!ref) continue
if ('key' in ref) keys.add(ref.key)
else ids.add(ref.fileId)
if (keys.size + ids.size >= MAX_EMBEDDED_IMAGES) break
}
return { keys: [...keys], ids: [...ids] }
}
@@ -0,0 +1,54 @@
import { z } from 'zod'
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
const isUrlLike = (value: string) =>
value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
export const RawFileInputSchema = z
.object({
id: z.string().optional(),
key: z.string().optional(),
path: z.string().optional(),
url: z.string().optional(),
name: z.string().min(1),
size: z.number().nonnegative(),
type: z.string().optional(),
uploadedAt: z.union([z.string(), z.date()]).optional(),
expiresAt: z.union([z.string(), z.date()]).optional(),
context: z.string().optional(),
base64: z.string().optional(),
})
.passthrough()
.refine((data) => Boolean(data.key || data.path || data.url), {
message: 'File must include key, path, or url',
})
.refine(
(data) => {
if (data.key || data.path) {
return true
}
if (!data.url) {
return true
}
return isInternalFileUrl(data.url)
},
{ message: 'File url must reference an uploaded file' }
)
.refine(
(data) => {
if (data.key || !data.path) {
return true
}
if (!isUrlLike(data.path)) {
return true
}
return isInternalFileUrl(data.path)
},
{ message: 'File path must reference an uploaded file' }
)
export type RawFileInput = z.infer<typeof RawFileInputSchema>
export const RawFileInputArraySchema = z.array(RawFileInputSchema)
export const FileInputSchema = z.union([RawFileInputSchema, z.string()])
@@ -0,0 +1,47 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDownloadFile } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
}))
vi.mock('@/lib/uploads/core/storage-service', () => ({
downloadFile: mockDownloadFile,
hasCloudStorage: vi.fn(() => true),
}))
vi.mock('@/app/api/files/authorization', () => ({
verifyFileAccess: vi.fn(),
}))
import { createLogger } from '@sim/logger'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import type { UserFile } from '@/executor/types'
describe('downloadFileFromStorage context derivation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDownloadFile.mockResolvedValue(Buffer.from('bytes'))
})
it('downloads with the key-derived context, ignoring a caller-supplied public context', async () => {
const userFile: UserFile = {
id: 'f1',
name: 'report.pdf',
url: '',
size: 5,
type: 'application/pdf',
key: 'workspace/ws-1/1700000000000-abc1234-report.pdf',
context: 'og-images',
}
await downloadFileFromStorage(userFile, 'req-1', createLogger('test'))
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
expect(mockDownloadFile).toHaveBeenCalledWith(
expect.objectContaining({ key: userFile.key, context: 'workspace' })
)
})
})
@@ -0,0 +1,367 @@
'use server'
import { createLogger, type Logger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import {
assertKnownSizeWithinLimit,
consumeOrCancelBody,
readResponseToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { StorageService } from '@/lib/uploads'
import { isExecutionFile } from '@/lib/uploads/contexts/execution/utils'
import {
extractStorageKey,
getFileExtension,
getMimeTypeFromExtension,
inferContextFromKey,
isInternalFileUrl,
processSingleFileToUserFile,
type RawFileInput,
resolveTrustedFileContext,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
import type { UserFile } from '@/executor/types'
const logger = createLogger('FileUtilsServer')
/**
* Result type for file input resolution
*/
export interface FileResolutionResult {
fileUrl?: string
error?: {
status: number
message: string
}
}
/**
* Options for resolving file input to a URL
*/
export interface ResolveFileInputOptions {
file?: RawFileInput
filePath?: string
userId: string
requestId: string
logger: Logger
}
/**
* Resolves file input (either a file object or filePath string) to a publicly accessible URL.
* Handles:
* - Processing raw file input via processSingleFileToUserFile
* - Resolving internal URLs via resolveInternalFileUrl
* - Generating presigned URLs for storage keys
* - Validating external URLs via validateUrlWithDNS
*/
export async function resolveFileInputToUrl(
options: ResolveFileInputOptions
): Promise<FileResolutionResult> {
const { file, filePath, userId, requestId, logger } = options
if (file) {
let userFile: UserFile
try {
userFile = processSingleFileToUserFile(file, requestId, logger)
} catch (error) {
return {
error: {
status: 400,
message: getErrorMessage(error, 'Failed to process file'),
},
}
}
let fileUrl = userFile.url || ''
// Handle internal URLs
if (fileUrl && isInternalFileUrl(fileUrl)) {
const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger)
if (resolution.error) {
return { error: resolution.error }
}
fileUrl = resolution.fileUrl || ''
}
// Generate presigned URL if we have a key but no URL
if (!fileUrl && userFile.key) {
const context = resolveTrustedFileContext(userFile.key, userFile.context)
const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: userFile.key,
context,
})
return { error: { status: 404, message: 'File not found' } }
}
fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60)
}
return { fileUrl }
}
if (filePath) {
let fileUrl = filePath
if (isInternalFileUrl(filePath)) {
const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger)
if (resolution.error) {
return { error: resolution.error }
}
fileUrl = resolution.fileUrl || fileUrl
} else if (filePath.startsWith('/')) {
logger.warn(`[${requestId}] Invalid internal path`, {
userId,
path: filePath.substring(0, 50),
})
return {
error: {
status: 400,
message: 'Invalid file path. Only uploaded files are supported for internal paths.',
},
}
} else {
const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath')
if (!urlValidation.isValid) {
return { error: { status: 400, message: urlValidation.error || 'Invalid URL' } }
}
}
return { fileUrl }
}
return { error: { status: 400, message: 'File input is required' } }
}
/**
* Options for {@link downloadFileFromUrl}.
*/
export interface DownloadFileFromUrlOptions {
/** Download timeout for external URLs. Defaults to the max execution timeout. */
timeoutMs?: number
/** Hard cap on the number of bytes read from the source. */
maxBytes?: number
/**
* Principal the download is performed on behalf of. Required to authorize
* internal (`/api/files/serve/...`) URLs: the resolved storage key is checked
* with {@link verifyFileAccess} before any bytes are read. Without it, internal
* URLs are rejected (fail closed) so a `/api/files/serve/` substring can never
* be treated as implicitly trusted.
*/
userId?: string
}
/**
* Download a file from a URL (internal or external).
*
* For internal URLs, uses direct storage access (server-side only) after
* authorizing the resolved storage key against `userId`. Context is derived
* from the key via {@link inferContextFromKey}, never from a caller-controlled
* `?context=` query param — trusting the param would let a private key be
* labeled with a world-readable context (e.g. profile-pictures) so
* {@link verifyFileAccess} short-circuits to granted while the private object is
* still read. This mirrors how `/api/files/serve` resolves context.
*
* For external URLs, validates DNS/SSRF and uses secure fetch with IP pinning.
*/
export async function downloadFileFromUrl(
fileUrl: string,
options: DownloadFileFromUrlOptions = {}
): Promise<Buffer> {
const { timeoutMs = getMaxExecutionTimeout(), maxBytes, userId } = options
if (isInternalFileUrl(fileUrl)) {
if (!userId) {
logger.warn('Internal file download denied: no userId provided', { fileUrl })
throw new Error('Access denied: internal file URL requires an authenticated user')
}
const key = extractStorageKey(fileUrl)
if (!key) {
logger.warn('Internal file download denied: could not resolve storage key', { fileUrl })
throw new Error('Access denied: could not resolve internal file key')
}
const context = inferContextFromKey(key)
const hasAccess = await verifyFileAccess(key, userId, undefined, context, false)
if (!hasAccess) {
logger.warn('Internal file download denied: access check failed', { key, context, userId })
throw new Error('Access denied: file not found or insufficient permissions')
}
const { downloadFile } = await import('@/lib/uploads/core/storage-service')
return downloadFile({ key, context, maxBytes })
}
const urlValidation = await validateUrlWithDNS(fileUrl, 'fileUrl')
if (!urlValidation.isValid) {
throw new Error(`Invalid file URL: ${urlValidation.error}`)
}
const response = await secureFetchWithPinnedIP(fileUrl, urlValidation.resolvedIP!, {
timeout: timeoutMs,
maxResponseBytes: maxBytes,
})
if (!response.ok) {
await consumeOrCancelBody(response)
throw new Error(`Failed to download file: ${response.statusText}`)
}
return readResponseToBufferWithLimit(response, {
maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER,
label: 'file download',
})
}
export async function resolveInternalFileUrl(
filePath: string,
userId: string,
requestId: string,
logger: Logger
): Promise<{ fileUrl?: string; error?: { status: number; message: string } }> {
if (!isInternalFileUrl(filePath)) {
return { fileUrl: filePath }
}
try {
const storageKey = extractStorageKey(filePath)
const context = inferContextFromKey(storageKey)
const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: storageKey,
context,
})
return { error: { status: 404, message: 'File not found' } }
}
const fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
return { fileUrl }
} catch (error) {
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
return { error: { status: 500, message: 'Failed to generate file access URL' } }
}
}
/**
* Downloads a file from storage (execution or regular)
* @param userFile - UserFile object
* @param requestId - Request ID for logging
* @param logger - Logger instance
* @returns Buffer containing file data
*/
export async function downloadFileFromStorage(
userFile: UserFile,
requestId: string,
logger: Logger,
options: { maxBytes?: number } = {}
): Promise<Buffer> {
let buffer: Buffer
if (options.maxBytes !== undefined && userFile.size > options.maxBytes) {
assertKnownSizeWithinLimit(userFile.size, options.maxBytes, 'storage file download')
}
if (isExecutionFile(userFile)) {
logger.info(`[${requestId}] Downloading from execution storage: ${userFile.key}`)
const { downloadExecutionFile } = await import(
'@/lib/uploads/contexts/execution/execution-file-manager'
)
buffer = await downloadExecutionFile(userFile, { maxBytes: options.maxBytes })
} else if (userFile.key) {
const context = resolveTrustedFileContext(userFile.key, userFile.context)
logger.info(`[${requestId}] Downloading from ${context} storage: ${userFile.key}`)
const { downloadFile } = await import('@/lib/uploads/core/storage-service')
buffer = await downloadFile({
key: userFile.key,
context,
maxBytes: options.maxBytes,
})
} else {
throw new Error('File has no key - cannot download')
}
if (options.maxBytes !== undefined) {
assertKnownSizeWithinLimit(buffer.length, options.maxBytes, 'storage file download')
}
return buffer
}
/**
* Result of {@link downloadServableFileFromStorage}: the bytes a consumer should
* actually attach/upload, plus the content type that matches those bytes.
*/
export interface ServableFile {
buffer: Buffer
contentType: string
}
/**
* Downloads a workspace file and resolves it to its SERVABLE bytes — the variant
* every tool that hands a file to an external service (email attachments, chat
* uploads, provider file inputs) should use instead of {@link downloadFileFromStorage}.
*
* AI-generated docs (pdf/docx/pptx/xlsx) store their generation SOURCE as the
* primary file and keep the rendered binary in a separate content-addressed
* artifact store. A raw download therefore yields source text under a `.pdf`
* name — the file the recipient cannot open. This swaps in the compiled artifact
* (and the correct binary content type) via the same resolver the file-serve
* route uses, so the serve and attachment paths resolve identically. Non-doc files
* and real uploaded binaries pass through unchanged, carrying `userFile.type` when set.
*
* Throws `DocCompileUserError` when a generated doc's artifact is not ready (still
* compiling) — callers should surface a retryable error rather than attach source.
*/
export async function downloadServableFileFromStorage(
userFile: UserFile,
requestId: string,
logger: Logger,
options: { maxBytes?: number; signal?: AbortSignal; ownerKey?: string } = {}
): Promise<ServableFile> {
const buffer = await downloadFileFromStorage(userFile, requestId, logger, {
maxBytes: options.maxBytes,
})
// Cheap pre-filter so only generated-doc candidates pay for the heavier resolver
// import below.
const ext = getFileExtension(userFile.name)
if (ext !== 'pdf' && ext !== 'docx' && ext !== 'pptx' && ext !== 'xlsx') {
return { buffer, contentType: userFile.type || getMimeTypeFromExtension(ext) }
}
const { parseWorkspaceFileKey } = await import(
'@/lib/uploads/contexts/workspace/workspace-file-manager'
)
const workspaceId = userFile.key ? (parseWorkspaceFileKey(userFile.key) ?? undefined) : undefined
const { resolveServableDocBytes } = await import('@/lib/copilot/tools/server/files/doc-compile')
const resolved = await resolveServableDocBytes({
rawBuffer: buffer,
fileName: userFile.name,
workspaceId,
ownerKey: options.ownerKey,
signal: options.signal,
})
// Re-check: the raw download enforced maxBytes on the source, but a generated doc
// resolves to a larger artifact.
if (options.maxBytes !== undefined && resolved.buffer.length > options.maxBytes) {
assertKnownSizeWithinLimit(resolved.buffer.length, options.maxBytes, 'servable file download')
}
return resolved
}
@@ -0,0 +1,155 @@
/**
* @vitest-environment node
*/
import { createLogger } from '@sim/logger'
import { describe, expect, it } from 'vitest'
import {
inferContextFromKey,
isAbortError,
isInternalFileUrl,
isNetworkError,
processSingleFileToUserFile,
resolveTrustedFileContext,
} from '@/lib/uploads/utils/file-utils'
const logger = createLogger('FileUtilsTest')
describe('isInternalFileUrl', () => {
it('classifies relative serve paths as internal', () => {
expect(isInternalFileUrl('/api/files/serve/kb/123-file.pdf')).toBe(true)
expect(isInternalFileUrl('/api/files/serve/workspace/ws-1/file.txt?context=workspace')).toBe(
true
)
})
it('classifies absolute serve URLs as internal regardless of host', () => {
expect(isInternalFileUrl('https://www.sim.ai/api/files/serve/kb/x.pdf')).toBe(true)
expect(isInternalFileUrl('http://localhost:3000/api/files/serve/blob/kb/x')).toBe(true)
// Host is not used to gate (self-hosted/multi-domain); the storage sink authorizes.
expect(isInternalFileUrl('https://other-host/api/files/serve/workspace/v/x')).toBe(true)
})
it('does not match the marker outside the path (query/fragment)', () => {
expect(isInternalFileUrl('https://evil.com/x?next=/api/files/serve/secret')).toBe(false)
expect(isInternalFileUrl('https://evil.com/page#/api/files/serve/secret')).toBe(false)
expect(isInternalFileUrl('https://evil.com/redirect?u=/api/files/serve/kb/x')).toBe(false)
})
it('preserves traversal sequences so they survive downstream rejection', () => {
// Must stay internal (not normalized away) so the parse route applies its `..` check.
expect(isInternalFileUrl('https://attacker.com/api/files/serve/../../../etc/passwd')).toBe(true)
expect(isInternalFileUrl('/api/files/serve/../../app.js')).toBe(true)
})
it('returns false for non-internal and non-string inputs', () => {
expect(isInternalFileUrl('https://example.com/file.pdf')).toBe(false)
expect(isInternalFileUrl('data:text/plain;base64,abc')).toBe(false)
// @ts-expect-error verifying runtime guard
expect(isInternalFileUrl(undefined)).toBe(false)
})
})
describe('inferContextFromKey', () => {
it('maps both kb/ and knowledge-base/ prefixes to knowledge-base', () => {
expect(inferContextFromKey('kb/1700000000000-doc.pdf')).toBe('knowledge-base')
// Direct/presigned uploads key as `${context}/...`, i.e. `knowledge-base/...`
expect(inferContextFromKey('knowledge-base/1781612506186-b2442e0dc045cb6c-doc.txt')).toBe(
'knowledge-base'
)
})
it('maps the remaining context prefixes', () => {
expect(inferContextFromKey('chat/x')).toBe('chat')
expect(inferContextFromKey('copilot/x')).toBe('copilot')
expect(inferContextFromKey('execution/ws/wf/ex/x')).toBe('execution')
expect(inferContextFromKey('workspace/ws/x')).toBe('workspace')
expect(inferContextFromKey('profile-pictures/x')).toBe('profile-pictures')
expect(inferContextFromKey('og-images/x')).toBe('og-images')
expect(inferContextFromKey('workspace-logos/x')).toBe('workspace-logos')
expect(inferContextFromKey('logs/x')).toBe('logs')
})
it('throws for empty or unrecognized keys', () => {
expect(() => inferContextFromKey('')).toThrow()
expect(() => inferContextFromKey('mystery/x')).toThrow()
})
})
describe('resolveTrustedFileContext', () => {
it('derives from the key prefix and ignores a mismatched caller context', () => {
expect(resolveTrustedFileContext('workspace/ws/1700000000000-abc-x.pdf', 'og-images')).toBe(
'workspace'
)
expect(resolveTrustedFileContext('chat/x', 'workspace-logos')).toBe('chat')
expect(resolveTrustedFileContext('workspace/ws/x', 'mothership')).toBe('workspace')
})
it('honors the caller context for legacy keys with no inferrable prefix', () => {
expect(resolveTrustedFileContext('legacy/ws/wf/ex/report.pdf', 'execution')).toBe('execution')
})
it('never resolves an un-inferrable key to a world-readable context', () => {
expect(() => resolveTrustedFileContext('legacy/report.pdf', 'og-images')).toThrow()
expect(() => resolveTrustedFileContext('legacy/report.pdf', 'profile-pictures')).toThrow()
expect(() => resolveTrustedFileContext('legacy/report.pdf')).toThrow()
})
})
describe('isAbortError', () => {
it('returns true for AbortError-named errors', () => {
const err = new Error('aborted')
err.name = 'AbortError'
expect(isAbortError(err)).toBe(true)
})
it('returns false for generic Errors', () => {
expect(isAbortError(new Error('boom'))).toBe(false)
expect(isAbortError(null)).toBe(false)
expect(isAbortError('AbortError')).toBe(false)
})
})
describe('isNetworkError', () => {
it.each([
'fetch failed',
'Network request failed',
'connection reset',
'request timeout',
'operation timed out',
'ECONNRESET while reading body',
])('matches transient message %s', (msg) => {
expect(isNetworkError(new Error(msg))).toBe(true)
})
it('does not match deterministic errors', () => {
expect(isNetworkError(new Error('Forbidden'))).toBe(false)
expect(isNetworkError(new Error('Validation failed: name is required'))).toBe(false)
expect(isNetworkError('not an error')).toBe(false)
expect(isNetworkError(null)).toBe(false)
})
})
describe('processSingleFileToUserFile', () => {
it('strips server-only provider file handles from untrusted input', () => {
const result = processSingleFileToUserFile(
{
id: 'file-1',
name: 'doc.pdf',
url: '/api/files/serve/workspace%2Fws-1%2Fdoc.pdf?context=workspace',
size: 1024,
type: 'application/pdf',
key: 'workspace/ws-1/doc.pdf',
providerFileId: 'file-injected',
providerFileUri: 'https://injected/uri',
remoteUrl: 'http://169.254.169.254/latest/meta-data',
} as never,
'req-1',
logger
)
expect(result.providerFileId).toBeUndefined()
expect(result.providerFileUri).toBeUndefined()
expect(result.remoteUrl).toBeUndefined()
expect(result.key).toBe('workspace/ws-1/doc.pdf')
})
})
+969
View File
@@ -0,0 +1,969 @@
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}`
}
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server'
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
/**
* Canonical retryable response for an attachment/upload whose generated-document
* artifact is still compiling. Returns the 409 when `error` is a
* {@link DocCompileUserError} (thrown by `downloadServableFileFromStorage`),
* otherwise `null` so the caller falls through to its own error handling. Shared
* by every tool route that downloads workspace files so the status, body shape,
* and user-facing copy stay identical instead of being re-typed per route.
*/
export function docNotReadyResponse(error: unknown): NextResponse | null {
if (error instanceof DocCompileUserError) {
return NextResponse.json(
{
success: false,
error: 'A document is still being generated. Wait for it to finish, then try again.',
},
{ status: 409 }
)
}
return null
}
@@ -0,0 +1,388 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import type { UserFile } from '@/executor/types'
const { mockDownloadFile, mockGetRedisClient, mockRedis, mockVerifyFileAccess } = vi.hoisted(() => {
const mockRedis = {
get: vi.fn(),
set: vi.fn(),
hget: vi.fn(),
hset: vi.fn(),
hgetall: vi.fn(),
expire: vi.fn(),
scan: vi.fn(),
del: vi.fn(),
eval: vi.fn(),
}
return {
mockDownloadFile: vi.fn(),
mockGetRedisClient: vi.fn(),
mockRedis,
mockVerifyFileAccess: vi.fn(),
}
})
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
downloadFile: mockDownloadFile,
},
}))
vi.mock('@/lib/uploads/contexts/execution/execution-file-manager', () => ({
downloadExecutionFile: mockDownloadFile,
}))
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
downloadFileFromStorage: mockDownloadFile,
}))
vi.mock('@/app/api/files/authorization', () => ({
verifyFileAccess: mockVerifyFileAccess,
}))
describe('hydrateUserFilesWithBase64', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetRedisClient.mockReturnValue(null)
mockRedis.get.mockResolvedValue(null)
mockRedis.set.mockResolvedValue('OK')
mockRedis.hget.mockResolvedValue(null)
mockRedis.hset.mockResolvedValue(1)
mockRedis.hgetall.mockResolvedValue({})
mockRedis.expire.mockResolvedValue(1)
mockRedis.scan.mockResolvedValue(['0', []])
mockRedis.del.mockResolvedValue(1)
mockRedis.eval.mockResolvedValue([1, 'ok', 0, 0])
mockVerifyFileAccess.mockResolvedValue(true)
})
it('strips existing base64 when it exceeds maxBytes', async () => {
const file: UserFile = {
id: 'file-1',
name: 'large.txt',
key: 'execution/workspace/workflow/execution/large.txt',
url: 'https://example.com/large.txt',
size: 5,
type: 'text/plain',
context: 'execution',
base64: Buffer.from('hello').toString('base64'),
}
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 1 })
expect(hydrated.file).not.toHaveProperty('base64')
})
it('keeps existing base64 when it is within maxBytes', async () => {
const base64 = Buffer.from('hello').toString('base64')
const file: UserFile = {
id: 'file-1',
name: 'small.txt',
key: 'execution/workspace/workflow/execution/small.txt',
url: 'https://example.com/small.txt',
size: 5,
type: 'text/plain',
context: 'execution',
base64,
}
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 10 })
expect(hydrated.file.base64).toBe(base64)
})
it('does not hydrate URL-only internal file objects', async () => {
const file: UserFile = {
id: 'file-1',
name: 'private.txt',
key: '',
url: '/api/files/serve/execution/workspace/workflow/execution/private.txt?context=execution',
size: 5,
type: 'text/plain',
}
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 10, userId: 'user-1' })
expect(hydrated.file).not.toHaveProperty('base64')
})
it('hydrates prior-execution files when workflow-scoped reads are enabled', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
const file: UserFile = {
id: 'file-1',
name: 'prior.txt',
key: 'execution/workspace/workflow/source-execution/prior.txt',
url: '/api/files/serve/execution/workspace/workflow/source-execution/prior.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const hydrated = await hydrateUserFilesWithBase64(
{ file },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'resume-execution',
allowLargeValueWorkflowScope: true,
userId: 'user-1',
maxBytes: 10,
}
)
expect(hydrated.file.base64).toBe(Buffer.from('hello').toString('base64'))
})
it('materializes large refs before hydrating nested files', async () => {
const file: UserFile = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace/workflow/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace/workflow/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'source-execution',
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key.includes('large-value')) {
return Buffer.from(JSON.stringify({ file }), 'utf8')
}
return Buffer.from('hello', 'utf8')
})
const hydrated = await hydrateUserFilesWithBase64(
{ ref },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'resume-execution',
largeValueExecutionIds: ['source-execution'],
userId: 'user-1',
maxBytes: 1024,
}
)
expect((hydrated.ref as unknown as { file: UserFile }).file.base64).toBe(
Buffer.from('hello').toString('base64')
)
})
it('preserves large-value metadata while hydrating visible files when requested', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
const file: UserFile = {
id: 'file-1',
name: 'visible.txt',
key: 'execution/workspace/workflow/execution-1/visible.txt',
url: '/api/files/serve/execution/workspace/workflow/execution-1/visible.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_PRESERVEREF1',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_PRESERVEREF1.json',
executionId: 'source-execution',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 256,
chunks: [
{
ref,
count: 1,
byteSize: 256,
},
],
preview: [{ id: 1 }],
}
const hydrated = await hydrateUserFilesWithBase64(
{ file, ref, manifest },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'execution-1',
userId: 'user-1',
maxBytes: 1024,
preserveLargeValueMetadata: true,
}
)
expect(hydrated.file.base64).toBe(Buffer.from('hello').toString('base64'))
expect(hydrated.ref).toBe(ref)
expect(hydrated.manifest).toBe(manifest)
expect(mockDownloadFile).toHaveBeenCalledOnce()
})
it('hydrates nested prior-execution files discovered from exact-key large refs', async () => {
const file: UserFile = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace/workflow/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace/workflow/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'object',
size: 256,
key: 'execution/workspace/workflow/source-execution/large-value-lv_MNOPQRSTUVWX.json',
executionId: 'source-execution',
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key.includes('large-value')) {
return Buffer.from(JSON.stringify({ file }), 'utf8')
}
return Buffer.from('hello', 'utf8')
})
const hydrated = await hydrateUserFilesWithBase64(
{ ref },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'resume-execution',
largeValueKeys: [ref.key],
userId: 'user-1',
maxBytes: 1024,
}
)
expect((hydrated.ref as unknown as { file: UserFile }).file.base64).toBe(
Buffer.from('hello').toString('base64')
)
})
it('releases reserved Redis budget when cleaning up execution cache entries', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
const rawEntry = JSON.stringify({ bytes: 12, userId: 'user-1' })
mockRedis.hgetall.mockResolvedValueOnce({
'key:file-1': rawEntry,
})
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
if (script.includes('HGET') && script.includes('HDEL') && script.includes('DECRBY')) {
expect(args).toEqual([
4,
'user-file:base64-budget:exec:exec-1',
'user-file:base64:exec:exec-1:key:file-1',
'execution:redis-budget:execution:exec-1',
'execution:redis-budget:user:user-1',
'key:file-1',
rawEntry,
12,
60 * 60,
])
return [1, 1]
}
return 1
})
await cleanupExecutionBase64Cache('exec-1')
expect(mockRedis.eval).toHaveBeenCalledOnce()
})
it('releases indexed budget entries even when cache keys already expired', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
mockRedis.hgetall.mockResolvedValueOnce({
'key:file-1': JSON.stringify({ bytes: 7, userId: 'user-1' }),
})
mockRedis.eval.mockResolvedValueOnce([1, 0])
await cleanupExecutionBase64Cache('exec-1')
expect(mockRedis.eval).toHaveBeenCalledOnce()
})
it('writes execution cache and budget index through one delta-aware script', async () => {
mockGetRedisClient.mockReturnValue(mockRedis)
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello world!', 'utf8'))
let reservedBytes = 0
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
if (script.includes('HGET') && script.includes('HSET') && script.includes('SET')) {
const keyCount = Number(args[0])
const valueBytes = Number(args[keyCount + 5])
reservedBytes = valueBytes - 10
return [1, 'ok', reservedBytes, reservedBytes]
}
return 1
})
const file: UserFile = {
id: 'file-1',
name: 'delta.txt',
key: 'execution/workspace/workflow/exec-1/delta.txt',
url: '/api/files/serve/execution/workspace/workflow/exec-1/delta.txt?context=execution',
size: 12,
type: 'text/plain',
context: 'execution',
}
const hydrated = await hydrateUserFilesWithBase64(
{ file },
{
workspaceId: 'workspace',
workflowId: 'workflow',
executionId: 'exec-1',
userId: 'user-1',
maxBytes: 20,
}
)
expect(hydrated.file.base64).toBe(Buffer.from('hello world!').toString('base64'))
expect(reservedBytes).toBe(Buffer.from('hello world!').toString('base64').length - 10)
expect(mockRedis.eval).toHaveBeenCalledWith(
expect.stringContaining('HGET'),
4,
'user-file:base64:exec:exec-1:key:execution/workspace/workflow/exec-1/delta.txt',
'user-file:base64-budget:exec:exec-1',
'execution:redis-budget:execution:exec-1',
'execution:redis-budget:user:user-1',
Buffer.from('hello world!').toString('base64'),
60 * 60,
'key:execution/workspace/workflow/exec-1/delta.txt',
JSON.stringify({
bytes: Buffer.from('hello world!').toString('base64').length,
userId: 'user-1',
}),
Buffer.from('hello world!').toString('base64').length,
64 * 1024 * 1024,
256 * 1024 * 1024,
60 * 60
)
expect(mockRedis.hget).not.toHaveBeenCalled()
expect(mockRedis.set).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,636 @@
import type { Logger } from '@sim/logger'
import { createLogger } from '@sim/logger'
import { isPlainRecord } from '@sim/utils/object'
import { getRedisClient } from '@/lib/core/config/redis'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
materializeLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import {
getLargeValueMaterializationError,
isLargeValueRef,
LARGE_VALUE_THRESHOLD_BYTES,
} from '@/lib/execution/payloads/large-value-ref'
import {
assertUserFileContentAccess,
readUserFileContent,
} from '@/lib/execution/payloads/materialization.server'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import {
type ExecutionRedisBudgetReservation,
getExecutionRedisBudgetKeys,
getExecutionRedisBudgetLimits,
} from '@/lib/execution/redis-budget.server'
import {
ExecutionResourceLimitError,
isExecutionResourceLimitError,
} from '@/lib/execution/resource-errors'
import type { UserFile } from '@/executor/types'
const INLINE_BASE64_JSON_OVERHEAD_BYTES = 512 * 1024
const DEFAULT_MAX_BASE64_BYTES = Math.floor(
(LARGE_VALUE_THRESHOLD_BYTES - INLINE_BASE64_JSON_OVERHEAD_BYTES) * 0.75
)
const DEFAULT_CACHE_TTL_SECONDS = 300
const REDIS_KEY_PREFIX = 'user-file:base64:'
const REDIS_BUDGET_KEY_PREFIX = 'user-file:base64-budget:'
const CLEANUP_BASE64_CACHE_ENTRY_SCRIPT = `
local file_key = ARGV[1]
local expected_entry = ARGV[2]
local bytes = tonumber(ARGV[3])
local budget_ttl_seconds = tonumber(ARGV[4])
local current_entry = redis.call('HGET', KEYS[1], file_key)
if not current_entry or current_entry ~= expected_entry then
return {0, 0}
end
local deleted = redis.call('DEL', KEYS[2])
redis.call('HDEL', KEYS[1], file_key)
if bytes and bytes > 0 then
local execution_next = redis.call('DECRBY', KEYS[3], bytes)
if execution_next <= 0 then
redis.call('DEL', KEYS[3])
else
redis.call('EXPIRE', KEYS[3], budget_ttl_seconds)
end
if #KEYS >= 4 then
local user_next = redis.call('DECRBY', KEYS[4], bytes)
if user_next <= 0 then
redis.call('DEL', KEYS[4])
else
redis.call('EXPIRE', KEYS[4], budget_ttl_seconds)
end
end
end
if redis.call('HLEN', KEYS[1]) == 0 then
redis.call('DEL', KEYS[1])
end
return {1, deleted}
`
const SET_BASE64_CACHE_SCRIPT = `
local value = ARGV[1]
local cache_ttl_seconds = tonumber(ARGV[2])
local file_key = ARGV[3]
local next_entry = ARGV[4]
local next_bytes = tonumber(ARGV[5])
local execution_limit = tonumber(ARGV[6])
local user_limit = tonumber(ARGV[7])
local budget_ttl_seconds = tonumber(ARGV[8])
local previous_entry = redis.call('HGET', KEYS[2], file_key)
local previous_bytes = 0
if previous_entry then
local parsed_previous_bytes = string.match(previous_entry, '"bytes"%s*:%s*(%d+)')
if parsed_previous_bytes then
previous_bytes = tonumber(parsed_previous_bytes)
end
end
local execution_current_raw = redis.call('GET', KEYS[3])
local execution_current = tonumber(execution_current_raw or '0')
local execution_delta = next_bytes - previous_bytes
if not execution_current_raw then
execution_delta = next_bytes
end
if execution_delta > 0 and execution_limit > 0 and execution_current + execution_delta > execution_limit then
return {0, 'execution_redis_bytes', execution_current}
end
local user_delta = 0
local user_current = 0
local user_current_raw = nil
if #KEYS >= 4 then
user_current_raw = redis.call('GET', KEYS[4])
user_current = tonumber(user_current_raw or '0')
user_delta = next_bytes - previous_bytes
if not user_current_raw then
user_delta = next_bytes
end
if user_delta > 0 and user_limit > 0 and user_current + user_delta > user_limit then
return {0, 'user_redis_bytes', user_current}
end
end
if execution_delta > 0 then
redis.call('INCRBY', KEYS[3], execution_delta)
elseif execution_delta < 0 and execution_current_raw then
local execution_next = redis.call('DECRBY', KEYS[3], -execution_delta)
if execution_next <= 0 then
redis.call('DEL', KEYS[3])
end
end
if redis.call('EXISTS', KEYS[3]) == 1 then
redis.call('EXPIRE', KEYS[3], budget_ttl_seconds)
end
if #KEYS >= 4 then
if user_delta > 0 then
redis.call('INCRBY', KEYS[4], user_delta)
elseif user_delta < 0 and user_current_raw then
local user_next = redis.call('DECRBY', KEYS[4], -user_delta)
if user_next <= 0 then
redis.call('DEL', KEYS[4])
end
end
if redis.call('EXISTS', KEYS[4]) == 1 then
redis.call('EXPIRE', KEYS[4], budget_ttl_seconds)
end
end
redis.call('SET', KEYS[1], value, 'EX', cache_ttl_seconds)
redis.call('HSET', KEYS[2], file_key, next_entry)
redis.call('EXPIRE', KEYS[2], cache_ttl_seconds)
return {1, 'ok', execution_delta, user_delta}
`
interface Base64BudgetEntry {
bytes: number
userId?: string
}
interface Base64Cache {
get(file: UserFile): Promise<string | null>
set(file: UserFile, value: string, ttlSeconds: number): Promise<void>
}
interface HydrationState {
seen: WeakSet<object>
cache: Base64Cache
cacheTtlSeconds: number
}
export interface Base64HydrationOptions {
requestId?: string
workspaceId?: string
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
logger?: Logger
maxBytes?: number
allowUnknownSize?: boolean
timeoutMs?: number
cacheTtlSeconds?: number
preserveLargeValueMetadata?: boolean
}
class InMemoryBase64Cache implements Base64Cache {
private entries = new Map<string, { value: string; expiresAt: number }>()
async get(file: UserFile): Promise<string | null> {
const key = getFileCacheKey(file)
const entry = this.entries.get(key)
if (!entry) {
return null
}
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key)
return null
}
return entry.value
}
async set(file: UserFile, value: string, ttlSeconds: number): Promise<void> {
const key = getFileCacheKey(file)
const expiresAt = Date.now() + ttlSeconds * 1000
this.entries.set(key, { value, expiresAt })
}
}
function createBase64Cache(options: Base64HydrationOptions, logger: Logger): Base64Cache {
const redis = getRedisClient()
const { executionId } = options
if (!redis) {
logger.warn(
`[${options.requestId}] Redis unavailable for base64 cache, using in-memory fallback`
)
return new InMemoryBase64Cache()
}
return {
async get(file: UserFile) {
try {
const key = getFullCacheKey(executionId, file)
return await redis.get(key)
} catch (error) {
logger.warn(`[${options.requestId}] Redis get failed, skipping cache`, error)
return null
}
},
async set(file: UserFile, value: string, ttlSeconds: number) {
const key = getFullCacheKey(executionId, file)
const valueBytes = Buffer.byteLength(value, 'utf8')
try {
if (!executionId) {
await redis.set(key, value, 'EX', ttlSeconds)
return
}
const limits = getExecutionRedisBudgetLimits()
if (valueBytes > limits.maxSingleWriteBytes) {
throw new ExecutionResourceLimitError({
resource: 'redis_key_bytes',
attemptedBytes: valueBytes,
limitBytes: limits.maxSingleWriteBytes,
})
}
const cacheTtlSeconds = Math.max(ttlSeconds, limits.ttlSeconds)
const budgetReservation: ExecutionRedisBudgetReservation = {
executionId,
userId: options.userId,
category: 'base64_cache',
operation: 'set_base64_cache',
bytes: valueBytes,
logger,
}
const budgetKeys = getExecutionRedisBudgetKeys(budgetReservation)
const result = (await redis.eval(
SET_BASE64_CACHE_SCRIPT,
2 + budgetKeys.length,
key,
getBudgetIndexKey(executionId),
...budgetKeys,
value,
cacheTtlSeconds,
getFileCacheKey(file),
serializeBudgetEntry({ bytes: valueBytes, userId: options.userId }),
valueBytes,
limits.maxExecutionBytes,
limits.maxUserBytes,
limits.ttlSeconds
)) as [number, string, number | string | null]
const [allowed, resource, current] = result
if (allowed !== 1) {
throw new ExecutionResourceLimitError({
resource:
resource === 'user_redis_bytes' ? 'user_redis_bytes' : 'execution_redis_bytes',
attemptedBytes: valueBytes,
currentBytes: Number(current ?? 0),
limitBytes:
resource === 'user_redis_bytes' ? limits.maxUserBytes : limits.maxExecutionBytes,
})
}
} catch (error) {
if (isExecutionResourceLimitError(error)) {
throw error
}
logger.warn(`[${options.requestId}] Redis set failed, skipping cache`, error)
}
},
}
}
function createHydrationState(options: Base64HydrationOptions, logger: Logger): HydrationState {
return {
seen: new WeakSet<object>(),
cache: createBase64Cache(options, logger),
cacheTtlSeconds: options.cacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS,
}
}
function getHydrationLogger(options: Base64HydrationOptions): Logger {
return options.logger ?? createLogger('UserFileBase64')
}
function getFileCacheKey(file: UserFile): string {
if (file.key) {
return `key:${file.key}`
}
if (file.url) {
return `url:${file.url}`
}
return `id:${file.id}`
}
function getFullCacheKey(executionId: string | undefined, file: UserFile): string {
const fileKey = getFileCacheKey(file)
if (executionId) {
return `${REDIS_KEY_PREFIX}exec:${executionId}:${fileKey}`
}
return `${REDIS_KEY_PREFIX}${fileKey}`
}
function getBudgetIndexKey(executionId: string): string {
return `${REDIS_BUDGET_KEY_PREFIX}exec:${executionId}`
}
function serializeBudgetEntry(entry: Base64BudgetEntry): string {
return JSON.stringify(entry)
}
function parseBudgetEntry(value: unknown): Base64BudgetEntry | null {
if (typeof value !== 'string') {
return null
}
try {
const parsed = JSON.parse(value) as Partial<Base64BudgetEntry>
if (typeof parsed.bytes !== 'number' || !Number.isFinite(parsed.bytes) || parsed.bytes <= 0) {
return null
}
return {
bytes: parsed.bytes,
userId: typeof parsed.userId === 'string' ? parsed.userId : undefined,
}
} catch {
return null
}
}
async function cleanupBudgetEntry(
redis: NonNullable<ReturnType<typeof getRedisClient>>,
executionId: string,
fileKey: string,
rawEntry: string,
entry: Base64BudgetEntry
): Promise<{ claimed: boolean; deletedCount: number }> {
const limits = getExecutionRedisBudgetLimits()
const budgetReservation: ExecutionRedisBudgetReservation = {
executionId,
userId: entry.userId,
category: 'base64_cache',
operation: 'cleanup_base64_cache',
bytes: entry.bytes,
}
const budgetKeys = getExecutionRedisBudgetKeys(budgetReservation)
const result = (await redis.eval(
CLEANUP_BASE64_CACHE_ENTRY_SCRIPT,
2 + budgetKeys.length,
getBudgetIndexKey(executionId),
`${REDIS_KEY_PREFIX}exec:${executionId}:${fileKey}`,
...budgetKeys,
fileKey,
rawEntry,
entry.bytes,
limits.ttlSeconds
)) as [number, number]
return { claimed: Number(result[0]) === 1, deletedCount: Number(result[1] ?? 0) }
}
function stripBase64(file: UserFile): UserFile {
const { base64: _base64, ...rest } = file
return rest
}
async function resolveBase64(
file: UserFile,
options: Base64HydrationOptions,
logger: Logger
): Promise<string | null> {
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES
if (file.base64) {
const base64Bytes = Buffer.byteLength(file.base64, 'base64')
if (base64Bytes > maxBytes) {
logger.warn(
`[${options.requestId}] Skipping existing base64 for ${file.name} (decoded ${base64Bytes} exceeds ${maxBytes})`
)
return null
}
return file.base64
}
const allowUnknownSize = options.allowUnknownSize ?? false
const hasStableStorageKey = Boolean(file.key)
if (Number.isFinite(file.size) && file.size > maxBytes) {
logger.warn(
`[${options.requestId}] Skipping base64 for ${file.name} (size ${file.size} exceeds ${maxBytes})`
)
return null
}
if (
(!Number.isFinite(file.size) || file.size <= 0) &&
!allowUnknownSize &&
!hasStableStorageKey
) {
logger.warn(`[${options.requestId}] Skipping base64 for ${file.name} (unknown file size)`)
return null
}
const requestId = options.requestId ?? 'unknown'
try {
return await readUserFileContent(file, {
requestId,
workspaceId: options.workspaceId,
workflowId: options.workflowId,
executionId: options.executionId,
largeValueExecutionIds: options.largeValueExecutionIds,
fileKeys: options.fileKeys,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
encoding: 'base64',
maxBytes,
maxSourceBytes: maxBytes,
})
} catch (error) {
logger.warn(`[${requestId}] Failed to hydrate base64 for ${file.name}`, error)
return null
}
}
async function hydrateUserFile(
file: UserFile,
options: Base64HydrationOptions,
state: HydrationState,
logger: Logger
): Promise<UserFile> {
if (!file.base64) {
try {
await assertUserFileContentAccess(file, {
requestId: options.requestId,
workspaceId: options.workspaceId,
workflowId: options.workflowId,
executionId: options.executionId,
largeValueExecutionIds: options.largeValueExecutionIds,
fileKeys: options.fileKeys,
allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope,
userId: options.userId,
logger,
})
} catch (error) {
logger.warn(`[${options.requestId ?? 'unknown'}] Skipping unauthorized file base64`, error)
return stripBase64(file)
}
}
const cached = await state.cache.get(file)
if (cached) {
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BASE64_BYTES
if (Buffer.byteLength(cached, 'base64') > maxBytes) {
return stripBase64(file)
}
return { ...file, base64: cached }
}
const base64 = await resolveBase64(file, options, logger)
if (!base64) {
return stripBase64(file)
}
await state.cache.set(file, base64, state.cacheTtlSeconds)
return { ...file, base64 }
}
async function hydrateValue(
value: unknown,
options: Base64HydrationOptions,
state: HydrationState,
logger: Logger
): Promise<unknown> {
if (!value || typeof value !== 'object') {
return value
}
if (
options.preserveLargeValueMetadata &&
(isLargeArrayManifest(value) || isLargeValueRef(value))
) {
return value
}
if (isLargeArrayManifest(value)) {
const materialized = await materializeLargeArrayManifest(value, options)
return hydrateValue(
materialized,
withLocalLargeValueExecutionIds(options, materialized),
state,
logger
)
}
if (isLargeValueRef(value)) {
const materialized = await materializeLargeValueRef(value, options)
if (materialized === undefined) {
throw getLargeValueMaterializationError(value)
}
return hydrateValue(
materialized,
withLocalLargeValueExecutionIds(options, materialized),
state,
logger
)
}
if (isUserFileWithMetadata(value)) {
return hydrateUserFile(value, options, state, logger)
}
if (state.seen.has(value)) {
return value
}
state.seen.add(value)
if (Array.isArray(value)) {
const hydratedItems = await Promise.all(
value.map((item) => hydrateValue(item, options, state, logger))
)
return hydratedItems
}
const entries = await Promise.all(
Object.entries(value).map(async ([key, entryValue]) => {
const hydratedEntry = await hydrateValue(entryValue, options, state, logger)
return [key, hydratedEntry] as const
})
)
return Object.fromEntries(entries)
}
function withLocalLargeValueExecutionIds(
options: Base64HydrationOptions,
materializedValue: unknown
): Base64HydrationOptions {
recordMaterializedAccessKeys(options, materializedValue)
return {
...options,
largeValueKeys: options.largeValueKeys,
fileKeys: options.fileKeys,
}
}
/**
* Hydrates UserFile objects within a value to include base64 content.
* Returns the original structure with UserFile.base64 set where available.
*/
export async function hydrateUserFilesWithBase64<T>(
value: T,
options: Base64HydrationOptions
): Promise<T> {
const logger = getHydrationLogger(options)
const state = createHydrationState(options, logger)
return (await hydrateValue(value, options, state, logger)) as T
}
/**
* Hydrates a single UserFile object when a resolver explicitly asks for base64.
*/
export async function hydrateUserFileWithBase64(
file: UserFile,
options: Base64HydrationOptions
): Promise<UserFile> {
const logger = getHydrationLogger(options)
const state = createHydrationState(options, logger)
return hydrateUserFile(file, options, state, logger)
}
/**
* Checks if a value contains any UserFile objects with metadata.
*/
export function containsUserFileWithMetadata(value: unknown): boolean {
if (!value || typeof value !== 'object') {
return false
}
if (isUserFileWithMetadata(value)) {
return true
}
if (Array.isArray(value)) {
return value.some((item) => containsUserFileWithMetadata(item))
}
if (!isPlainRecord(value)) {
return false
}
return Object.values(value).some((entry) => containsUserFileWithMetadata(entry))
}
/**
* Cleans up base64 cache entries for a specific execution.
* Should be called at the end of workflow execution.
*/
export async function cleanupExecutionBase64Cache(executionId: string): Promise<void> {
const redis = getRedisClient()
if (!redis) {
return
}
const logger = createLogger('UserFileBase64')
try {
const budgetEntries = await redis.hgetall(getBudgetIndexKey(executionId))
let deletedCount = 0
for (const [fileKey, rawEntry] of Object.entries(budgetEntries ?? {})) {
const budgetEntry = parseBudgetEntry(rawEntry)
if (!budgetEntry) continue
const cleanupResult = await cleanupBudgetEntry(
redis,
executionId,
fileKey,
rawEntry,
budgetEntry
)
if (cleanupResult.claimed) {
deletedCount += cleanupResult.deletedCount
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} base64 cache entries for execution ${executionId}`)
}
} catch (error) {
logger.warn(`Failed to cleanup base64 cache for execution ${executionId}`, error)
}
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
SUPPORTED_ATTACHMENT_EXTENSIONS,
sniffImageContentType,
validateAttachmentFileType,
} from '@/lib/uploads/utils/validation'
describe('sniffImageContentType', () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00])
const gif87 = Buffer.from('GIF87a....', 'latin1')
const gif89 = Buffer.from('GIF89a....', 'latin1')
const webp = Buffer.concat([
Buffer.from('RIFF', 'latin1'),
Buffer.from([0x00, 0x00, 0x00, 0x00]),
Buffer.from('WEBP', 'latin1'),
])
it('detects real raster image formats from magic bytes', () => {
expect(sniffImageContentType(png)).toBe('image/png')
expect(sniffImageContentType(jpeg)).toBe('image/jpeg')
expect(sniffImageContentType(gif87)).toBe('image/gif')
expect(sniffImageContentType(gif89)).toBe('image/gif')
expect(sniffImageContentType(webp)).toBe('image/webp')
})
it('rejects non-image content, including image-shaped strings and SVG', () => {
expect(
sniffImageContentType(Buffer.from('<html><script>x</script></html>', 'utf-8'))
).toBeNull()
expect(sniffImageContentType(Buffer.from('<svg xmlns="...">', 'utf-8'))).toBeNull()
expect(sniffImageContentType(Buffer.from('RIFFxxxxAVI ', 'latin1'))).toBeNull()
expect(sniffImageContentType(Buffer.alloc(0))).toBeNull()
expect(sniffImageContentType(Buffer.from([0x89, 0x50]))).toBeNull()
})
})
describe('validateAttachmentFileType', () => {
it('accepts image files (png, jpg, gif, webp, svg)', () => {
expect(validateAttachmentFileType('screenshot.png')).toBeNull()
expect(validateAttachmentFileType('photo.jpg')).toBeNull()
expect(validateAttachmentFileType('photo.JPEG')).toBeNull()
expect(validateAttachmentFileType('animation.gif')).toBeNull()
expect(validateAttachmentFileType('image.webp')).toBeNull()
expect(validateAttachmentFileType('icon.svg')).toBeNull()
})
it('accepts video files (mp4, mov, webm)', () => {
expect(validateAttachmentFileType('clip.mp4')).toBeNull()
expect(validateAttachmentFileType('clip.mov')).toBeNull()
expect(validateAttachmentFileType('clip.webm')).toBeNull()
})
it('accepts audio files (mp3, wav, m4a)', () => {
expect(validateAttachmentFileType('voice.mp3')).toBeNull()
expect(validateAttachmentFileType('voice.wav')).toBeNull()
expect(validateAttachmentFileType('voice.m4a')).toBeNull()
})
it('accepts document files (pdf, docx, csv, md)', () => {
expect(validateAttachmentFileType('report.pdf')).toBeNull()
expect(validateAttachmentFileType('letter.docx')).toBeNull()
expect(validateAttachmentFileType('data.csv')).toBeNull()
expect(validateAttachmentFileType('notes.md')).toBeNull()
})
it('accepts code files (ts, py, sh, json)', () => {
expect(validateAttachmentFileType('app.ts')).toBeNull()
expect(validateAttachmentFileType('main.py')).toBeNull()
expect(validateAttachmentFileType('script.sh')).toBeNull()
expect(validateAttachmentFileType('config.json')).toBeNull()
})
it('rejects executables and unknown extensions', () => {
expect(validateAttachmentFileType('virus.exe')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('installer.msi')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('archive.dmg')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('binary.bin')?.code).toBe('UNSUPPORTED_FILE_TYPE')
})
it('rejects files with no extension', () => {
const result = validateAttachmentFileType('README')
expect(result?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(result?.message).toContain('README')
})
it('rejects files with non-alphanumeric extensions', () => {
expect(validateAttachmentFileType('odd.<>')?.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(validateAttachmentFileType('foo. ')?.code).toBe('UNSUPPORTED_FILE_TYPE')
})
it('does not contain duplicate extensions (e.g. webm)', () => {
const seen = new Set<string>()
for (const ext of SUPPORTED_ATTACHMENT_EXTENSIONS) {
expect(seen.has(ext)).toBe(false)
seen.add(ext)
}
})
it('returns supportedTypes list in error', () => {
const result = validateAttachmentFileType('foo.exe')
expect(result?.supportedTypes).toEqual(expect.arrayContaining(['png', 'pdf', 'mp4', 'mp3']))
})
})
+398
View File
@@ -0,0 +1,398 @@
/**
* Checks whether a string is a valid file extension (lowercase alphanumeric only).
* Rejects extensions containing spaces, punctuation, or other non-alphanumeric characters
* that arise from non-filename document names (e.g. "Sim.ai <> RVTech").
*/
export function isAlphanumericExtension(ext: string): boolean {
return /^[a-z0-9]+$/.test(ext)
}
function extractExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.')
return lastDot !== -1 ? fileName.slice(lastDot + 1).toLowerCase() : ''
}
export const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
export const SUPPORTED_DOCUMENT_EXTENSIONS = [
'pdf',
'csv',
'doc',
'docx',
'txt',
'md',
'xlsx',
'xls',
'ppt',
'pptx',
'html',
'htm',
'json',
'jsonl',
'yaml',
'yml',
] as const
export const SUPPORTED_CODE_EXTENSIONS = [
'mdx',
'xml',
'css',
'scss',
'less',
'js',
'jsx',
'ts',
'tsx',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'swift',
'c',
'cpp',
'h',
'hpp',
'cs',
'php',
'sh',
'bash',
'zsh',
'fish',
'sql',
'graphql',
'gql',
'toml',
'ini',
'conf',
'cfg',
'env',
'log',
'diff',
'patch',
'dockerfile',
'makefile',
'gitignore',
'editorconfig',
'prettierrc',
'eslintrc',
'mmd',
] as const
export type SupportedCodeExtension = (typeof SUPPORTED_CODE_EXTENSIONS)[number]
export const SUPPORTED_AUDIO_EXTENSIONS = [
'mp3',
'm4a',
'wav',
'webm',
'ogg',
'flac',
'aac',
'opus',
] as const
export const SUPPORTED_VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm'] as const
export const SUPPORTED_IMAGE_EXTENSIONS = [
'png',
'jpg',
'jpeg',
'gif',
'webp',
'svg',
'bmp',
'tif',
'tiff',
'heic',
'heif',
'avif',
'ico',
] as const
export type SupportedDocumentExtension = (typeof SUPPORTED_DOCUMENT_EXTENSIONS)[number]
export type SupportedAudioExtension = (typeof SUPPORTED_AUDIO_EXTENSIONS)[number]
export type SupportedVideoExtension = (typeof SUPPORTED_VIDEO_EXTENSIONS)[number]
export type SupportedImageExtension = (typeof SUPPORTED_IMAGE_EXTENSIONS)[number]
export type SupportedMediaExtension =
| SupportedDocumentExtension
| SupportedAudioExtension
| SupportedVideoExtension
| SupportedImageExtension
export const SUPPORTED_MIME_TYPES: Record<SupportedDocumentExtension, string[]> = {
pdf: ['application/pdf', 'application/x-pdf'],
csv: ['text/csv', 'application/csv', 'text/comma-separated-values'],
doc: ['application/msword', 'application/doc', 'application/vnd.ms-word'],
docx: [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/octet-stream',
],
txt: ['text/plain', 'text/x-plain', 'application/txt'],
md: [
'text/markdown',
'text/x-markdown',
'text/plain',
'application/markdown',
'application/octet-stream',
],
xlsx: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/octet-stream',
],
xls: [
'application/vnd.ms-excel',
'application/excel',
'application/x-excel',
'application/x-msexcel',
],
ppt: ['application/vnd.ms-powerpoint', 'application/powerpoint', 'application/x-mspowerpoint'],
pptx: [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/octet-stream',
],
html: ['text/html', 'application/xhtml+xml'],
htm: ['text/html', 'application/xhtml+xml'],
json: ['application/json', 'text/json', 'application/x-json'],
jsonl: ['application/jsonl', 'application/x-jsonlines', 'text/jsonl', 'application/octet-stream'],
yaml: ['text/yaml', 'text/x-yaml', 'application/yaml', 'application/x-yaml'],
yml: ['text/yaml', 'text/x-yaml', 'application/yaml', 'application/x-yaml'],
}
export const SUPPORTED_AUDIO_MIME_TYPES: Record<SupportedAudioExtension, string[]> = {
mp3: ['audio/mpeg', 'audio/mp3'],
m4a: ['audio/mp4', 'audio/x-m4a', 'audio/m4a'],
wav: ['audio/wav', 'audio/wave', 'audio/x-wav'],
webm: ['audio/webm'],
ogg: ['audio/ogg', 'audio/vorbis'],
flac: ['audio/flac', 'audio/x-flac'],
aac: ['audio/aac', 'audio/x-aac'],
opus: ['audio/opus'],
}
export const SUPPORTED_VIDEO_MIME_TYPES: Record<SupportedVideoExtension, string[]> = {
mp4: ['video/mp4', 'video/mpeg'],
mov: ['video/quicktime', 'video/x-quicktime'],
avi: ['video/x-msvideo', 'video/avi'],
mkv: ['video/x-matroska'],
webm: ['video/webm'],
}
export const ACCEPTED_FILE_TYPES = Object.values(SUPPORTED_MIME_TYPES).flat()
export const ACCEPTED_AUDIO_TYPES = Object.values(SUPPORTED_AUDIO_MIME_TYPES).flat()
export const ACCEPTED_VIDEO_TYPES = Object.values(SUPPORTED_VIDEO_MIME_TYPES).flat()
export const ACCEPTED_MEDIA_TYPES = [
...ACCEPTED_FILE_TYPES,
...ACCEPTED_AUDIO_TYPES,
...ACCEPTED_VIDEO_TYPES,
]
export const ACCEPTED_FILE_EXTENSIONS = SUPPORTED_DOCUMENT_EXTENSIONS.map((ext) => `.${ext}`)
export const ACCEPT_ATTRIBUTE = [...ACCEPTED_FILE_TYPES, ...ACCEPTED_FILE_EXTENSIONS].join(',')
const SUPPORTED_IMAGE_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'image/bmp',
'image/tiff',
'image/heic',
'image/heif',
'image/avif',
'image/x-icon',
'image/vnd.microsoft.icon',
]
export const CHAT_ACCEPT_ATTRIBUTE = [
ACCEPT_ATTRIBUTE,
...SUPPORTED_IMAGE_MIME_TYPES,
...SUPPORTED_IMAGE_EXTENSIONS.map((ext) => `.${ext}`),
].join(',')
export interface FileValidationError {
code: 'UNSUPPORTED_FILE_TYPE' | 'MIME_TYPE_MISMATCH'
message: string
supportedTypes: string[]
}
export const SUPPORTED_ATTACHMENT_EXTENSIONS = Array.from(
new Set<string>([
...SUPPORTED_DOCUMENT_EXTENSIONS,
...SUPPORTED_CODE_EXTENSIONS,
...SUPPORTED_IMAGE_EXTENSIONS,
...SUPPORTED_AUDIO_EXTENSIONS,
...SUPPORTED_VIDEO_EXTENSIONS,
])
) as readonly string[]
/**
* Validate that a file's extension is allowed as a chat/mothership attachment.
*
* Permits documents, code, images, audio, and video — anything users would
* reasonably attach to a chat message. Rejects executables and unknown types.
*/
export function validateAttachmentFileType(fileName: string): FileValidationError | null {
const raw = extractExtension(fileName)
const extension = isAlphanumericExtension(raw) ? raw : ''
if (!SUPPORTED_ATTACHMENT_EXTENSIONS.includes(extension)) {
return {
code: 'UNSUPPORTED_FILE_TYPE',
message: `Unsupported file type${extension ? `: ${extension}` : ` for "${fileName}"`}. Supported types include documents, code, images, audio, and video.`,
supportedTypes: [...SUPPORTED_ATTACHMENT_EXTENSIONS],
}
}
return null
}
/**
* Validate if a file type is supported for document processing
*/
export function validateFileType(fileName: string, mimeType: string): FileValidationError | null {
const raw = extractExtension(fileName)
const extension = (isAlphanumericExtension(raw) ? raw : '') as SupportedDocumentExtension
if (!SUPPORTED_DOCUMENT_EXTENSIONS.includes(extension)) {
return {
code: 'UNSUPPORTED_FILE_TYPE',
message: `Unsupported file type${extension ? `: ${extension}` : ` for "${fileName}"`}. Supported types are: ${SUPPORTED_DOCUMENT_EXTENSIONS.join(', ')}`,
supportedTypes: [...SUPPORTED_DOCUMENT_EXTENSIONS],
}
}
const baseMimeType = mimeType.split(';')[0].trim()
// Allow empty MIME types if the extension is supported (browsers often don't recognize certain file types)
if (!baseMimeType) {
return null
}
const allowedMimeTypes = SUPPORTED_MIME_TYPES[extension]
if (!allowedMimeTypes.includes(baseMimeType)) {
return {
code: 'MIME_TYPE_MISMATCH',
message: `MIME type ${baseMimeType} does not match file extension ${extension}. Expected: ${allowedMimeTypes.join(', ')}`,
supportedTypes: allowedMimeTypes,
}
}
return null
}
/**
* Check if file extension is supported
*/
export function isSupportedExtension(extension: string): extension is SupportedDocumentExtension {
return SUPPORTED_DOCUMENT_EXTENSIONS.includes(
extension.toLowerCase() as SupportedDocumentExtension
)
}
/**
* Get supported MIME types for an extension
*/
export function getSupportedMimeTypes(extension: string): string[] {
if (isSupportedExtension(extension)) {
return SUPPORTED_MIME_TYPES[extension as SupportedDocumentExtension]
}
if (SUPPORTED_AUDIO_EXTENSIONS.includes(extension as SupportedAudioExtension)) {
return SUPPORTED_AUDIO_MIME_TYPES[extension as SupportedAudioExtension]
}
if (SUPPORTED_VIDEO_EXTENSIONS.includes(extension as SupportedVideoExtension)) {
return SUPPORTED_VIDEO_MIME_TYPES[extension as SupportedVideoExtension]
}
return []
}
/**
* Check if file extension is a supported audio extension
*/
export function isSupportedAudioExtension(extension: string): extension is SupportedAudioExtension {
return SUPPORTED_AUDIO_EXTENSIONS.includes(extension.toLowerCase() as SupportedAudioExtension)
}
/**
* Check if file extension is a supported video extension
*/
export function isSupportedVideoExtension(extension: string): extension is SupportedVideoExtension {
return SUPPORTED_VIDEO_EXTENSIONS.includes(extension.toLowerCase() as SupportedVideoExtension)
}
/**
* Validate if an audio/video file type is supported for STT processing
*/
const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
/**
* Validate that a buffer contains valid PNG data by checking magic bytes
*/
export function isValidPng(buffer: Buffer): boolean {
return buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)
}
/**
* Detect a renderable raster image from its leading bytes, returning the canonical MIME type or
* `null` when the content is not one of the inline-renderable image formats (PNG, JPEG, GIF, WebP).
*
* The stored `contentType` is client-declared and never sniffed at upload time, so any path that
* renders a file inline for a less-trusted audience (e.g. images embedded in a public share) must
* derive the served type from the bytes themselves — a file claiming `image/png` could be HTML, SVG,
* or a script. SVG is deliberately excluded: it can carry script and is not a raster format.
*/
export function sniffImageContentType(buffer: Buffer): string | null {
if (isValidPng(buffer)) return 'image/png'
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return 'image/jpeg'
}
if (buffer.length >= 6) {
const header = buffer.toString('latin1', 0, 6)
if (header === 'GIF87a' || header === 'GIF89a') return 'image/gif'
}
if (
buffer.length >= 12 &&
buffer.toString('latin1', 0, 4) === 'RIFF' &&
buffer.toString('latin1', 8, 12) === 'WEBP'
) {
return 'image/webp'
}
return null
}
export function validateMediaFileType(
fileName: string,
mimeType: string
): FileValidationError | null {
const raw = extractExtension(fileName)
const extension = isAlphanumericExtension(raw) ? raw : ''
const isAudio = SUPPORTED_AUDIO_EXTENSIONS.includes(extension as SupportedAudioExtension)
const isVideo = SUPPORTED_VIDEO_EXTENSIONS.includes(extension as SupportedVideoExtension)
if (!isAudio && !isVideo) {
return {
code: 'UNSUPPORTED_FILE_TYPE',
message: `Unsupported media file type${extension ? `: ${extension}` : ` for "${fileName}"`}. Supported audio types: ${SUPPORTED_AUDIO_EXTENSIONS.join(', ')}. Supported video types: ${SUPPORTED_VIDEO_EXTENSIONS.join(', ')}`,
supportedTypes: [...SUPPORTED_AUDIO_EXTENSIONS, ...SUPPORTED_VIDEO_EXTENSIONS],
}
}
const baseMimeType = mimeType.split(';')[0].trim()
const allowedMimeTypes = isAudio
? SUPPORTED_AUDIO_MIME_TYPES[extension as SupportedAudioExtension]
: SUPPORTED_VIDEO_MIME_TYPES[extension as SupportedVideoExtension]
if (!allowedMimeTypes.includes(baseMimeType)) {
return {
code: 'MIME_TYPE_MISMATCH',
message: `MIME type ${baseMimeType} does not match file extension ${extension}. Expected: ${allowedMimeTypes.join(', ')}`,
supportedTypes: allowedMimeTypes,
}
}
return null
}