From 80fb5bf1bbc78a99e465b861b835e78dc878a883 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 22 Oct 2026 14:28:42 +0330 Subject: [PATCH] feat(lead-forms): real file upload under 200KB and Jalali date Public form file fields compress images or reject oversized files; dates use the full Jalali picker. --- apps/api/src/main.ts | 14 +- apps/web/src/lib/form-file-compress.ts | 144 +++++++++++++++++++++ apps/web/src/views/LeadFormBuilderView.vue | 11 +- apps/web/src/views/LeadFormPublicView.vue | 130 ++++++++++++++++++- 4 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/form-file-compress.ts diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 2da6de4..9a94b97 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,11 +1,23 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { json, urlencoded } from 'express'; +import type { Request } from 'express'; import { brand } from '@mento/shared'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule, { rawBody: true }); + // bodyParser خاموش تا سقف ۲ مگ برای پیوست فشردهٔ فرم عمومی + حفظ rawBody برای وب‌هوک + const app = await NestFactory.create(AppModule, { bodyParser: false }); + app.use( + json({ + limit: '2mb', + verify: (req: Request, _res, buf) => { + (req as Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf); + }, + }), + ); + app.use(urlencoded({ extended: true, limit: '2mb' })); const config = app.get(ConfigService); app.setGlobalPrefix('api'); diff --git a/apps/web/src/lib/form-file-compress.ts b/apps/web/src/lib/form-file-compress.ts new file mode 100644 index 0000000..43e8bb9 --- /dev/null +++ b/apps/web/src/lib/form-file-compress.ts @@ -0,0 +1,144 @@ +/** سقف حجم فایل فیلد فرم عمومی پس از فشرده‌سازی */ +export const FORM_FILE_MAX_BYTES = 200 * 1024; + +export class FormFileTooLargeError extends Error { + constructor(message = 'حجم فایل زیاد است') { + super(message); + this.name = 'FormFileTooLargeError'; + } +} + +function toBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('فشرده‌سازی تصویر ناموفق بود.'))), + type, + quality, + ); + }); +} + +function fileToDataUrl(file: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('خواندن فایل ناموفق بود.')); + reader.readAsDataURL(file); + }); +} + +async function compressImageUnder(file: File, maxBytes: number): Promise { + if (file.size <= maxBytes) return file; + + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + throw new FormFileTooLargeError(); + } + + let edge = Math.max(bitmap.width, bitmap.height); + edge = Math.min(edge, 1600); + let quality = 0.85; + let best: File | null = null; + + try { + for (let attempt = 0; attempt < 14; attempt += 1) { + const ratio = Math.min(edge / bitmap.width, 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) throw new FormFileTooLargeError(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(bitmap, 0, 0, width, height); + + let blob: Blob; + try { + blob = await toBlob(canvas, 'image/jpeg', quality); + } catch { + blob = await toBlob(canvas, 'image/webp', quality); + } + + const base = file.name.replace(/\.[^/.]+$/, '') || 'file'; + const ext = blob.type === 'image/webp' ? 'webp' : 'jpg'; + const out = new File([blob], `${base}.${ext}`, { + type: blob.type, + lastModified: Date.now(), + }); + if (!best || out.size < best.size) best = out; + if (out.size <= maxBytes) return out; + + if (quality > 0.45) { + quality = Math.max(0.4, quality - 0.1); + } else { + quality = 0.72; + edge = Math.round(edge * 0.72); + if (edge < 320) break; + } + } + } finally { + bitmap.close(); + } + + if (best && best.size <= maxBytes) return best; + throw new FormFileTooLargeError(); +} + +/** + * فایل را تا سقف ۲۰۰ کیلوبایت فشرده می‌کند (تصاویر با کاهش کیفیت/ابعاد). + * اگر ممکن نباشد، خطای «حجم فایل زیاد است» می‌دهد. + */ +export async function prepareFormFieldFile(file: File): Promise<{ + name: string; + mime: string; + size: number; + dataUrl: string; +}> { + let prepared = file; + if (file.type.startsWith('image/')) { + prepared = await compressImageUnder(file, FORM_FILE_MAX_BYTES); + } else if (file.size > FORM_FILE_MAX_BYTES) { + throw new FormFileTooLargeError(); + } + + if (prepared.size > FORM_FILE_MAX_BYTES) { + throw new FormFileTooLargeError(); + } + + const dataUrl = await fileToDataUrl(prepared); + return { + name: prepared.name || file.name || 'file', + mime: prepared.type || file.type || 'application/octet-stream', + size: prepared.size, + dataUrl, + }; +} + +/** مقدار ذخیره‌شده در پاسخ فرم: نام + data URL */ +export function encodeFormFileValue(meta: { + name: string; + mime: string; + dataUrl: string; +}): string { + return JSON.stringify({ + name: meta.name, + mime: meta.mime, + dataUrl: meta.dataUrl, + }); +} + +export function formFileDisplayName(raw: string | undefined | null): string { + if (!raw) return ''; + try { + const parsed = JSON.parse(raw) as { name?: string }; + if (parsed?.name) return parsed.name; + } catch { + /* plain string */ + } + if (raw.startsWith('data:')) return 'فایل پیوست'; + return raw.length > 48 ? `${raw.slice(0, 48)}…` : raw; +} diff --git a/apps/web/src/views/LeadFormBuilderView.vue b/apps/web/src/views/LeadFormBuilderView.vue index 2c2fdb5..64c9cb0 100644 --- a/apps/web/src/views/LeadFormBuilderView.vue +++ b/apps/web/src/views/LeadFormBuilderView.vue @@ -276,7 +276,16 @@ function formatValue(val: unknown): string { if (Array.isArray(val)) return val.join('، '); if (val === true) return 'بله'; if (val === false) return 'خیر'; - return val == null ? '' : String(val); + if (val == null) return ''; + const s = String(val); + try { + const parsed = JSON.parse(s) as { name?: string; dataUrl?: string }; + if (parsed?.name && parsed?.dataUrl) return `📎 ${parsed.name}`; + } catch { + /* not a file payload */ + } + if (s.startsWith('data:')) return '📎 فایل پیوست'; + return s; } function readyIcon(type: LeadFormFieldType | string): IconName { diff --git a/apps/web/src/views/LeadFormPublicView.vue b/apps/web/src/views/LeadFormPublicView.vue index 5333c30..cb7c6a1 100644 --- a/apps/web/src/views/LeadFormPublicView.vue +++ b/apps/web/src/views/LeadFormPublicView.vue @@ -4,11 +4,22 @@ import { useRoute } from 'vue-router'; import axios from 'axios'; import type { PublicLeadFormDto, SubmitLeadFormResponse } from '@mento/shared'; import UiIcon from '@/components/UiIcon.vue'; +import JalaliDateInput from '@/components/JalaliDateInput.vue'; +import { + FormFileTooLargeError, + FORM_FILE_MAX_BYTES, + encodeFormFileValue, + formFileDisplayName, + prepareFormFieldFile, +} from '@/lib/form-file-compress'; const route = useRoute(); const loading = ref(true); const saving = ref(false); const error = ref(''); +const fieldErrors = reactive>({}); +const fileBusy = reactive>({}); +const fileInputKey = reactive>({}); const done = ref(false); const thankYou = ref(''); const started = ref(false); @@ -113,6 +124,50 @@ function hasMulti(key: string, option: string) { return (values[key] || '').split(',').map((s) => s.trim()).includes(option); } +function clearFile(key: string) { + values[key] = ''; + fieldErrors[key] = ''; + fileInputKey[key] = (fileInputKey[key] ?? 0) + 1; +} + +async function onFileChange(key: string, event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + fieldErrors[key] = ''; + if (!file) { + values[key] = ''; + return; + } + fileBusy[key] = true; + try { + const prepared = await prepareFormFieldFile(file); + values[key] = encodeFormFileValue(prepared); + } catch (e) { + values[key] = ''; + input.value = ''; + fieldErrors[key] = + e instanceof FormFileTooLargeError + ? e.message + : e instanceof Error + ? e.message + : 'آپلود فایل ناموفق بود.'; + } finally { + fileBusy[key] = false; + } +} + +function validateRequiredFiles(): string | null { + if (!form.value) return null; + for (const field of form.value.fields) { + if (field.fieldType !== 'file' || !field.required) continue; + if (!values[field.key]?.trim()) { + fieldErrors[field.key] = 'آپلود فایل الزامی است.'; + return `فیلد «${field.label || 'آپلود فایل'}» را کامل کنید.`; + } + } + return null; +} + async function submit() { if (!form.value) return; if (isPreview) { @@ -120,6 +175,15 @@ async function submit() { done.value = true; return; } + const fileErr = validateRequiredFiles(); + if (fileErr) { + error.value = fileErr; + return; + } + if (Object.values(fieldErrors).some(Boolean)) { + error.value = 'لطفاً خطاهای فایل را برطرف کنید.'; + return; + } saving.value = true; error.value = ''; try { @@ -189,7 +253,7 @@ onBeforeUnmount(() => window.removeEventListener('storage', onStorage));