Normalize chat uploads for montifyshop file-type whitelist to stop 400 errors.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user