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
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:
@@ -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 = `
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
`
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user