const OFFICE_MIME_SUBSTRINGS = [ 'application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument', 'application/vnd.oasis.opendocument', 'application/rtf' ] as const; const OFFICE_EXTENSIONS = [ '.doc', '.docm', '.docx', '.odp', '.ods', '.odt', '.ppt', '.pptm', '.pptx', '.rtf', '.xls', '.xlsm', '.xlsx' ] as const; const TEXT_ATTACHMENT_MIME_TYPES = { csv: 'text/csv', css: 'text/css', json: 'application/json', txt: 'text/plain', yaml: 'text/yaml' } as const; export const TEXT_LIKE_FILE_EXTENSIONS = [ '.txt', '.text', '.log', '.md', '.markdown', '.csv', '.tsv', '.json', '.jsonl', '.ndjson', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.env', '.sh', '.bash', '.zsh', '.js', '.jsx', '.ts', '.tsx', '.mts', '.cts', '.py', '.go', '.java', '.rb', '.php', '.sql', '.css', '.scss', '.html', '.htm' ] as const; const MIME_TYPE_FILE_EXTENSIONS: Record = { 'application/json': '.json', 'application/pdf': '.pdf', 'application/x-yaml': '.yaml', 'application/yaml': '.yaml', 'image/gif': '.gif', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'text/css': '.css', 'text/csv': '.csv', 'text/plain': '.txt', 'text/yaml': '.yaml' }; type UploadAttachmentDataLike = { data: string; mimeType?: string | null; name?: string; fileName?: string; }; type UploadImageAttachmentLike = { type: 'image'; image: string; fileName: string; }; type UploadPdfAttachmentLike = { type: 'pdf'; data: string; fileName: string; }; type UploadArchiveAttachmentLike = { type: 'archive'; data: string; fileName: string; }; type UploadTextAttachmentLike = { type: 'csv' | 'css' | 'json' | 'txt' | 'yaml'; content: string; fileName: string; mimeType?: string | null; }; type UploadUnsupportedAttachmentLike = { type: 'uploaded'; }; export type AttachmentUploadSourceLike = | Blob | File | UploadAttachmentDataLike | UploadArchiveAttachmentLike | UploadImageAttachmentLike | UploadPdfAttachmentLike | UploadTextAttachmentLike | UploadUnsupportedAttachmentLike; export interface AttachmentUploadDescriptor { blob: Blob; fileName: string; mimeType: string; } export type AttachmentUploadScopeType = 'app' | 'org'; export type AttachmentUploadPurpose = 'attachment' | 'context'; export interface AppContextUploadDto { applicationId: string; branchName?: string; commitId?: string; purpose: 'context'; scopeType: 'app'; uploaded: true; } export interface AttachmentUploadDto { applicationId?: string; attachmentId: string; contentUrl: string; fileName: string; mimeType: string | null; purpose: AttachmentUploadPurpose; scopeType: AttachmentUploadScopeType; signedUrl: string; signedUrlExpiresAt: string; storageKey: string; } export type UploadBodyDto = AppContextUploadDto | AttachmentUploadDto; // Backwards-compatible alias for existing attachment-only client consumers. export type AttachmentUploadServerDto = AttachmentUploadDto; export interface UploadedAttachmentLike { type: 'uploaded'; url: string; signedUrl: string; mediaType: string; fileName: string; label?: string; insight?: string; storageKey?: string; signedUrlExpiresAt: string; scopeType?: AttachmentUploadScopeType; applicationId?: string; } export interface AttachmentUploadMetadata { label?: string; insight?: string; } export function buildAttachmentUploadMetadata(params: AttachmentUploadMetadata): AttachmentUploadMetadata | undefined { const label = normalizeNonEmptyString(params.label); const insight = normalizeNonEmptyString(params.insight); if (!label && !insight) { return undefined; } return { ...(label ? { label } : {}), ...(insight ? { insight } : {}) }; } function normalizeNonEmptyString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } export function isTextLikeMimeType(mimeType: string): boolean { const normalizedMimeType = mimeType.toLowerCase(); return ( normalizedMimeType.startsWith('text/') || normalizedMimeType.includes('json') || normalizedMimeType.includes('xml') || normalizedMimeType.includes('yaml') || normalizedMimeType.includes('toml') || normalizedMimeType.includes('javascript') || normalizedMimeType.includes('typescript') || normalizedMimeType.includes('ecmascript') || normalizedMimeType.includes('markdown') || normalizedMimeType.includes('shellscript') || normalizedMimeType.endsWith('/x-sh') || normalizedMimeType.includes('python') || normalizedMimeType.includes('ruby') || normalizedMimeType.includes('php') || normalizedMimeType.includes('sql') ); } export function isOfficeXml(params: { fileName?: string; mimeType?: string | null }): boolean { const normalizedMimeType = normalizeNonEmptyString(params.mimeType)?.toLowerCase(); if (normalizedMimeType && OFFICE_MIME_SUBSTRINGS.some((mimeType) => normalizedMimeType.includes(mimeType))) { return true; } const normalizedFileName = normalizeNonEmptyString(params.fileName)?.toLowerCase(); if (!normalizedFileName) { return false; } return OFFICE_EXTENSIONS.some((extension) => normalizedFileName.endsWith(extension)); } export function getTextAttachmentType(params: { fileName?: string; mimeType?: string | null; }): 'csv' | 'css' | 'json' | 'txt' | 'yaml' | null { if (isOfficeXml(params)) { return null; } const normalizedFileName = normalizeNonEmptyString(params.fileName)?.toLowerCase(); const normalizedMimeType = normalizeNonEmptyString(params.mimeType)?.toLowerCase(); if (normalizedMimeType === 'text/csv' || normalizedFileName?.endsWith('.csv')) { return 'csv'; } if (normalizedMimeType === 'application/json' || normalizedFileName?.endsWith('.json')) { return 'json'; } if ( normalizedMimeType === 'text/yaml' || normalizedMimeType === 'application/x-yaml' || normalizedMimeType === 'application/yaml' || normalizedFileName?.endsWith('.yaml') || normalizedFileName?.endsWith('.yml') ) { return 'yaml'; } if (normalizedMimeType === 'text/css' || normalizedFileName?.endsWith('.css')) { return 'css'; } if ( normalizedMimeType === 'text/plain' || (normalizedMimeType ? isTextLikeMimeType(normalizedMimeType) : false) || (normalizedFileName ? TEXT_LIKE_FILE_EXTENSIONS.some((extension) => normalizedFileName.endsWith(extension)) : false) ) { return 'txt'; } return null; } function getTextLikeMimeTypeFromFileName(fileName: string | undefined): string | undefined { const textAttachmentType = getTextAttachmentType({ fileName }); return textAttachmentType ? TEXT_ATTACHMENT_MIME_TYPES[textAttachmentType] : undefined; } function isProviderSafeTextMimeType(mimeType: string): boolean { return mimeType.startsWith('text/') || mimeType === 'application/json'; } export function normalizeAttachmentMimeType(params: { fileName?: string; mimeType?: string | null; fallbackMimeType?: string | null; }): string | undefined { const normalizedMimeType = normalizeNonEmptyString(params.mimeType)?.toLowerCase() ?? normalizeNonEmptyString(params.fallbackMimeType)?.toLowerCase(); const textLikeMimeType = getTextLikeMimeTypeFromFileName(params.fileName); if (normalizedMimeType && isProviderSafeTextMimeType(normalizedMimeType)) { return normalizedMimeType; } if (textLikeMimeType) { return textLikeMimeType; } // Binary container formats such as image/svg+xml and Office OpenXML documents // match the broad isTextLikeMimeType heuristics (e.g. the substring "xml"), but // coercing them to text/plain would drop the image branch in // toUploadedAttachmentContentPart and corrupt the stored Office content type. if ( normalizedMimeType && !normalizedMimeType.startsWith('image/') && !isOfficeXml({ fileName: params.fileName, mimeType: normalizedMimeType }) && isTextLikeMimeType(normalizedMimeType) ) { return 'text/plain'; } return normalizedMimeType ?? undefined; } function getDefaultFileName(mimeType: string): string { const normalizedMimeType = mimeType.toLowerCase(); const knownExtension = MIME_TYPE_FILE_EXTENSIONS[normalizedMimeType]; if (knownExtension) { return `attachment${knownExtension}`; } if (normalizedMimeType.startsWith('image/')) { const subtype = normalizedMimeType.slice('image/'.length).split(/[+;]/)[0]?.trim(); if (subtype) { return `attachment.${subtype}`; } } return 'attachment'; } function resolveFileName(params: { fileName: unknown; fallbackFileName?: string; mimeType: string }): string { return ( normalizeNonEmptyString(params.fileName) ?? normalizeNonEmptyString(params.fallbackFileName) ?? getDefaultFileName(params.mimeType) ); } function decodeBase64(base64Data: string): Uint8Array { return Uint8Array.from(atob(base64Data), (char) => char.charCodeAt(0)); } function withMimeType(blob: Blob, mimeType: string): Blob { if (blob.type === mimeType) { return blob; } return new Blob([blob], { type: mimeType }); } function parseDataUrl(data: string): { base64Data: string; mimeType?: string } | null { const match = data.match(/^data:([^;,]+)?(?:;base64)?,(.*)$/s); if (!match) { return null; } return { mimeType: normalizeNonEmptyString(match[1]), base64Data: match[2] ?? '' }; } function isAttachmentDataLike(source: AttachmentUploadSourceLike): source is UploadAttachmentDataLike { return 'data' in source && typeof source.data === 'string' && !('type' in source); } export function toAttachmentUploadDescriptor( source: AttachmentUploadSourceLike, options?: { fallbackFileName?: string; fallbackMimeType?: string; } ): AttachmentUploadDescriptor | null { if (source instanceof File) { const mimeType = normalizeAttachmentMimeType({ fileName: source.name, mimeType: source.type, fallbackMimeType: options?.fallbackMimeType }); if (!mimeType) { return null; } return { blob: withMimeType(source, mimeType), fileName: resolveFileName({ fileName: source.name, fallbackFileName: options?.fallbackFileName, mimeType }), mimeType }; } if (source instanceof Blob) { const sourceWithOptionalName = source as Blob & { name?: unknown }; const mimeType = normalizeAttachmentMimeType({ fileName: typeof sourceWithOptionalName.name === 'string' ? sourceWithOptionalName.name : options?.fallbackFileName, mimeType: source.type, fallbackMimeType: options?.fallbackMimeType }); if (!mimeType) { return null; } return { blob: withMimeType(source, mimeType), fileName: resolveFileName({ fileName: sourceWithOptionalName.name, fallbackFileName: options?.fallbackFileName, mimeType }), mimeType }; } if (isAttachmentDataLike(source)) { const parsedDataUrl = parseDataUrl(source.data); const mimeType = normalizeAttachmentMimeType({ fileName: typeof source.name === 'string' ? source.name : source.fileName, mimeType: parsedDataUrl?.mimeType ?? source.mimeType, fallbackMimeType: options?.fallbackMimeType }); const base64Data = parsedDataUrl?.base64Data ?? source.data; if (!mimeType) { return null; } return { blob: new Blob([decodeBase64(base64Data)], { type: mimeType }), fileName: resolveFileName({ fileName: source.name ?? source.fileName, fallbackFileName: options?.fallbackFileName, mimeType }), mimeType }; } if (source.type === 'image') { const parsedDataUrl = parseDataUrl(source.image); if (!parsedDataUrl?.mimeType) { return null; } return { blob: new Blob([decodeBase64(parsedDataUrl.base64Data)], { type: parsedDataUrl.mimeType }), fileName: resolveFileName({ fileName: source.fileName, fallbackFileName: options?.fallbackFileName, mimeType: parsedDataUrl.mimeType }), mimeType: parsedDataUrl.mimeType }; } if (source.type === 'pdf' || source.type === 'archive') { const parsedDataUrl = parseDataUrl(source.data); const defaultMimeType = source.type === 'pdf' ? 'application/pdf' : 'application/octet-stream'; const mimeType = parsedDataUrl?.mimeType ?? normalizeNonEmptyString(options?.fallbackMimeType) ?? defaultMimeType; return { blob: new Blob([decodeBase64(parsedDataUrl?.base64Data ?? source.data)], { type: mimeType }), fileName: resolveFileName({ fileName: source.fileName, fallbackFileName: options?.fallbackFileName, mimeType }), mimeType }; } if (source.type === 'csv' || source.type === 'css' || source.type === 'json' || source.type === 'txt' || source.type === 'yaml') { const mimeType = normalizeAttachmentMimeType({ fileName: source.fileName, mimeType: source.mimeType, fallbackMimeType: options?.fallbackMimeType ?? TEXT_ATTACHMENT_MIME_TYPES[source.type] }); if (!mimeType) { return null; } return { blob: new Blob([source.content], { type: mimeType }), fileName: resolveFileName({ fileName: source.fileName, fallbackFileName: options?.fallbackFileName, mimeType }), mimeType }; } return null; } export function buildAttachmentUploadFormData(params: { descriptor: AttachmentUploadDescriptor; prefix: string; fieldName?: string; branchId?: string; commitId?: string; metadata?: AttachmentUploadMetadata; }): FormData { const formData = new FormData(); formData.append(params.fieldName ?? 'file', params.descriptor.blob, params.descriptor.fileName); formData.append('prefix', params.prefix); if (params.branchId) { formData.append('branchId', params.branchId); } if (params.commitId) { formData.append('commitId', params.commitId); } const metadata = buildAttachmentUploadMetadata(params.metadata ?? {}); if (metadata) { formData.append('metadata', JSON.stringify(metadata)); } return formData; } export function createUploadedAttachmentFromServer(params: { response: AttachmentUploadServerDto; fallbackFileName: string; fallbackMimeType: string; label?: string; insight?: string; }): UploadedAttachmentLike { const mediaType = normalizeNonEmptyString(params.response.mimeType) ?? params.fallbackMimeType; return { type: 'uploaded', url: normalizeNonEmptyString(params.response.contentUrl) ?? params.response.signedUrl, signedUrl: params.response.signedUrl, mediaType, fileName: normalizeNonEmptyString(params.response.fileName) ?? params.fallbackFileName, label: params.label, insight: params.insight, storageKey: normalizeNonEmptyString(params.response.storageKey), signedUrlExpiresAt: params.response.signedUrlExpiresAt, scopeType: params.response.scopeType, applicationId: normalizeNonEmptyString(params.response.applicationId) }; } /** * Shared transport for POST /api/v1/files/upload. Builds form-data, URL with query params, * fetches, and returns the raw server DTO. Callers map to their own types (e.g. Attachment, * AttachmentUploadResult). */ export async function uploadAttachmentToServerApi(params: { baseUrl: string; authorization: string; descriptor: AttachmentUploadDescriptor; scopeType: AttachmentUploadScopeType; purpose: AttachmentUploadPurpose; applicationId?: string; branchName?: string; commitId?: string; prefix?: string; metadata?: AttachmentUploadMetadata; }): Promise { const { baseUrl, authorization, descriptor, scopeType, purpose, applicationId, branchName, commitId, prefix = 'message-attachment', metadata } = params; const formData = buildAttachmentUploadFormData({ descriptor, prefix, metadata, ...(branchName != null && { branchId: branchName }), ...(commitId != null && { commitId }) }); const uploadUrl = new URL('/api/v1/files/upload', baseUrl); uploadUrl.searchParams.set('scopeType', scopeType); uploadUrl.searchParams.set('purpose', purpose); if (applicationId != null && applicationId.trim() !== '') { uploadUrl.searchParams.set('applicationId', applicationId); } if (branchName != null && branchName.trim() !== '') { uploadUrl.searchParams.set('branchName', branchName); } if (commitId != null && commitId.trim() !== '') { uploadUrl.searchParams.set('commitId', commitId); } const response = await fetch(uploadUrl.toString(), { method: 'POST', body: formData, headers: { Authorization: authorization } }); if (!response.ok) { const errorText = await response.text().catch(() => ''); throw new Error(`Attachment upload failed: status=${response.status}${errorText ? `, body=${errorText}` : ''}`); } return (await response.json()) as AttachmentUploadServerDto; }