d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
/**
|
|
* 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
|
|
}
|