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';
|
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> {
|
async function compressImageIfNeeded(file: File): Promise<File> {
|
||||||
if (!file.type.startsWith('image/')) return 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;
|
if (file.size < 350_000) return file;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1134,6 +1264,9 @@ function formatUploadFailure(status: number, bodyText: string): string {
|
|||||||
const json = JSON.parse(raw) as Record<string, unknown>;
|
const json = JSON.parse(raw) as Record<string, unknown>;
|
||||||
const err = String(json.error ?? json.message ?? '').trim();
|
const err = String(json.error ?? json.message ?? '').trim();
|
||||||
const details = String(json.details ?? '').trim();
|
const details = String(json.details ?? '').trim();
|
||||||
|
if (/invalid file type/i.test(err)) {
|
||||||
|
return 'این نوع فایل توسط سرویس آپلود رد شد. تصویر، PDF، صوت یا ویدیو را دوباره امتحان کنید.';
|
||||||
|
}
|
||||||
if (err && details) return `آپلود ناموفق بود: ${err} (${details})`;
|
if (err && details) return `آپلود ناموفق بود: ${err} (${details})`;
|
||||||
if (err) return `آپلود ناموفق بود: ${err}`;
|
if (err) return `آپلود ناموفق بود: ${err}`;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1141,6 +1274,9 @@ function formatUploadFailure(status: number, bodyText: string): string {
|
|||||||
if (snippet) return `آپلود ناموفق بود (${status}): ${snippet}`;
|
if (snippet) return `آپلود ناموفق بود (${status}): ${snippet}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (status === 400) {
|
||||||
|
return 'آپلود رد شد (۴۰۰). نوع یا نام فایل با سرویس آپلود سازگار نیست.';
|
||||||
|
}
|
||||||
if (status === 500) {
|
if (status === 500) {
|
||||||
return 'آپلود ناموفق بود (۵۰۰). سرویس ذخیرهسازی فایل (S3) روی آدرس آپلود در دسترس نیست — تنظیمات upload/download در سوپرادمین را بررسی کنید.';
|
return 'آپلود ناموفق بود (۵۰۰). سرویس ذخیرهسازی فایل (S3) روی آدرس آپلود در دسترس نیست — تنظیمات upload/download در سوپرادمین را بررسی کنید.';
|
||||||
}
|
}
|
||||||
@@ -1536,7 +1672,7 @@ export function useOrgChat() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setProgress(5);
|
setProgress(5);
|
||||||
let fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file;
|
let fileForUpload = file;
|
||||||
let outboundFileName = file.name;
|
let outboundFileName = file.name;
|
||||||
if (type === 'video') {
|
if (type === 'video') {
|
||||||
// Camouflage as image path so external chat WS delivers the message.
|
// 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`;
|
looksLikeVideo(file.name) ? file.name : `${file.name.replace(/\.[^.]+$/, '') || 'video'}.mp4`;
|
||||||
outboundFileName = original;
|
outboundFileName = original;
|
||||||
fileForUpload = new File([file], `vid-${Date.now()}.jpg`, {
|
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);
|
setProgress(12);
|
||||||
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
|
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
|
||||||
|
|||||||
@@ -2332,7 +2332,7 @@ onUnmounted(() => {
|
|||||||
type="file"
|
type="file"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
multiple
|
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"
|
@change="onFilePicked"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user