Normalize chat uploads for montifyshop file-type whitelist to stop 400 errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-04 11:33:48 +03:30
co-authored by Cursor
parent 236f73de93
commit e144b2277a
2 changed files with 151 additions and 4 deletions
+150 -3
View File
@@ -565,9 +565,139 @@ function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'file' | 'text' {
return 'text';
}
/**
* پسوندهایی که سرویس آپلود montifyshop (مثلاً /upload/crm-montact) می‌پذیرد.
* بقیه باید تبدیل یا camouflage شوند وگرنه ۴۰۰ Invalid file type می‌دهد.
*/
const UPLOAD_DIRECT_EXT = new Set([
'jpg',
'jpeg',
'png',
'webp',
'pdf',
'mp3',
'mp4',
'webm',
'ogg',
'm4a',
]);
function fileExt(name: string): string {
const m = /\.([a-z0-9]+)$/i.exec(String(name || '').trim());
return m?.[1]?.toLowerCase() ?? '';
}
function canUploadDirectly(file: File): boolean {
const ext = fileExt(file.name);
if (ext && UPLOAD_DIRECT_EXT.has(ext)) return true;
const mime = (file.type || '').toLowerCase().split(';')[0];
return (
mime === 'image/jpeg' ||
mime === 'image/png' ||
mime === 'image/webp' ||
mime === 'application/pdf' ||
mime === 'audio/mpeg' ||
mime === 'audio/webm' ||
mime === 'audio/ogg' ||
mime === 'audio/mp4' ||
mime === 'video/mp4' ||
mime === 'video/webm' ||
mime === 'video/ogg'
);
}
async function rasterizeImageToJpeg(file: File): Promise<File | null> {
try {
const bitmap = await createImageBitmap(file);
const ratio = Math.min(
IMAGE_MAX_EDGE / bitmap.width,
IMAGE_MAX_EDGE / bitmap.height,
1,
);
const width = Math.max(1, Math.round(bitmap.width * ratio));
const height = Math.max(1, Math.round(bitmap.height * ratio));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.9);
});
if (!blob) return null;
const base = file.name.replace(/\.[^/.]+$/, '') || 'image';
return new File([blob], `${base}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
});
} catch {
return null;
}
}
/**
* سرویس آپلود whitelist سختگیرانه دارد (مثلاً docx/txt/gif/heic → 400).
* تصویرهای ناسازگار را به JPEG تبدیل می‌کنیم؛ بقیه را مثل ویدیو camouflage می‌کنیم
* (آپلود به‌صورت .pdf) و نام اصلی را برای UI/دانلود نگه می‌داریم.
*/
async function prepareFileForUploadService(
file: File,
mediaType: Exclude<ChatMediaType, 'text'>,
): Promise<{ uploadFile: File; displayName: string }> {
const displayName = file.name?.trim() || `file-${Date.now()}`;
const mime = (file.type || '').toLowerCase().split(';')[0];
const ext = fileExt(displayName);
if (mediaType === 'voice') {
if (canUploadDirectly(file)) return { uploadFile: file, displayName };
throw new Error(
'این فرمت صوت پشتیبانی نمی‌شود. webm، mp3، ogg یا m4a بفرستید.',
);
}
if (mediaType === 'image') {
const needsRaster =
mime === 'image/gif' ||
mime === 'image/bmp' ||
mime === 'image/heic' ||
mime === 'image/heif' ||
mime === 'image/svg+xml' ||
['gif', 'bmp', 'heic', 'heif', 'svg', 'tif', 'tiff'].includes(ext);
if (needsRaster || !canUploadDirectly(file)) {
const converted = await rasterizeImageToJpeg(file);
if (converted) return { uploadFile: converted, displayName: converted.name };
}
if (canUploadDirectly(file)) return { uploadFile: file, displayName };
}
if (canUploadDirectly(file)) {
return { uploadFile: file, displayName };
}
// اسناد/آرشیو و… → آپلود با پسوند pdf (whitelist سرویس)؛ نام اصلی برای دانلود می‌ماند
const uploadFile = new File([file], `doc-${Date.now()}.pdf`, {
type: 'application/pdf',
lastModified: file.lastModified,
});
return { uploadFile, displayName };
}
async function compressImageIfNeeded(file: File): Promise<File> {
if (!file.type.startsWith('image/')) return file;
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file;
// gif/svg/heic را prepareFileForUploadService قبلاً به JPEG تبدیل کرده
if (
file.type === 'image/gif' ||
file.type === 'image/svg+xml' ||
file.type === 'image/heic' ||
file.type === 'image/heif'
) {
return file;
}
if (file.size < 350_000) return file;
try {
@@ -1134,6 +1264,9 @@ function formatUploadFailure(status: number, bodyText: string): string {
const json = JSON.parse(raw) as Record<string, unknown>;
const err = String(json.error ?? json.message ?? '').trim();
const details = String(json.details ?? '').trim();
if (/invalid file type/i.test(err)) {
return 'این نوع فایل توسط سرویس آپلود رد شد. تصویر، PDF، صوت یا ویدیو را دوباره امتحان کنید.';
}
if (err && details) return `آپلود ناموفق بود: ${err} (${details})`;
if (err) return `آپلود ناموفق بود: ${err}`;
} catch {
@@ -1141,6 +1274,9 @@ function formatUploadFailure(status: number, bodyText: string): string {
if (snippet) return `آپلود ناموفق بود (${status}): ${snippet}`;
}
}
if (status === 400) {
return 'آپلود رد شد (۴۰۰). نوع یا نام فایل با سرویس آپلود سازگار نیست.';
}
if (status === 500) {
return 'آپلود ناموفق بود (۵۰۰). سرویس ذخیره‌سازی فایل (S3) روی آدرس آپلود در دسترس نیست — تنظیمات upload/download در سوپرادمین را بررسی کنید.';
}
@@ -1536,7 +1672,7 @@ export function useOrgChat() {
try {
setProgress(5);
let fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file;
let fileForUpload = file;
let outboundFileName = file.name;
if (type === 'video') {
// Camouflage as image path so external chat WS delivers the message.
@@ -1545,8 +1681,19 @@ export function useOrgChat() {
looksLikeVideo(file.name) ? file.name : `${file.name.replace(/\.[^.]+$/, '') || 'video'}.mp4`;
outboundFileName = original;
fileForUpload = new File([file], `vid-${Date.now()}.jpg`, {
type: file.type || 'video/mp4',
type: 'image/jpeg',
});
} else {
const prepared = await prepareFileForUploadService(file, type);
fileForUpload = prepared.uploadFile;
outboundFileName = prepared.displayName;
if (type === 'image') {
fileForUpload = await compressImageIfNeeded(fileForUpload);
// اگر فشرده‌سازی نام را عوض کرد (مثلاً webp)، همان را برای نمایش نگه دار
if (fileForUpload.name && fileForUpload.name !== prepared.uploadFile.name) {
outboundFileName = fileForUpload.name;
}
}
}
setProgress(12);
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
+1 -1
View File
@@ -2332,7 +2332,7 @@ onUnmounted(() => {
type="file"
class="sr-only"
multiple
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt"
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.txt,.csv"
@change="onFilePicked"
/>
<button