Author SHA1 Message Date
aramesh-site deploy 68863eb44e fix(admin): entityConfig هم کلید و هم resource را پیدا کند — رندر صفحهٔ اصلی با «منبع ناشناخته: why_us» می‌شکست
رفاکتر قبلی جستجو را فقط به resource محدود کرد در حالی که EditableItem و
AddItemButton و SingletonPill در چند صفحه با کلید فهرست صدا زده می‌شوند
(why_us، contact_section، before_after، process_steps، investment_models) —
useEntityList پرتاب خطا می‌کرد و رندر کل صفحه (خانه، خدمات، سرمایه‌گذاری و
بخش تماس) با از کار افتادن درخت ری‌اکت به صفحهٔ خالی تبدیل می‌شد.
2026-10-01 08:00:48 +00:00
aramesh-site deploy 679195b590 feat(media): سقف آپلود ۳۰ مگابایت — لبه ۴۰MB، بک‌اند ۳۵MB، پیش‌چک ۳۰MB + رمزگذاری روی ترد تا رویدادلوپ قفل نشود 2026-09-30 10:54:36 +00:00
aramesh-site deploy b070125f51 fix(frontend): مسیرهای /assets/* هرگز به index.html برنگردند — مسموم‌شدن کش CDN
در پنجرهٔ تعویض کانتینر فرانت‌اند، درخواستِ هشِ جدید که در dist کانتینرِ در
حال اجرا نبود با fallback اسپا پاسخ 200ٔ text/html می‌گرفت؛ CDN (WCDN، SMART)
همین پاسخ HTML را زیر URL فایل جاوااسکریپت با هدر immutable یک‌ساله کش می‌کرد
و اسکریپت ماژول برای همهٔ بازدیدکننده‌ها می‌شکست (سایت روی پری‌لودر قفل می‌شد).
حالا فایل ناموجود در assets واقعاً 404 می‌دهد که کش نمی‌شود.
2026-09-30 10:53:46 +00:00
aramesh-site deploy d513512b87 fix(media): پیش‌چک آپلود روی سقف واقعی CDN (۱۸MB) — WCDN بدنه‌های ~۲۰MB+ را قطع می‌کند 2026-09-30 10:44:14 +00:00
aramesh-site deploy 30038d5103 fix(media): سقف آپلود ۱۰→۲۵ مگابایت (لبه ۳۰MB) + پیام مشخص ۴۱۳ و پیش‌چک حجم سمت کاربر — فایل‌های بزرگ تیم محتوا در لبه رد می‌شدند 2026-09-30 10:37:30 +00:00
aramesh-site deploy d2ed1b1c76 fix(admin): جستجوی تعریف موجودیت بر اساس resource — ویرایش چرا آرامش/فرم تماس روی /en هم ستون فارسی را می‌نوشت
کلید فهرست ENTITIES برای چند موجودیت snake_case است (why_us، contact_section،
seo_meta، …) ولی resource همان‌ها کباب‌کامل؛ isTranslatable و سایر جستجوها با
ENTITIES[resource] پیدا نمی‌شدند و نتیجه دو بود:
- ویرایش درجای «چرا آرامش» و متن‌های فرم تماس روی صفحهٔ انگلیسی به‌جای ستون‌های
  _en ستون فارسی را بازنویسی می‌کرد (حتی با زبان ویرایش انگلیسی)؛
- قرص سئوی صفحهٔ اصلی (SingletonPill resource="seo-meta") اصلاً رندر نمی‌شد.
حالا همهٔ جستجوها با entityConfig بر اساس resource انجام می‌شود.
2026-09-30 10:20:34 +00:00
aramesh-site deploy 5530c141a2 fix(admin): title و subtitle در تعریف هیرو — ویرایش تیتر اصلی روی /en متن فارسی را بازنویسی می‌کرد
تیتر و زیرتیتر هیرو درجا ویرایش می‌شوند اما در فهرست فیلدهای موجودیت هیرو
تعریف نشده بودند؛ isTranslatable('hero','title') false برمی‌گشت، کلید به
title_en نگاشت نمی‌شد و PATCH ستون فارسی را پر می‌کرد — با زبان ویرایش
انگلیسی هم. مقادیر خراب‌شدهٔ ستون فارسی از پشتیبان شبانه بازگردانی شد.
2026-09-30 10:15:14 +00:00
aramesh-site deploy cb6fd6f5af fix(admin): تعریف section-headings در فهرست موجودیت‌ها — ویرایش تیتر بخش‌ها روی /en به ستون انگلیسی می‌رفت
تیتر و توضیح بخش‌ها (تیتر بخش خدمات، پروژه‌های شاخص، چرا آرامش و…) فقط
درجا ویرایش می‌شوند و تعریفشان در ENTITIES جا افتاده بود؛ isTranslatable
برایشان false برمی‌گشت و کلید تبدیل به title_en نمی‌شد — نتیجه اینکه
ویرایش روی صفحهٔ انگلیسی حتی با زبان ویرایش انگلیسی، متن فارسی را
بازنویسی می‌کرد و نسخهٔ انگلیسی به‌ظاهر هیچ تغییری نمی‌کرد.
2026-09-30 09:59:28 +00:00
aramesh-site deploy 1800909661 fix(media): ابعاد خروجی FFmpeg با Pillow خوانده شود — ffprobe روی WebP صفر برمی‌گرداند 2026-09-30 09:39:18 +00:00
aramesh-site deploy c0eaac1daa feat(media): هر رسانهٔ آپلودی به WebP نرمال می‌شود — Pillow مسیر اصلی، FFmpeg برای ویدیو و فرمت‌های خاص؛ ویدیو تا ۸ثانیه/۱۲fps وب متحرک، غیرقابل‌رمزگشایی ۴۱۵ 2026-09-30 09:27:21 +00:00
aramesh-site deploy 79ebe69221 fix(admin): زبان ویرایش از زبان صفحه پیروی کند — ویرایش روی /en دیگر متن فارسی را بازنویسی نمی‌کند
قبلاً زبان ویرایش (editLang) کلید جداگانه‌ای در نوار ادمین بود که با زبان صفحهٔ
در حال نمایش هیچ ارتباطی نداشت و پیش‌فرضش فارسی می‌ماند؛ نتیجه‌اش این بود که
ویرایش درجا روی صفحه‌های /en به ستون‌های فارسی می‌رفت و تغییرها «سمت دیگر»
یعنی سایت فارسی ظاهر می‌شدند.

- AdminBar: در حالت ویرایش، editLang با زبان URL همگام می‌شود (syncEditLang)؛
  کلید دستی فقط برای ویرایش ستون زبان دیگر روی همین صفحه می‌ماند.
- entities: تعریف status-labels اضافه شد — نبودش یعنی ویرایش برچسب وضعیت روی
  صفحهٔ انگلیسی همیشه ستون فارسی را بازنویسی می‌کرد.
2026-09-30 09:14:34 +00:00
aramesh-site deploy e5fe31f6ba feat(admin): پس از ذخیرهٔ موفق پروژهٔ موجود، بازگشت به فهرست — نبود بازخورد «ذخیره شد» القای شکست می‌کرد 2026-09-30 08:11:15 +00:00
aramesh-site deploy 05945099a1 fix(admin): creator_scoped=True روی ثبت مقاله‌ها — منطق نقش فعال شد
پارامتر تعریف شده بود ولی هنگام ثبت مقاله‌ها پاس داده نمی‌شد؛ نتیجه:
ساخت/ویرایش نقش creator مثل ادمین رفتار می‌کرد (status=published قابل ثبت).
2026-09-29 09:58:45 +00:00
aramesh-site deploy 399a76da8e feat(auth): author_id در خروجی مقاله‌ها 2026-09-29 09:48:29 +00:00
aramesh-site deploy 659b1a1943 fix(admin): ایمپورت oauth2_scheme برای گارد نقش 2026-09-29 09:35:49 +00:00
aramesh-site deploy fb1d53d97a fix(admin): گارد نقش پیش از تعریف روتر — NameError در استارتاپ 2026-09-29 08:51:01 +00:00
aramesh-site deploy 79813ff52c feat(auth): نقش سوم «creator» — نویسندهٔ محتوا با دسترسی محدود
- بک‌اند: ستون role برای admin_users (admin/creator) + author_id برای مقاله‌ها
- نقش‌گارد روی کل روتر مدیریتی: creator فقط لیست/ساخت/خواندن/ویرایش
  پیش‌نویس‌های «خودش» را می‌بیند؛ بقیهٔ مسیرهای مدیریتی ۴۰۳
- ساخت توسط creator همیشه پیش‌نویس است و ویرایشش هرگز منتشر نمی‌شود
  (حتی تلاش برای publish در payload نادیده گرفته می‌شود)
- حذف/ترتیب‌بندی/پیام‌ها/خبرنامه/سایر بخش‌ها برای creator ممنوع
- لاگین نقش را برمی‌گرداند؛ پنل: AdminBar نویسنده فقط «نوشتن مقاله» +
  زبان ویرایش + رمز + خروج دارد؛ دکمه‌های انتشار/حذف مقاله فقط برای admin
- ورود creator مستقیم به صفحهٔ نوشتن مقاله هدایت می‌شود
2026-09-29 08:38:16 +00:00
aramesh-site deploy 1b571387df perf(home): متن هیرو بلافاصله دیده شود — تاخیرهای ۲.۱-۲.۶ ثانیه حذف و درخواست‌های داده موازی
- انیمیشن ورود هیرو تاخیر ۲.۱ تا ۲.۶ ثانیه داشت (بعد از بارگذاری JS+API هم
  متن ۳ ثانیه نامرئی بود)؛ حالا ورود نرم با تاخیر ۰.۰۵-۰.۳۶ ثانیه
- SiteProvider: /api/site و /api/admin/articles پشت سر هم fetch می‌شدند و
  setData منتظر هر دو می‌ماند؛ حالا همزمان شروع می‌شوند
2026-09-28 11:27:59 +00:00
aramesh-site deploy e1bf404455 fix(typography): فهرست‌های مقاله هم مثل پاراگراف justify شوند + متن پروژه justify
قبلاً li از قاعدهٔ justify مستثنا بود و راست/چپ‌چین می‌شد؛ حالا فقط تیترها
تراز می‌مانند. متن پروژه در صفحهٔ جزئیات هم justify شد.
2026-09-28 11:15:22 +00:00
aramesh-site deploy 18e226ad47 fix(toc): حذف نوار اسکرول فهرست مطالب و پهن‌کردن ستون آن
- ستون فهرست در دسکتاپ از ۱۷rem به ۲۱rem پهن‌تر شد تا تیترهای بلند جا شوند
- نوار اسکرول بومی مرورگر (با فلش) مخفی شد؛ اسکرول با چرخ ماوس/لمس سر جایش است
2026-09-28 11:06:51 +00:00
aramesh-site deploy 182a1bb674 feat(projects): داده‌های واقعی پروژه‌ها — ستون‌های تازه و حذف داده‌های ماک
- دیتابیس/مدل/اسکیما: land_area (مساحت عرصه)، pilot_floors (طبقات روی
  همکف/پیلوت) و سه ستون واحدها (مسکونی/تجاری/اداری) به‌جای «واحدها»ی تکی
- فایل استاتیک projectsData (داده‌های ماک) حذف شد؛ لیست، جزئیات و
  «پروژه‌های شاخص» همه از دیتابیس می‌خوانند
- برچسب‌های i18n فهرست مشخصات مطابق شیت تیم
2026-09-28 10:50:39 +00:00
aramesh-site deploy 0c87a287a3 feat(admin): صفحهٔ اختصاصی ویرایش/ساخت پروژه + گالری فقط با آپلود به MinIO
- /admin/projects/new و /admin/projects/:id — همهٔ فیلدها در یک صفحه با
  ورودی دوزبانهٔ صریح (فارسی/English)، ذخیره، حذف
- GalleryField تازه: گالری چندتصویری فقط با آپلود به MinIO (حذف/جابه‌جایی/
  افزودن چندفایلی) — فیلد «هر خط یک URL» دیگر استفاده نمی‌شود
- مدادِ کارت پروژه و دکمهٔ «افزودن پروژه» به صفحهٔ اختصاصی هدایت می‌شوند؛
  دکمهٔ ویرایش روی صفحهٔ جزئیات هم لینک همان صفحه است
2026-09-28 10:33:05 +00:00
aramesh-site deploy edcbd0dc58 feat(projects): فیلدهای «نوع پروژه» و «کاربری» + فهرست مشخصات جدید صفحهٔ جزئیات
- دیتابیس/مدل/اسکیما: ستون‌های project_type/usage دوزبانه (مهاجرت DDL
  idempotent — با استارت بک‌اند اعمال می‌شود)
- پنل: کشویر پروژه‌ها دو فیلد تازه گرفت
- صفحهٔ جزئیات: فهرست مشخصات مطابق داده‌های تیم — وضعیت / سال / طبقات /
  واحدها / نوع پروژه / زیربنا / کاربری (ردیف‌های خالی پنهان‌اند)
2026-09-28 10:17:25 +00:00
aramesh-site deploy 5cf7b51686 revert(ui): بازگردانی دکمه‌های فرم تماس، خبرنامه، مقالات، داستان و صفحهٔ مقاله به طرح قبلی
همه جای Button/PremiumButton جابه‌جا شده بودند؛ به Button/ButtonLink قبلی
برگشتند (کامپوننت Button قبلاً بازگردانده شده بود).
2026-09-28 09:58:49 +00:00
aramesh-site deploy 531e8d63c1 revert(ui): بازگردانی هیروی صفحهٔ اصلی به حالت پیش از UI v1
- دکمه‌های CTA: بازگشت به ButtonLink شیشه‌ای (اصلی با دایرهٔ مسی، ثانویه مات)
- کارت شناور آمار (badge_value/badge_label) که UI v1 حذف کرده بود برگشت —
  فیلدهایش در کشوی «تنظیمات هیرو» دوباره معنا دارند
- Button.tsx که قبلاً بازگردانده شده بود دوباره استفاده می‌شود
2026-09-28 09:52:21 +00:00
aramesh-site deploy dc7a92f040 revert(ui): بازگردانی کامپوننت Button (ButtonLink) — وابستگی ساختار قدیمی جزئیات پروژه
UI v1 این کامپوننت را حذف کرده بود؛ ساختار قدیمی صفحهٔ جزئیات پروژه به آن
وابسته است (دکمهٔ بازگشت به فهرست پروژه‌ها).
2026-09-28 09:41:11 +00:00
aramesh-site deploy 11552d6124 revert(ui): بازگردانی سه بخش به حالت پیش از UI v1 (کامیت 853ad67)
- دکمهٔ تغییر زبان: کپسول بزرگ دوزبانه → دکمهٔ کوچک آیکون+متن (طرح قبلی)
- ساختار صفحهٔ جزئیات پروژه: بازگشت به چیدمان قبلی (دیتابیس‌محور بود و می‌ماند)
- قالب پرسش‌وپاسخ: بازگشت به چیدمان دوستونه (تیتر کنار آکاردئون)

توجه: بخشی از UI v1 (fatemeh) عمداً برگردانده شده به درخواست تیم UI؛
بقیهٔ تغییرات آن کامیت سر جایش است. ویرایش‌پذیری دیتابیس‌محور پروژه‌ها
و دکمهٔ ویرایش، روی همین ساختار قدیمی دوباره اعمال شده‌اند.
2026-09-28 09:30:15 +00:00
aramesh-site deploy 7b4783bb3e feat(admin): ویرایش‌پذیری هیروی صفحه‌ها و پروژه‌ها
- page-heroes به فهرست موجودیت‌های پنل اضافه شد و PageHero یک دکمهٔ واضح
  «عنوان، سرآغاز و تصویر هیروی این صفحه» گرفت (پیش‌تر فقط ویرایش درجای
  نامرئی روی خود متن بود).
- صفحهٔ پروژه‌ها و صفحهٔ جزئیات پروژه حالا از دیتابیس خوانده می‌شوند
  (فایل استاتیک projectsData فقط جانشین دیتابیس خالی/اسلاگ‌های قدیمی است) —
  پیش‌تر ویرایش پنل روی این دو صفحه اصلاً نمایش داده نمی‌شد.
- روی لیست پروژه‌ها دکمهٔ ویرایش/جابه‌جایی/حذف و «افزودن پروژه» اضافه شد و
  روی صفحهٔ جزئیات هر پروژه دکمهٔ ویرایش همان پروژه.
2026-09-28 08:36:30 +00:00
aramesh-site deploy 98c7977a19 fix(frontend): کپشن وسط‌چین، نام تصویر در Alt، و تورفتگی واضح H3 در فهرست
- کپشن: قاعدهٔ وسط‌چینِ .article-body figcaption با قاعده‌های [data-dir=...] برای
  راست/چپ‌چین بازنویسی می‌شد و عملاً وسط‌چین نمی‌شد؛ figcaption از آن فهرست‌ها
  برداشته شد. متن کپشن در ویرایشگر هم وسط‌چین شد تا با خروجی سایت یکی باشد.
- نام تصویر: نامی که پیش از بارگذاری نوشته می‌شود، حالا همان Alt تصویر می‌شود
  (پیش‌تر نام فایل مبنا بود و باید دستی در جعبهٔ زیر تصویر اصلاح می‌شد).
- فهرست مطالب: H3 با ps-4 فقط ۴ پیکسل بیشتر از H2 تورفتگی داشت (عملاً نامرئی)؛
  حالا ps-6 با فونت یک پله کوچک‌تر.
2026-09-28 07:49:08 +00:00
aramesh-site deploy c9e0720764 feat(preview): پنل مدیریت و API از گیت Basic Auth مستثنی شدند
پیش‌نمایش خصوصی دو بار احراز هویت می‌خواست (Basic Auth لبه + ورود خود پنل) و
هر origin (www/apex/.ir) هم پرسش جداگانه‌ای داشت؛ نتیجه سردرگمی تیم.
حالا روترهای /admin و /api میدل‌ور preview-auth ندارند (noindex می‌ماند) و
پنل با همان ورود خودش کار می‌کند؛ صفحات عمومی همچنان خصوصی‌اند.
2026-09-27 11:20:17 +00:00
aramesh-site deploy 87b744c9c7 fix(scripts): جلوگیری از خرابی Basic Auth با کوتیشن‌کردن مقدارهای دارای $
docker compose مقادیر .env را خودش تفسیر می‌کند؛ هش htpasswd شامل $apr1$...
است و بدون کوتیشن، $apr1 و بخش‌های بعدی به‌عنوان متغیر خالی جایگزین می‌شدند
(برچسب تبدیل به preview:/OBoRS/... می‌شد) و پیش‌نمایش خصوصی هرگز رمز را
نمی‌پذیرفت. حالا env_set مقدارهای دارای $ را تک‌کوتیشن می‌گذارد و env_get
کوتیشن را برمی‌دارد.
2026-09-27 10:41:00 +00:00
aramesh-site deploy 275c3d6bb3 fix(chatbot): row_factory=dict_row در sync_chunks و fetch_all_chunks
بدون آن، psycopg3 تاپل برمی‌گرداند و row["model"] خطای
«tuple indices must be integers or slices, not str» می‌داد؛ نتیجه:
«Initial index sync failed» و unavailable ماندن چت با وجود امبد موفق.
2026-09-27 10:32:48 +00:00
aramesh-site deploy ef619c635e fix(chatbot): کوتیشن desc در سه کوئری دیگر ایندکس
همان باگ قبلی در investment_models/process_steps/why_us/timeline هم بود؛
بدون این، سینک ایندکس ناقص می‌ماند و بخشی از محتوا هرگز بردار نمی‌گرفت.
2026-09-27 10:31:12 +00:00
aramesh-site deploy d55c8cc1e5 fix(chatbot): کوئری خدمات و نصب torch نسخهٔ CPU
- content.py: ستون desc در SQL بدون کوتیشن بود و PostgreSQL آن را کلمهٔ کلیدی
  می‌گیرد؛ نتیجه: «Initial index sync failed: syntax error at or near "desc"»
  در استارت و غیرقابل استفاده ماندن چت تا reindex. حالا "desc" کوتیشن شده.
- Dockerfile: با این‌که سرور GPU ندارد، pip نسخهٔ CUDA تورچ را از PyPI می‌گرفت
  (ایمیج ۹.۷GB با کتابخانه‌های nvidia). اول torch از ایندکس CPU نصب می‌شود.
2026-09-26 22:04:29 +00:00
aramesh-site deploy 9d0015808c feat(frontend): تحلیل رفتار با OpenPanel به‌جای اجرای نمونهٔ دوم Umami
نمونهٔ OpenPanel موجود (stats.*) با یک پروژهٔ دوم برای این سایت استفاده می‌شود؛
VITE_OPENPANEL_URL/VITE_OPENPANEL_CLIENT_ID در زمان بیلد تزریق می‌شوند و در
نبودشان همه‌چیز بی‌اثر است. API عمومی track() تغییر نکرده و مسیر Umami هم
برای عقب‌نشینی دست‌نخورده مانده.
2026-09-26 21:43:59 +00:00
aramesh-site deploy 8600d2d4a3 fix(scripts): جلوگیری از تولید راز خالی و اجرای اسکریپت پیش‌نمایش با python3
- gen-env.sh: rand با دو آرگومان صدا زده می‌شد ولی فقط $1 را می‌خواند؛
  openssl rand -hex بدون تعداد بایت خروجی خالی و کد ۰ می‌دهد، پس fallback
  هرگز اجرا نمی‌شد و .env با رازهای خالی ساخته می‌شد. حالا تعداد بایت
  رعایت می‌شود و اگر مقداری خالی بماند، اسکریپت با خطا متوقف می‌شود.
- preview-mode.sh: python روی اوبونتو ۲۴.۰۴ وجود ندارد (فقط python3).
2026-09-26 21:43:59 +00:00
MMD 846682ac4a صفحه تماس: حذف بخش دفاتر مطابق تصمیم تیم UI
CI / tests (push) Canceled after 0s
CI / images (push) Canceled after 0s
بازگرداندن من اشتباه بود — تیم UI عمداً این بخش را برداشته/جابه‌جا کرده است.
جای‌نگهدار خودشان (section خالی با aria-label) حفظ شد و فقط importهای
بی‌استفاده حذف شدند تا تایپ‌چک تمیز بماند.
2026-09-26 23:30:18 +03:30
MMD f7105a62cc بازگرداندن بخش دفاتر در صفحه تماس
شاخه UI این بخش را به یک section خالی تبدیل کرده بود (کار ناتمام)؛
ادغام خودکار آن را بی‌صدا حذف می‌کرد. محتوای دفاتر (شهر/نشانی/ساعات)
با همان چیدمان و ویرایش‌پذیری مدیریتی برگردانده شد.
2026-09-26 23:09:13 +03:30
MMD 0628344dcf Merge remote-tracking branch 'origin/feature/ui-updates-v1' into new-one 2026-09-26 23:04:15 +03:30
MMD d25db9367c پیش‌نمایش خصوصی: سایت آنلاین اما غیرعمومی تا زمان انتشار
- گیت ورود Traefik (Basic Auth) روی همه مسیرها: سایت، API، تصاویر و چت — بدون رمز فقط ۴۰۱
- لایه دوم noindex: هدر X-Robots-Tag روی همه پاسخ‌ها + robots.txt مسدودکننده + نقشه سایت خالی
- اسکریپت scripts/preview-mode.sh on|off|status — ساخت خودکار نام کاربری/رمز و هماهنگ‌کردن .env
- دو دامنه (DOMAIN + DOMAIN_ALIAS) با گواهی TLS و ریدایرکت ۳۰۱ دامنه دوم به اصلی در زمان انتشار
- اسکیمای FAQPage در پوسته سئوی سمت سرور (فارسی و انگلیسی) برای خزنده‌های بدون جاوااسکریپت
- مستندسازی جریان پیش‌نمایش و چک‌لیست انتشار در README و .env.example
2026-09-26 22:47:04 +03:30
MMD 1d29e50852 وبلاگ: نام‌گذاری و متادیتای تصاویر، پرسش‌وپاسخ مقاله، میزبانی تصاویر چسبانده‌شده
- Alt/Title تصویر شاخص اکنون ذخیره می‌شود (ستون‌های جدید image_alt/image_title — قبلاً بی‌صدا دور ریخته می‌شد)
- Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایش‌اند (کنار کپشن)
- نام فایل ذخیره‌سازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (به‌جای UUID خالی)
- پنل پرسش‌وپاسخ پایان مقاله (ستون‌های faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage
- چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی می‌شوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
2026-09-26 22:46:56 +03:30
MMD 851e3fc2dc fix(compose): umami healthcheck via 127.0.0.1 — localhost resolves to ::1 and umami listens on IPv4 only 2026-09-19 01:24:15 +03:30
MMD 74770cd99d fix(stack): bring-up fixes from first local deployment
- traefik pin v3.3 -> v3.6: v3.3's old docker client fails against
  Engine 29 socket proxy (empty daemon errors, no routers)
- backend/minio/chatbot/umami: traefik.docker.network hint — Traefik
  picked the internal-network IP for multi-network services and replies
  were hairpin-dropped (all backend routes hung)
- db-init: psql needs an explicit -d arameshplaza (no default db 'aramesh'),
  set -e so failures actually fail; one-shot folded-YAML needs semicolons
- chatbot image: drop g++ (apt unreachable in build network; all wheels
  prebuilt) and mkdir /data before chown (VOLUME does not create it)
- frontend image: create caddy user (alpine image has none) with writable
  /config and /data
- monitoring: blackbox probes replace raw metric scrapes of JSON/HTML
  health endpoints (they reported 'down' while healthy); blackbox joins
  edge; SITE_URL passed via env (Prometheus has no ${VAR:-default}
  expansion); ContainerMemoryNearLimit PromQL fixed (comparison inside
  label braces); HostDiskAlmostFull filters WSL/Docker-Desktop
  pseudo-mounts
- alertmanager: config rendered by stack.sh (no env expansion in AM) and
  delivered to Bale (tapi.bale.ai); stack.sh monitoring command
- machine-local override: minio console/API moved to 29001/29000
2026-09-19 01:22:02 +03:30
MMD ce0735d775 feat(stack): full overhaul — hardening, ops tooling, monitoring, analytics, chatbot
Security: deep /api/health (DB+MinIO probes, 503 on failure) + /api/live;
rate-limited public forms with honeypots; real client IP behind Traefik
(--proxy-headers); TipTap URL sanitization on write (nh3); non-root
containers + slim .dockerignore; prod MinIO console bound to 127.0.0.1;
HSTS/nosniff on TLS routers; JWT 8h; no default credentials in code.

Reliability: Alembic migrations (create_all baseline); graceful shutdown
(exec uvicorn); hardened backups (checksums, sanity check, freshness gauge,
optional rclone off-site) + restore.sh + hourly db-backup sidecar (ops
profile); stack.sh ops tooling (deploy/logs/health/backup/restore/monitoring);
.gitattributes LF for shell scripts.

Orchestration: json-file log rotation, pinned images, per-service memory
limits, edge/internal networks, Traefik ping/accesslog/metrics + flood
limiter, conditional minio-setup, pgvector db image, one-shot db-init
(umami + aramesh_chatbot databases).

Chatbot: unified from site-chatbot branch (RAG + pgvector, SSE, ChatWidget).

Monitoring: Prometheus/Alertmanager/Loki/Promtail/Grafana + cAdvisor,
node/postgres/blackbox exporters, provisioned dashboards and 10 alert
rules; alerts delivered to Bale (tapi.bale.ai — Telegram-compatible).

Analytics: Umami on shared Postgres, build-time tracker injection, custom
events (contact/newsletter/phone/chat/lang) via frontend/src/analytics.ts.

Hygiene: CSV export for contact messages and subscribers; upload image
optimization (Pillow); CI script + GitHub-compatible workflow; audit.sh;
gen-env.sh for strong credentials.
2026-09-19 00:24:57 +03:30
110 changed files with 6874 additions and 666 deletions
+48
View File
@@ -1,4 +1,5 @@
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید. # کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
# راه ساده‌تر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی می‌سازد.
# docker compose به‌طور خودکار این فایل را می‌خواند؛ بدون آن استک بالا نمی‌آید. # docker compose به‌طور خودکار این فایل را می‌خواند؛ بدون آن استک بالا نمی‌آید.
# --- رمزهای اصلی (اجباری) --- # --- رمزهای اصلی (اجباری) ---
@@ -14,10 +15,57 @@ ADMIN_PASSWORD=change-me-admin-password
# پورت ورود Traefik در توسعه (پیش‌فرض 5173) # پورت ورود Traefik در توسعه (پیش‌فرض 5173)
TRAEFIK_HTTP_PORT=5173 TRAEFIK_HTTP_PORT=5173
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example # نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
# در تولید این مقدار هم پایهٔ پروب بیرون‌نگر مانیتورینگ است.
SITE_URL= SITE_URL=
# آدرس‌های مجاز CORS (لیست JSON) — پیش‌فرض فقط لوکال‌هاست # آدرس‌های مجاز CORS (لیست JSON) — پیش‌فرض فقط لوکال‌هاست
# CORS_ORIGINS=["http://localhost:5173"] # CORS_ORIGINS=["http://localhost:5173"]
# --- بیلد فرانت‌اند (در زمان npm run build روی هاست خوانده می‌شود — stack.sh پاس می‌دهد) ---
# اگر API روی مبدأ دیگری است (مثل توسعه ویندوز با پورت 19001) تنظیم کنید؛ هم‌مبدأ یعنی خالی
VITE_API_URL=
# اسکریپت ردیاب Umami — خالی یعنی آنالیتیکس خاموش؛ در تولید مثل:
# VITE_UMAMI_SRC=https://analytics.aramesh.example/script.js
VITE_UMAMI_SRC=
# شناسهٔ وب‌سایت از داشبورد Umami (اولین بار: وب‌سایت را در analytics.localhost بسازید)
VITE_UMAMI_WEBSITE_ID=
# --- چت‌بات (دستیار هوشمند) ---
# LLM سازگار با OpenAI — اجباری برای پاسخ‌گویی چت‌بات؛ خالی یعنی سرویس بالا می‌آید اما پاسخ نمی‌دهد
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=openai/gpt-oss-120b
# توکن محافظت از POST /chat/reindex — خالی یعنی فقط از شبکه داخلی
CHAT_REINDEX_TOKEN=
# --- مانیتورینگ (docker-compose.monitoring.yml) ---
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=change-me-grafana-password
# هشدارها به بله — توکن بات (همان شکل تلگرام؛ از BotFather بله) و شناسه چت/گروه
# شناسه چت: به بات یک پیام بدهید سپس:
# curl "https://tapi.bale.ai/bot<توکن>/getUpdates"
ALERT_BOT_TOKEN=
ALERT_CHAT_ID=0
# --- عملیات ---
# نگهداری پشتیبان‌های شبانه (روز) — scripts/backup.sh
RETENTION_DAYS=14
# انتشار پشتیبان خارج از سرور (rclone remote مثل storage:aramesh-backups) — خالی یعنی خاموش
OFFSITE_RCLONE_REMOTE=
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) --- # --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
# DOMAIN=aramesh.example # DOMAIN=aramesh.example
# ACME_EMAIL=admin@aramesh.example # ACME_EMAIL=admin@aramesh.example
# دامنه دوم (در صورت وجود) — مسیرها و گواهی TLS برای هر دو صادر می‌شود
# DOMAIN_ALIAS=aramesh.example.ir
# --- پیش‌نمایش خصوصی و انتشار (scripts/preview-mode.sh) ---
# «on» یعنی سایت آنلاین است اما پشت گیت ورود و بدون ایندکس؛ «off» یعنی انتشار عمومی.
# این مقادیر را دستی ویرایش نکنید — اسکریپت preview-mode.sh هماهنگشان می‌کند:
# bash scripts/preview-mode.sh on|off|status → bash scripts/stack.sh deploy-prod
# PREVIEW_MODE=on
# هش htpasswd کاربر مشترک پیش‌نمایش — اولین «on» خودش می‌سازد
# PREVIEW_AUTH_USERS=preview:$apr1$...
# فهرست میان‌افزارهای فعال؛ «on» پرش می‌کند و «off» خالیش می‌کند
# PREVIEW_MIDDLEWARES=preview-auth@docker,preview-noindex@docker
# در انتشار، دامنه دوم را به اصلی ریدایرکت می‌کند تا محتوای تکراری ساخته نشود
# ALIAS_REDIRECT_MIDDLEWARES=to-primary@docker
+7
View File
@@ -0,0 +1,7 @@
# اسکریپت‌های شل باید در لینوکس هم LF باشند — CRLF در bash می‌شکند
*.sh text eol=lf
*.yml text
*.yaml text
*.json text
Dockerfile text
*.py text
+47
View File
@@ -0,0 +1,47 @@
name: CI
# گیت سرور self-hosted شما (Gitea Actions / Forgejo) همین سینتکس را اجرا می‌کند؛
# روی GitHub هم بی‌تغییر کار می‌کند.
on:
push:
branches: [main]
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install backend deps
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run backend tests
working-directory: backend
run: pytest -q
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Frontend typecheck/build
working-directory: frontend
run: |
npm ci
npm run build
images:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v4
- name: Build images
run: docker compose -f docker-compose.yml build backend frontend chatbot
+7
View File
@@ -9,6 +9,12 @@ env/
# Environment # Environment
.env .env
# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
monitoring/alertmanager/alertmanager.rendered.yml
# تنظیمات محلی هر ماشین
docker-compose.override.yml
# پشتیبان‌گیری محلی # پشتیبان‌گیری محلی
backups/ backups/
@@ -22,6 +28,7 @@ dist/
.vscode/ .vscode/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.zcode/
# Database # Database
*.sqlite3 *.sqlite3
+199 -71
View File
@@ -3,15 +3,20 @@
مونوریپوی سایت گروه ساختمانی آرامش شامل: مونوریپوی سایت گروه ساختمانی آرامش شامل:
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متن‌ها و تصاویر از API خوانده می‌شود) - **`frontend/`** — سایت React + Vite + Tailwind (تمام متن‌ها و تصاویر از API خوانده می‌شود)
- **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO - **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL (pgvector) + JWT + MinIO
- **`chatbot/`** — دستیار هوشمند (FastAPI + LangGraph + RAG با pgvector، پاسخ استریمی)
- **مانیتورینگ و آنالیتیکس** — Prometheus/Grafana/Loki و Umami به‌صورت لایه‌های compose
## معماری ## معماری
``` ```
Traefik (پورت ۵۱۷۳ — تنها نقطه ورود) Traefik (تنها نقطه ورود — 80/443 در تولید)
├── / → Caddy (فایل‌های استاتیک SPA) ├── / → Caddy (فایل‌های استاتیک SPA)
├── /api → FastAPI (بک‌اند) ├── /api → FastAPI (بک‌اند) + متریک /metrics فقط داخلی
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی) ├── /media → MinIO (باکت aramesh-media)
├── /chat → چت‌بات (SSE استریم پاسخ)
├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
└── analytics.* → Umami (آنالیتیکس محصول)
``` ```
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانت‌اند هاردکد نشده؛ - **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانت‌اند هاردکد نشده؛
@@ -19,107 +24,213 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره می‌شوند و با مسیر نسبی - **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره می‌شوند و با مسیر نسبی
`/media/...` برگردانده می‌شوند — بک‌اند ذخیره‌سازی بدون تغییر محتوای دیتابیس قابل تعویض است `/media/...` برگردانده می‌شوند — بک‌اند ذخیره‌سازی بدون تغییر محتوای دیتابیس قابل تعویض است
- مسیریابی همه‌چیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد - مسیریابی همه‌چیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
- شبکه‌های compose تفکیک شده‌اند: `edge` (سرویس‌های پشت Traefik) و `internal`
(دیتابیس/ذخیره‌سازی) — فرانت‌اند هیچ راه مستقیمی به دیتابیس ندارد
- **لاگ‌ها**: json-file با سقف ۱۰MB×۳ برای هر سرویس؛ علاوه بر آن Promtail همه را به
Loki می‌فرستد تا در Grafana جست‌وجو شوند
## ساختار ## ساختار
``` ```
├── frontend/ # سایت React ├── frontend/ # سایت React (بیلد روی هاست — ایمیج فقط سرو می‌کند)
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند) │ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند)
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy │ ├── Caddyfile # سرو استاتیک + کش immutable برای /assets/*
│ └── Caddyfile # فقط سرو فایل‌های استاتیک (مسیریابی روی Traefik است) │ └── Dockerfile # Caddy — بدون root
├── backend/ # بک‌اند FastAPI ├── backend/ # بک‌اند FastAPI
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py) │ ├── app/ # کد منبع (آپلود: app/api/admin.py — سئو: app/seo_pages.py)
│ ├── tests/ # تست‌های API (pytest) │ ├── migrations/ # مهاجرت‌های نسخه‌دار Alembic
│ ├── requirements.txt │ ├── tests/ # تست‌های API (pytest)
│ ├── Dockerfile │ ├── alembic.ini
│ └── .env.example │ └── Dockerfile # بدون root — خاموشی تمیز با exec
├── scripts/backup.sh # پشتیبان‌گیری دیتابیس و تصاویر ├── chatbot/ # دستیار هوشمند (RAG + pgvector)
├── docker-compose.yml # اجرای کامل با یک دستور ├── monitoring/ # پیکربندی Prometheus/Alertmanager/Loki/Promtail/Grafana
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt ├── scripts/
│ ├── stack.sh # ابزار اصلی عملیات — deploy/logs/health/backup/restore/update
│ ├── backup.sh # پشتیبان کامل (DB + تصاویر) با چک‌سام و گزینه انتشار بیرونی
│ ├── restore.sh # بازگردانی پشتیبان دیتابیس
│ ├── gen-env.sh # ساخت .env با رمزهای قوی تصادفی
│ ├── audit.sh # ممیزی آسیب‌پذیری وابستگی‌ها و ایمیج‌ها
│ ├── migrate-media.py # انتقال یک‌باره تصاویر CDN خارجی به MinIO
│ └── ci.sh # تست + بیلد ایمیج (محلی یا هر CI)
├── docker-compose.yml # استک اصلی (شامل چت‌بات و Umami)
├── docker-compose.monitoring.yml # لایه مانیتورینگ
├── docker-compose.prod.yml # لایه تولید: TLS، دامنه، سخت‌سازی پورت‌ها
└── docker-compose.override.yml # تنظیمات محلی این ماشین (در گیت نیست)
``` ```
## اجرا با Docker (ساده‌ترین راه) ## اجرا با Docker
پیش‌نیاز: Docker Desktop. بیلد فرانت‌اند روی هاست انجام می‌شود (کانتینر فقط فایل‌های پیش‌نیاز: Docker Desktop. بیلد فرانت‌اند روی هاست انجام می‌شود (کانتینر فقط فایل‌های
`dist/` را سرو می‌کند)، پس اولین بار باید یک‌بار `npm run build` بزنید: `dist/` را سرو می‌کند) — `scripts/stack.sh` همین را به یک دستور تبدیل می‌کند:
```bash ```bash
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمی‌آید cp .env.example .env # یا: bash scripts/gen-env.sh (رمزهای قوی تصادفی)
cd frontend && npm install && npm run build && cd .. cd frontend && npm install && cd ..
docker compose up -d --build bash scripts/stack.sh deploy # بیلد فرانت + بیلد و بالا آوردن استک + انتظار برای سلامت
``` ```
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`) - سایت: http://localhost:18081 (پورت از `TRAEFIK_HTTP_PORT` در `.env`) — ورود مدیریت: `/admin/login`
- دستورهای عملیات: `bash scripts/stack.sh` — `logs`، `ps`، `health`، `backup`، `restore`، `update`، `seed-force`
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی) - مستندات API: http://localhost:19001/docs (با `docker-compose.override.yml` محلی)
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env` - کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
- دیتابیس PostgreSQL و آبجکت‌استور MinIO هر دو در کانتینر جدا با volume دائمی - دیتابیس PostgreSQL (تصویر pgvector)، آبجکت‌استور MinIO و کش مدل‌های چت‌بات هر یک
- بک‌اند هنگام شروع، خودش جداول را می‌سازد و **فقط جدول‌های خالی** را seed می‌کند — در کانتینر جدا با volume دائمی
ارتقای نسخه داده‌های فعلی را از بین نمی‌برد - بک‌اند هنگام شروع، جداول پایه را می‌سازد، **مهاجرت‌های Alembic** را اعمال می‌کند و
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را می‌سازد، خواندن عمومی را **فقط جدول‌های خالی** را seed می‌کند — ارتقای نسخه داده‌های فعلی را از بین نمی‌برد
فعال می‌کند و تصاویر داخل `seed-media/` را در باکت می‌ریزد — پس یک کلون تازه هم - `minio-setup` باکت `aramesh-media` را می‌سازد و فقط **وقتی باکت خالی است** تصاویر
همان تصاویر سایت را دارد `seed-media/` را می‌ریزد — `db-init` هم دیتابیس‌های `umami` و `aramesh_chatbot` را
روی همان نمونهٔ Postgres می‌سازد
> نکته برای تازه‌واردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛ > نکته برای تازه‌واردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
> فایل‌های `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند. > فایل‌های `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
>
> پایهٔ API فرانت‌اند در زمان بیلد از `VITE_API_URL` در `.env` خوانده می‌شود —
> در توسعه ویندوزیِ این ماشین `VITE_API_URL=http://localhost:19001` بگذارید.
## انتشار واقعی (TLS) ## انتشار واقعی (TLS)
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید: پیش‌نیاز: رکوردهای DNS (`example.com`، `analytics.example.com` و اگر مانیتورینگ فعال
است `grafana.example.com`) به این سرور، سپس در `.env`: `DOMAIN`، `ACME_EMAIL` و
`SITE_URL=https://example.com`:
```bash ```bash
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com bash scripts/stack.sh deploy-prod
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
``` ```
- Traefik گواهی Let's Encrypt می‌گیرد و تمدید می‌کند؛ همه درخواست‌های HTTP به HTTPS هدایت می‌شوند - Traefik گواهی Let's Encrypt می‌گیرد و تمدید می‌کند؛ همه درخواست‌های HTTP به HTTPS هدایت می‌شوند
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرس‌های OG مطلق شوند - **پورت‌های مدیریتی MinIO (۹۰۰۱ کنسول و ۱۹۰۰۰ API) در تولید فقط روی `127.0.0.1`
بسته می‌شوند** — دسترسی از راه دور فقط با تونل SSH:
`ssh -L 9001:127.0.0.1:9001 server`
- همه روترهای websecure هدرهای امنیتی می‌گیرند (HSTS + nosniff)
## اجرای محلی (توسعه) ### پیش‌نمایش خصوصی (کار روی سایت، قبل از انتشار عمومی)
### بک‌اند سایت روی اینترنت بالا می‌آید و تیم با همان پنل مدیریت محتوا را ویرایش می‌کند
(ویرایش درجای ایمیل/تلفن/متن‌ها و نوشتن مقاله)، اما **نه عموم به آن دسترسی دارند و
نه گوگل چیزی ایندکس می‌کند**. دو لایه محافظت:
1. **گیت ورود Traefik (Basic Auth)** — همه مسیرها (سایت، API، تصاویر و چت) پشت یک
نام کاربری/رمز مشترک‌اند؛ بدون آن فقط ۴۰۱ برمی‌گردد
2. **noindex** — هدر `X-Robots-Tag` روی همه پاسخ‌ها + `robots.txt` مسدودکننده +
نقشه سایت خالی؛ حتی اگر گیت برداشته شود، گوگل چیزی ذخیره نمی‌کند
```bash ```bash
cd backend bash scripts/preview-mode.sh on # فعال‌سازی (بار اول نام کاربری/رمز می‌سازد و چاپ می‌کند)
python -m venv .venv bash scripts/stack.sh deploy-prod # اعمال
.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate bash scripts/preview-mode.sh status # وضعیت فعلی
pip install -r requirements.txt
# فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
python -m app.seed # ساخت جداول + محتوای اولیه
uvicorn app.main:app --reload
``` ```
### فرانت‌اند - دامنه اصلی (`DOMAIN`) و دامنه دوم (`DOMAIN_ALIAS`) هر دو سرو می‌شوند و هر دو گیت دارند
- برای دادن دسترسی به کارفرما، همان نام کاربری/رمز مشترک را بدهید
- رمز مشترک در `.env` (`PREVIEW_AUTH_USERS`) می‌ماند — فقط یک‌بار چاپ می‌شود
### انتشار عمومی (برداشتن پیش‌نمایش)
```bash ```bash
cd frontend bash scripts/preview-mode.sh off # حذف گیت ورود + اجازه ایندکس
npm install bash scripts/stack.sh deploy-prod
npm run dev
``` ```
در حالت توسعه، Vite درخواست‌های `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی می‌کند؛ بعد از این مرحله:
پس استک Docker باید در حال اجرا باشد. آدرس بک‌اند در `frontend/src/config.ts` از
`VITE_API_URL` خوانده می‌شود — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ. 1. دامنه دوم به‌صورت ۳۰۱ دائمی به دامنه اصلی ریدایرکت می‌شود (جلوگیری از محتوای تکراری)
2. در Search Console دامنه را تأیید و `https://DOMAIN/sitemap.xml` را ثبت کنید
3. اگر پیش‌تر چیزی ایندکس شده بود، حذفش را از همان کنسول درخواست کنید
### چک‌لیست رفتن به تولید
1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
2. DNS: رکوردهای دامنه و زیردامنه‌ها + باز بودن پورت‌های ۸۰/۴۴۳ در فایروال
3. `DOMAIN`، `DOMAIN_ALIAS` (دامنه دوم)، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
4. `bash scripts/preview-mode.sh on` — اولین انتشار به‌صورت پیش‌نمایش خصوصی می‌رود؛
محتوا را روی سایت آنلاین نهایی کنید و بعد `preview-mode.sh off` + deploy مجدد
5. `python scripts/migrate-media.py` — انتقال یک‌باره تصاویر CDN خارجی به MinIO
6. پشتیبان‌گیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
7. یک تمرین بازگردانی: `bash scripts/restore.sh backups/db-<زمان>.sql.gz` روی محیط آزمایشی
8. مانیتورینگ (بخش پایین) و سپس چت‌بات/آنالیتیکس را با متغیرهای LLM و Umami فعال کنید
## مانیتورینگ (Prometheus + Grafana + Loki)
```bash
bash scripts/stack.sh monitoring
# در تولید (سه فایل با هم):
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml -f docker-compose.prod.yml up -d
```
- داشبورد: `grafana.localhost` (توسعه) یا `grafana.example.com` (تولید — برچسب‌های
روتر آن را در `docker-compose.override.yml` سرور بگذارید؛ نمونه در انتهای
`docker-compose.prod.yml`) — کاربر/رمز از `GRAFANA_ADMIN_*` در `.env`
- دو داشبورد آمادهٔ «زیرساخت» و «اپلیکیشن» به‌صورت code-provisioned سوار می‌شوند
- **هشدارها به بله**: در `.env` مقدار `ALERT_BOT_TOKEN` (بات بله — همان شکل API تلگرام،
از BotFather بله) و `ALERT_CHAT_ID` را بگذارید (شناسه چت: به بات یک پیام بدهید سپس
`curl "https://tapi.bale.ai/bot<توکن>/getUpdates"`) — قواعد آماده در
`monitoring/prometheus/alerts.yml`:
پایین‌بودن هر سرویس، شکست بررسی عمق سلامت، پرشدن دیسک، نرخ خطای 5xx، انقضای گواهی TLS،
**کهنه‌شدن پشتیبان** (سنجهٔ `backup_success_time` از `scripts/backup.sh`) و...
- **لاگ‌ها در Grafana**: Promtail لاگ همهٔ کانتینرهای استک را به Loki می‌فرستد —
برای خطاهای بک‌اند از داشبورد «اپلیکیشن» پنل لاگ را ببینید
- **سلامت واقعی**: `/api/health` اتصال DB و MinIO را واقعاً می‌آزماید (503 در خرابی)؛
پروب blackbox هم همین را از بیرون و نشانی `SITE_URL` را عمومی چک می‌کند
- مصرف منابع مجموع لایه حدود ۱ تا ۱.۵ گیگابایت RAM است؛ حذف هر سرویس بقیه را نمی‌شکند
## آنالیتیکس محصول (Umami)
استک شامل سرویس Umami است؛ داشبورد: `analytics.localhost` (توسعه) یا
`analytics.example.com` (تولید). بدون کوکی — بدون نیاز به بنر رضایت.
راه‌اندازی (یک‌بار):
1. استک را بالا بیاورید و به `analytics.localhost` وارد شوید (پیش‌فرض `admin`/`umami`
— بلافاصله رمز را عوض کنید)
2. یک وب‌سایت با دامنهٔ سایت بسازید؛ «شناسه وب‌سایت» را در `.env` بگذارید:
`VITE_UMAMI_WEBSITE_ID=...` و آدرس اسکریپت: `VITE_UMAMI_SRC=https://analytics.example.com/script.js`
3. `bash scripts/stack.sh deploy` — اسکریپت ردیاب در `index.html` تزریق می‌شود
رخدادهای سفارشی که خودکار ارسال می‌شوند: ارسال فرم تماس (`contact-submit`)، عضویت
خبرنامه (`newsletter-signup`)، کلیک تلفن (`phone-click`)، باز کردن چت‌بات
(`chat-open`)، پرسش از چت‌بات (`chat-question`)، تغییر زبان (`lang-switch`) —
سرچشمه: `frontend/src/analytics.ts`.
## چت‌بات (دستیار هوشمند)
- RAG روی محتوای همان دیتابیس سایت: امبدینگ MiniLM چندزبانه + BM25 + رنکر — بردارها
در دیتابیس جدا `aramesh_chatbot` (pgvector) روی همان نمونهٔ Postgres
- مسیرها: `GET /chat/healthz`، `GET /chat/suggestions`، `GET /chat/stream` (SSE) —
رابط کاربری: ویجت شناور پایین صفحه (`ChatWidget.tsx`)
- راه‌اندازی: `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` در `.env` (هر LLM سازگار با
OpenAI) — بدون آن سرویس بالا می‌آید اما پاسخ نمی‌دهد
- اولین اجرا مدل‌ها را دانلود می‌کند (~۵۰۰MB در حجم `chatbot_hf`) — کند است، طبیعی است
- ایندکس هر ۱۰ دقیقه خودش با تغییرهای محتوا همگام می‌شود؛ همگام‌سازی فوری با
`POST /chat/reindex` و هدر `x-reindex-token` (از `CHAT_REINDEX_TOKEN`)
## امکانات بک‌اند ## امکانات بک‌اند
- **API عمومی**: برند، منو، هیرو، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم، - **API عمومی**: برند، منو، هیرو، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم،
تایم‌لاین، آمار، دفاتر، مدل‌های سرمایه‌گذاری و... — همه در دیتابیس تایم‌لاین، آمار، دفاتر، مدل‌های سرمایه‌گذاری و... — همه در دیتابیس
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` - **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` — هر دو با
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) محدودسازی نرخ (تماس: ۵ در ۱۵ دقیقه، خبرنامه: ۳ در ساعت برای هر IP) + دامنهٔ پنهان
و سپس CRUD کامل همه محتواها در `/api/admin/...` (honeypot) ضدربات + میان‌افزار محدودسازی سیلاب روی Traefik
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (مقادیر از `ADMIN_*` در `.env` —
هیچ رمز پیش‌فرضی در کد وجود ندارد) و سپس CRUD کامل همه محتواها در `/api/admin/...`
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است - **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد می‌شود - **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد می‌شود
(IP واقعی کلاینت با `--proxy-headers` از پشت Traefik درست خوانده می‌شود)
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO - **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
ذخیره و URL نسبی `/media/...` برمی‌گردد ذخیره و URL نسبی `/media/...` برمی‌گردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
فشرده می‌شود (Pillow) تا سرعت صفحه حفظ شود
- **خروجی CSV** از نوار مدیریت: پیام‌های تماس و مشترکان خبرنامه (با BOM برای Excel)
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند - **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند
- **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}` - **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
- همه محتوا در یک درخواست: `GET /api/site` (مقاله‌های پیش‌نویس در آن نمی‌آیند) - همه محتوا در یک درخواست: `GET /api/site` (مقاله‌های پیش‌نویس در آن نمی‌آیند)
- **تست‌ها**: `cd backend && pip install -r requirements-dev.txt && pytest tests` - **مهاجرت‌های اسکیما**: Alembic (`backend/migrations/`) — هر تغییر اسکیمای جدید باید
revision شود: `cd backend && alembic revision --autogenerate -m "..."` و پس از بازبینی
commit؛ در استارت کانتینر خودکار `upgrade head` اجرا می‌شود
- **تست‌ها**: `cd backend && .venv/Scripts/activate && pytest tests` (۲۴ تست — شامل
honeypot، محدودسازی نرخ و پاک‌سازی)
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید > در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید. > بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
## ویرایش زنده سایت (دوقلوی دیجیتال) ## ویرایش زنده سایت (دوقلوی دیجیتال)
@@ -157,7 +268,9 @@ npm run dev
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام. **فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده
(پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف) (پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف)
در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند. در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند. در زمان ذخیره، آدرس‌های
`href/src` سند در سمت سرور پاک‌سازی می‌شوند (`app/sanitize.py`) تا اسکیم‌های خطرناک
مثل `javascript:` هرگز ذخیره نشوند.
## سئو ## سئو
@@ -179,24 +292,39 @@ npm run dev
دیتابیس داخل آن تزریق می‌کند (`app/seo_pages.py`) — بازدیدکننده‌های عادی همان SPA دیتابیس داخل آن تزریق می‌کند (`app/seo_pages.py`) — بازدیدکننده‌های عادی همان SPA
همیشگی را می‌بینند همیشگی را می‌بینند
## پشتیبان‌گیری ## پشتیبان‌گیری و بازگردانی
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید: همه محتوای سایت در PostgreSQL و تصاویر در MinIO است:
```bash ```bash
bash scripts/backup.sh # در backups/ — نگهداری پیش‌فرض ۱۴ روز bash scripts/stack.sh backup # پشتیبان کامل: DB + تصاویر MinIO
docker compose --profile ops up -d db-backup # پشتیبان ساعتی خودکار DB (بدون cron میزبان)
``` ```
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت) - خروجی: `backups/db-<زمان>.sql.gz` (فشرده + فایل `.sha256`) و `backups/images-<زمان>/`
- زمان‌بندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست - اسکریپت پشتیبان، خروجی خالی/شکسته را خطا می‌گیرد، سنجهٔ تازگی برای هشدار Grafana
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza` می‌نویسد و اگر `OFFSITE_RCLONE_REMOTE` در `.env` تنظیم باشد، پشتیبان را به حافظهٔ
دیگری هم کپی می‌کند (rclone)
- **بازگردانی**: `bash scripts/stack.sh restore backups/db-<زمان>.sql.gz`
(احتیاط: محتوای فعلی جایگزین می‌شود — قبل از انتشار واقعی یک‌بار تمرین کنید!)
- زمان‌بندی پشتیبان کامل با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
## تست و CI
- `bash scripts/ci.sh` — تست‌های pytest + بیلد ایمیج‌ها + ممیزی وابستگی‌ها
- `bash scripts/audit.sh` — pip-audit / npm audit / trivy (هر کدام نصب بود)
- `.github/workflows/ci.yml` — همین مراحل؛ سینتکس GitHub Actions روی Gitea Actions/
Forgejo هم اجرا می‌شود
## نکات امنیتی ## نکات امنیتی
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml` - مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
با سینتکس `${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید `${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید؛ هیچ رمز پیش‌فرضی در کد نیست
- رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است - رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
- ورود با محدودیت تلاش محافظت می‌شود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429 - توکن‌های JWT ۸ ساعت اعتبار دارند (stateless — تغییر `SECRET_KEY` همه را خارج می‌کند)
- توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است - همهٔ کانتینرها **بدون root** اجرا می‌شوند؛ کانتکست بیلد هر ایمیج با `.dockerignore`
باریک است و لاگ‌ها سقف دارند
- فرم‌های عمومی ضدربات‌اند (honeypot + محدودسازی نرخ در بک‌اند + سقف سیلاب در Traefik)
- کنسول MinIO و API مستقیمش در تولید فقط از `127.0.0.1` در دسترس‌اند (تونل SSH)
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است - تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
- مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند - مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند
+13
View File
@@ -0,0 +1,13 @@
# کانتکست بیلد باریک — .venv و کش‌های تست محلی جایی در تصویر ندارند
.venv/
venv/
tests/
.pytest_cache/
__pycache__/
**/__pycache__/
*.pyc
README.md
.env
.env.*
Dockerfile
.dockerignore
+18 -3
View File
@@ -1,3 +1,4 @@
# بک‌اند FastAPI — تصویر باریک و بدون root
FROM python:3.13-slim FROM python:3.13-slim
WORKDIR /app WORKDIR /app
@@ -8,9 +9,23 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# FFmpeg — نرمال‌سازی رسانه به WebP برای ورودی‌هایی که Pillow نمی‌خواند
# (ویدیو → WebP متحرک، فرمت‌های تصویری خاص). مسیر Pillow به آن وابسته نیست.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY app ./app COPY app ./app
COPY alembic.ini ./
COPY migrations ./migrations
# کاربر بدون دسترسی root — برنامه چیزی روی دیسک محلی نمی‌نویسد
RUN useradd --system --create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000 EXPOSE 8000
# ساخت جداول پایه، مهاجرت‌های Alembic و seed (بدون حذف داده‌های موجود) و سپس
# ساخت جداول و seed (بدون حذف داده‌های موجود) و سپس اجرای سرور # اجرای سرور با exec — تا SIGTERM (docker stop) مستقیم به uvicorn برسد و خاموشی
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"] # تمیز انجام شود؛ --proxy-headers برای دیدن IP واقعی کلاینت پشت Traefik
CMD ["sh", "-c", "python -m app.db_init && alembic upgrade head && python -m app.seed && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'"]
+40
View File
@@ -0,0 +1,40 @@
[alembic]
script_location = migrations
prepend_sys_path = .
# آدرس دیتابیس از DATABASE_URL (محیط) یا تنظیمات برنامه خوانده می‌شود — env.py
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+388 -46
View File
@@ -1,16 +1,31 @@
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند.""" """انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
import csv
import ipaddress
import io
import json
import os
import re
import shutil
import socket
import subprocess
import tempfile
import urllib.request
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import urlparse
import boto3 import boto3
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from PIL import Image, ImageOps
from pydantic import BaseModel
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from .. import models, schemas from .. import models, schemas
from ..auth import create_access_token, get_current_admin, hash_password, verify_password from ..auth import create_access_token, get_current_admin, get_user_from_token, hash_password, oauth2_scheme, verify_password
from ..config import settings from ..config import settings
from ..database import get_db from ..database import get_db
from ..rate_limit import clear as clear_rate_limit from ..rate_limit import clear as clear_rate_limit
@@ -104,7 +119,46 @@ from ..schemas import (
auth_router = APIRouter() auth_router = APIRouter()
# روتر محافظت‌شده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند # روتر محافظت‌شده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
router = APIRouter(dependencies=[Depends(get_current_admin)]) async def admin_access_guard(
request: Request,
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> models.AdminUser:
"""نقش‌گارد — 'admin' همه‌کاره؛ 'creator' فقط مقاله‌نویسی: ساخت پیش‌نویس،
خواندن/ویرایش پیش‌نویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳."""
user = get_user_from_token(token, db)
if user.role == "admin":
return user
if user.role != "creator":
raise HTTPException(status_code=403, detail="نقش ناشناخته")
path = request.url.path.rstrip("/")
method = request.method
# آپلود تصویر مقاله‌ها — مجاز
if path in ("/api/admin/upload", "/api/admin/import-image") and method == "POST":
return user
# فهرست و ساخت مقاله — مجاز (ساخت همیشه پیش‌نویس است و نویسنده‌اش ثبت می‌شود)
if path == "/api/admin/articles" and method in ("GET", "POST"):
return user
# خواندن/ویرایش پیش‌نویس خودِ نویسنده — مالکیت در هندلر هم دوباره چک می‌شود
m = re.fullmatch(r"/api/admin/articles/(\d+)", path)
if m and method in ("GET", "PUT", "PATCH"):
article = db.get(models.Article, int(m.group(1)))
if article is not None and article.author_id == user.id:
return user
# ۴۰۴ نه ۴۰۳ — مقاله‌های بقیه برای نویسنده اصلاً وجود ندارند
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
router = APIRouter(
dependencies=[
Depends(get_current_admin),
Depends(admin_access_guard),
]
)
@auth_router.post("/auth/login", response_model=TokenOut) @auth_router.post("/auth/login", response_model=TokenOut)
@@ -118,7 +172,7 @@ def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
detail="نام کاربری یا رمز عبور اشتباه است", detail="نام کاربری یا رمز عبور اشتباه است",
) )
clear_rate_limit(request) clear_rate_limit(request)
return TokenOut(access_token=create_access_token(user.username)) return TokenOut(access_token=create_access_token(user.username, user.role), role=user.role)
@auth_router.post("/auth/change-password") @auth_router.post("/auth/change-password")
@@ -135,13 +189,21 @@ def change_password(
return {"ok": True} return {"ok": True}
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None: def register_crud(
path: str, model, out_schema, create_schema, patch_schema=None,
creator_scoped: bool = False,
) -> None:
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیب‌بندی) روی روتر مدیریت.""" حذف و ترتیب‌بندی) روی روتر مدیریت.
creator_scoped=True (فقط مقاله‌ها): نقش creator فقط رکوردهای خودش را می‌بیند،
ساختش همیشه پیش‌نویس است و ویرایشش هرگز منتشر نمی‌شود."""
@router.get(f"/{path}", response_model=list[out_schema]) @router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db)): def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
query = db.query(model) query = db.query(model)
if creator_scoped and admin.role == "creator":
query = query.filter(model.author_id == admin.id)
if hasattr(model, "position"): if hasattr(model, "position"):
query = query.order_by(model.position, model.id) query = query.order_by(model.position, model.id)
else: else:
@@ -149,8 +211,13 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return query.all() return query.all()
@router.post(f"/{path}", response_model=out_schema, status_code=201) @router.post(f"/{path}", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)): def create_item(payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = model(**payload.model_dump()) item = model(**payload.model_dump())
if creator_scoped:
# نویسنده ثبت می‌شود؛ ساخت توسط creator همیشه پیش‌نویس است
item.author_id = admin.id
if admin.role == "creator":
item.status = "draft"
if hasattr(model, "position"): if hasattr(model, "position"):
# آیتم جدید در انتهای لیست قرار می‌گیرد # آیتم جدید در انتهای لیست قرار می‌گیرد
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1 item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
@@ -164,19 +231,26 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return item return item
@router.get(f"/{path}/{{item_id}}", response_model=out_schema) @router.get(f"/{path}/{{item_id}}", response_model=out_schema)
def get_item(item_id: int, db: Session = Depends(get_db)): def get_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
# مقالهٔ بقیه برای creator وجود ندارد
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return item return item
@router.put(f"/{path}/{{item_id}}", response_model=out_schema) @router.put(f"/{path}/{{item_id}}", response_model=out_schema)
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db)): def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
for key, value in payload.model_dump().items(): for key, value in payload.model_dump().items():
setattr(item, key, value) setattr(item, key, value)
if creator_scoped and admin.role == "creator":
item.status = "draft" # نویسنده حق انتشار ندارد
try: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError:
@@ -188,13 +262,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
if patch_schema is not None: if patch_schema is not None:
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema) @router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)): def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
"""ویرایش جزئی — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند (برای ویرایش زنده).""" """ویرایش جزئی — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند (برای ویرایش زنده)."""
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
for key, value in payload.model_dump(exclude_unset=True).items(): for key, value in payload.model_dump(exclude_unset=True).items():
setattr(item, key, value) setattr(item, key, value)
if creator_scoped and admin.role == "creator":
item.status = "draft" # نویسنده حق انتشار ندارد
try: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError:
@@ -204,7 +282,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return item return item
@router.post(f"/{path}/reorder", response_model=list[out_schema]) @router.post(f"/{path}/reorder", response_model=list[out_schema])
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)): def reorder_items(payload: ReorderIn, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
if creator_scoped and admin.role == "creator":
raise HTTPException(status_code=403, detail="ترتیب مقاله‌ها فقط توسط مدیر تغییر می‌کند")
"""ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی.""" """ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی."""
if not hasattr(model, "position"): if not hasattr(model, "position"):
raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند") raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند")
@@ -218,7 +298,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return db.query(model).order_by(model.position, model.id).all() return db.query(model).order_by(model.position, model.id).all()
@router.delete(f"/{path}/{{item_id}}", status_code=204) @router.delete(f"/{path}/{{item_id}}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)): def delete_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
if creator_scoped and admin.role == "creator":
raise HTTPException(status_code=403, detail="حذف مقاله فقط توسط مدیر انجام می‌شود")
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
@@ -235,7 +317,7 @@ register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOu
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch) register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch) register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch) register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch) register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch, creator_scoped=True)
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch) register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch) register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch) register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
@@ -254,29 +336,191 @@ register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch) register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد # آپلود رسانه به Object Storage (MinIO) — هر ورودی قابل‌رمزگشایی به WebP نرمال می‌شود:
ALLOWED_IMAGE_TYPES = { # تصویر ثابت → WebP تک‌فریمی | gif/apng/webp متحرک → WebP متحرک | ویدیو → WebP متحرک با سقف.
"image/jpeg": "jpg", # URL نسبی برمی‌گردد تا بک‌اند ذخیره‌سازی قابل تعویض باشد.
"image/png": "png", MAX_DIMENSION = 2000 # سقف ضلع تصویر ثابت — ابعاد بزرگ اصلی‌ترین عامل کندی سایت است
"image/webp": "webp", MAX_ANIM_DIMENSION = 800 # انیمیشن: سقف ضلع فریم
"image/gif": "gif", MAX_ANIM_FRAMES = 150 # انیمیشن: سقف فریم
"image/avif": "avif", VIDEO_MAX_SECONDS = 8 # ویدیو: سقف مدت ورودی
} VIDEO_FPS = 12 # ویدیو: نرخ فریم خروجی
VIDEO_WEBP_LIMIT = 5 * 1024 * 1024 # انیمیشن ویدیویی حجیم‌تر از این → فقط فریم پوستر
WEBP_QUALITY = 82
WEBP_CONTENT_TYPE = "image/webp"
SUPPORTED_MEDIA_DETAIL = "فرمت پشتیبانی نمی‌شود — فقط تصویر یا ویدیویی که بتوان به WebP تبدیل کرد مجاز است"
def _to_webp_mode(img: Image.Image) -> Image.Image:
"""ذخیرهٔ WebP در Pillow فقط RGB/RGBA/L را می‌پذیرد — مدهای دیگر با حفظ شفافیت تبدیل می‌شوند."""
if img.mode in ("RGB", "RGBA", "L"):
return img
has_alpha = img.mode in ("LA", "PA") or (img.mode == "P" and "transparency" in img.info)
return img.convert("RGBA" if has_alpha else "RGB")
def _webp_still(img: Image.Image) -> tuple[bytes, int, int]:
"""تک‌فریم → WebP؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت می‌شود."""
img = ImageOps.exif_transpose(img)
if max(img.size) > MAX_DIMENSION:
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
img = _to_webp_mode(img)
buf = io.BytesIO()
img.save(buf, "WEBP", quality=WEBP_QUALITY, method=4)
return buf.getvalue(), *img.size
def _webp_animated(img: Image.Image) -> tuple[bytes, int, int] | None:
"""چندفریم (gif/apng/webp متحرک) → WebP متحرک؛ None یعنی ورودی تک‌فریمی است.
فریم‌های بیش از سقف رها می‌شوند تا حافظه و حجم خروجی مهار شود."""
total = getattr(img, "n_frames", 1)
if total <= 1:
return None
count = min(total, MAX_ANIM_FRAMES)
frames: list[Image.Image] = []
durations: list[int] = []
for i in range(count):
img.seek(i)
durations.append(int(img.info.get("duration") or 100))
frame = img.convert("RGBA")
if max(frame.size) > MAX_ANIM_DIMENSION:
frame.thumbnail((MAX_ANIM_DIMENSION, MAX_ANIM_DIMENSION), Image.LANCZOS)
frames.append(frame)
buf = io.BytesIO()
frames[0].save(
buf, "WEBP", save_all=True, append_images=frames[1:],
duration=durations, loop=0, quality=WEBP_QUALITY, method=4,
)
return buf.getvalue(), *frames[0].size
def _ffprobe_video(path: str) -> tuple[dict | None, dict]:
"""استریم ویدیوی اول + مشخصات فرمت با ffprobe — استریم None یعنی ویدیویی در فایل نیست."""
ffprobe = shutil.which("ffprobe")
if ffprobe is None:
return None, {}
try:
proc = subprocess.run(
[ffprobe, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path],
capture_output=True, timeout=20,
)
except (subprocess.TimeoutExpired, OSError):
return None, {}
if proc.returncode != 0:
return None, {}
try:
info = json.loads(proc.stdout or "{}")
except json.JSONDecodeError:
return None, {}
video = next((s for s in info.get("streams") or [] if s.get("codec_type") == "video"), None)
return video, info.get("format") or {}
def _run_ffmpeg(args: list[str]) -> None:
try:
proc = subprocess.run(args, capture_output=True, timeout=60)
except subprocess.TimeoutExpired as exc:
raise HTTPException(status_code=415, detail="تبدیل رسانه بیش از حد طول کشید") from exc
except OSError as exc:
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL) from exc
if proc.returncode != 0:
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
"""مسیر FFmpeg — ورودی‌هایی که Pillow نمی‌خواند: ویدیو و فرمت‌های تصویری خاص.
ویدیو تا ۸ ثانیه با ۱۲fps به WebP متحرک می‌رسد؛ خروجی حجیم → فقط فریم پوستر."""
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src")
dst = os.path.join(tmp, "out.webp")
with open(src, "wb") as fh:
fh.write(data)
video, fmt = _ffprobe_video(src)
if video is None:
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
ff = shutil.which("ffmpeg") or "ffmpeg"
animated = float(fmt.get("duration") or 0) >= 0.5
video_vf = f"fps={VIDEO_FPS},scale='min({MAX_ANIM_DIMENSION},iw)':-2:flags=lanczos"
still_vf = f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos"
def run_convert(extra: list[str], vf: str) -> None:
_run_ffmpeg([ff, "-y", "-v", "error", "-i", src, *extra, "-vf", vf,
"-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst])
# ویدیو → متحرک با سقف فریم‌نرخ/ضلع/مدت؛ تصویر خاص → تک‌فریم
if animated:
run_convert(["-t", str(VIDEO_MAX_SECONDS)], video_vf)
else:
run_convert(["-frames:v", "1"], still_vf)
with open(dst, "rb") as fh:
out = fh.read()
# انیمیشن ویدیویی ناگذیر حجیم → فقط فریم پوستر
if animated and len(out) > VIDEO_WEBP_LIMIT:
run_convert(["-frames:v", "1"], still_vf)
with open(dst, "rb") as fh:
out = fh.read()
# ffprobe روی WebP عرض/ارتفاع صفر برمی‌گرداند — ابعاد از خود خروجی با Pillow
try:
width, height = Image.open(io.BytesIO(out)).size
except Exception:
width = height = None
return out, width, height
def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]:
"""هر ورودی قابل‌رمزگشایی → WebP. اول Pillow (دقیق‌ترین برای EXIF/شفافیت/انیمیشن)؛
اگر رمزگشایی نشد، FFmpeg (ویدیو و فرمت‌های خاص). هیچ‌کدام نشد → ۴۱۵."""
try:
img = Image.open(io.BytesIO(data))
animated = _webp_animated(img)
if animated is not None:
return animated
return _webp_still(img)
except HTTPException:
raise
except Exception:
pass
return _ffmpeg_webp(data)
def _readable_key(name: str, ext: str) -> str:
"""کلید ذخیره‌سازی خوانا از نام دلخواه/نام فایل — «۱۴۰۳/elahieh-tower-a1b2c3.jpg».
نام‌گذاری‌ها ایمن می‌شوند: نویسه‌های ناامن و جداکننده مسیر حذف، فاصله‌ها خط تیره،
طول محدود؛ خالی بودن نام → شناسه تصادفی مثل قبل. پسوند ۶ نویسه‌ای تضمین یکتایی است.
"""
slug = re.sub(r"[^\w؀-ۿ\- ]+", "", name, flags=re.UNICODE).strip()
slug = re.sub(r"[\s_]+", "-", slug).strip("-")[:80]
unique = uuid.uuid4().hex[:6]
base = f"{slug}-{unique}" if slug else uuid.uuid4().hex
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
def _store_image(data: bytes, name: str) -> str:
"""ذخیرهٔ WebP در Object Storage با کلید خوانا — URL نسبی برمی‌گردد."""
key = _readable_key(name, "webp")
s3 = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name="us-east-1",
)
try:
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=WEBP_CONTENT_TYPE)
except Exception:
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
return f"{settings.media_public_base}/{key}"
@router.post("/upload") @router.post("/upload")
async def upload_media( async def upload_media(
file: UploadFile = File(...), file: UploadFile = File(...),
name: str = Form(""),
_admin: models.AdminUser = Depends(get_current_admin), _admin: models.AdminUser = Depends(get_current_admin),
): ):
content_type = (file.content_type or "").lower()
ext = ALLOWED_IMAGE_TYPES.get(content_type)
if ext is None:
raise HTTPException(
status_code=415,
detail="فرمت فایل پشتیبانی نمی‌شود (مجاز: jpg، png، webp، gif، avif)",
)
max_bytes = settings.media_max_upload_mb * 1024 * 1024 max_bytes = settings.media_max_upload_mb * 1024 * 1024
data = await file.read() data = await file.read()
if len(data) == 0: if len(data) == 0:
@@ -287,29 +531,127 @@ async def upload_media(
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است", detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
) )
now = datetime.now(timezone.utc) # هر ورودی قابل‌رمزگشایی (تصویر رایج/خاص، متحرک، ویدیو) → WebP نرمال — روی ترد،
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}" # تا رمزگذاری فایل حجیم رویداد‌لوپ و بقیهٔ درخواست‌ها را قفل نکند
data, width, height = await run_in_threadpool(_normalize_media, data)
s3 = boto3.client( # نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
"s3", display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
endpoint_url=settings.s3_endpoint, url = _store_image(data, display_name)
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name="us-east-1",
)
try:
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
except Exception:
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
return { return {
"url": f"{settings.media_public_base}/{key}", "url": url,
"key": key, "key": url.removeprefix(f"{settings.media_public_base}/"),
"size": len(data), "size": len(data),
"content_type": content_type, "content_type": WEBP_CONTENT_TYPE,
"width": width,
"height": height,
} }
# ---------- درون‌ریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ----------
class ImportImageIn(BaseModel):
url: str
def _assert_public_http(url: str) -> None:
"""فقط http/https به مقاصد عمومی — جلوگیری از SSRF به شبکه داخلی."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise HTTPException(status_code=400, detail="نشانی تصویر معتبر نیست")
try:
infos = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror:
raise HTTPException(status_code=400, detail="میزبان تصویر پیدا نشد")
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if not ip.is_global:
raise HTTPException(status_code=400, detail="نشانی تصویر به میزبان داخلی اشاره می‌کند")
@router.post("/import-image")
async def import_image(
payload: ImportImageIn,
_admin: models.AdminUser = Depends(get_current_admin),
):
"""دانلود تصویر از نشانی خارجی، بهینه‌سازی و ذخیره در Object Storage خودِ سایت —
تصاویر چسبانده‌شده از Outline/مستندات خارجی به میزبانی سایت منتقل می‌شوند."""
_assert_public_http(payload.url)
max_bytes = settings.media_max_upload_mb * 1024 * 1024
request = urllib.request.Request(
payload.url, headers={"User-Agent": "ArameshSite/1.0 (+image import)"}
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
data = response.read(max_bytes + 1)
except Exception:
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
if len(data) == 0:
raise HTTPException(status_code=400, detail="تصویر خالی است")
if len(data) > max_bytes:
raise HTTPException(
status_code=413,
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
)
# هدر Content-Type دروغ می‌گوید — خود بدنه رمزگشایی و به WebP نرمال می‌شود (روی ترد)
data, width, height = await run_in_threadpool(_normalize_media, data)
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
media_url = _store_image(data, url_name)
return {
"url": media_url,
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
"size": len(data),
"content_type": WEBP_CONTENT_TYPE,
"width": width,
"height": height,
}
# ---------- خروجی CSV (برای تیم فروش) ----------
def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerows(rows)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
# BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند
content="\ufeff" + buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'},
)
@router.get("/messages/export.csv")
def export_messages(db: Session = Depends(get_db)):
rows = [
[m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()]
for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all()
]
return _csv_response(
"contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows
)
@router.get("/subscribers/export.csv")
def export_subscribers(db: Session = Depends(get_db)):
rows = [
[s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()]
for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
]
return _csv_response(
"newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows
)
# پیام‌های تماس # پیام‌های تماس
@router.get("/messages", response_model=list[ContactMessageOut]) @router.get("/messages", response_model=list[ContactMessageOut])
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)): def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
+40 -5
View File
@@ -1,17 +1,48 @@
"""انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه.""" """انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه.
from fastapi import APIRouter, Depends هر فرم یک دامنهٔ پنهان (honeypot) دارد: ربات‌ها همهٔ فیلدها را پُر می‌کنند؛
در آن حالت پاسخ موفقِ قلابی برمی‌گردد و چیزی ذخیره نمی‌شود.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db
from .. import models from .. import models
from ..database import get_db
from ..rate_limit import contact_limiter, newsletter_limiter
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
router = APIRouter() router = APIRouter()
def _fake_contact(payload: ContactIn) -> ContactMessageOut:
"""پاسخ موفق بدون ذخیره — تا ربات متوجه تله نشود."""
return ContactMessageOut(
id=0,
name=payload.name.strip(),
phone=payload.phone.strip(),
note="",
is_read=True,
created_at=datetime.now(timezone.utc),
)
def _fake_subscriber(payload: NewsletterIn) -> SubscriberOut:
return SubscriberOut(
id=0,
email=str(payload.email).lower(),
is_active=True,
created_at=datetime.now(timezone.utc),
)
@router.post("/contact", response_model=ContactMessageOut, status_code=201) @router.post("/contact", response_model=ContactMessageOut, status_code=201)
def submit_contact(payload: ContactIn, db: Session = Depends(get_db)): def submit_contact(payload: ContactIn, request: Request, db: Session = Depends(get_db)):
contact_limiter.hit(request)
if payload.website:
return _fake_contact(payload)
message = models.ContactMessage( message = models.ContactMessage(
name=payload.name.strip(), name=payload.name.strip(),
phone=payload.phone.strip(), phone=payload.phone.strip(),
@@ -24,7 +55,11 @@ def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
@router.post("/newsletter", response_model=SubscriberOut, status_code=201) @router.post("/newsletter", response_model=SubscriberOut, status_code=201)
def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)): def subscribe(payload: NewsletterIn, request: Request, db: Session = Depends(get_db)):
newsletter_limiter.hit(request)
if payload.website:
return _fake_subscriber(payload)
email = str(payload.email).lower() email = str(payload.email).lower()
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first() subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
+42 -2
View File
@@ -1,8 +1,13 @@
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت.""" """انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
import boto3
from botocore.config import Config as BotoConfig
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..config import settings
from ..database import get_db from ..database import get_db
from .. import models from .. import models
from ..schemas import ( from ..schemas import (
@@ -22,9 +27,44 @@ from ..schemas import (
router = APIRouter() router = APIRouter()
@router.get("/live")
def live():
"""زنده‌بودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمی‌شود."""
return {"status": "ok"}
@router.get("/health") @router.get("/health")
def health(): def health(db: Session = Depends(get_db)):
return {"status": "ok", "app": "Aramesh Building Group API"} """بررسی عمق سلامت — اتصال DB و MinIO واقعاً آزموده می‌شود؛
در صورت خرابی هر وابستگی، 503 با جزئیات برمی‌گردد."""
checks: dict[str, str] = {}
try:
db.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception:
checks["database"] = "error"
try:
s3 = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name="us-east-1",
config=BotoConfig(connect_timeout=2, read_timeout=2, retries={"max_attempts": 1}),
)
s3.head_bucket(Bucket=settings.s3_bucket)
checks["storage"] = "ok"
except Exception:
checks["storage"] = "error"
if any(state != "ok" for state in checks.values()):
return JSONResponse(
status_code=503,
content={"status": "degraded", "checks": checks},
)
return {"status": "ok", "app": "Aramesh Building Group API", "checks": checks}
@router.get("/brand", response_model=BrandOut) @router.get("/brand", response_model=BrandOut)
+12 -7
View File
@@ -21,17 +21,14 @@ def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(username: str) -> str: def create_access_token(username: str, role: str = "admin") -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": username, "exp": expire} payload = {"sub": username, "role": role, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def get_current_admin( def get_user_from_token(token: str, db: Session) -> AdminUser:
token: str = Depends(oauth2_scheme), """decode توکن و برگرداندن کاربر — هم برای get_current_admin و هم نقش‌گارد"""
db: Session = Depends(get_db),
) -> AdminUser:
"""وابستگی محافظت‌شده — فقط با توکن JWT معتبر دسترسی می‌دهد."""
credentials_error = HTTPException( credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="احراز هویت نامعتبر است", detail="احراز هویت نامعتبر است",
@@ -50,3 +47,11 @@ def get_current_admin(
if user is None: if user is None:
raise credentials_error raise credentials_error
return user return user
def get_current_admin(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> AdminUser:
"""وابستگی محافظت‌شده — فقط با توکن JWT معتبر دسترسی می‌دهد."""
return get_user_from_token(token, db)
+8 -2
View File
@@ -13,7 +13,8 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api" database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
secret_key: str = "dev-secret-change-me-in-production" secret_key: str = "dev-secret-change-me-in-production"
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 1440 # ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاه‌تر ریسک نشت را کم می‌کند
access_token_expire_minutes: int = 480
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "aramesh123" admin_password: str = "aramesh123"
@@ -25,12 +26,17 @@ class Settings(BaseSettings):
s3_bucket: str = "aramesh-media" s3_bucket: str = "aramesh-media"
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی می‌کند # پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی می‌کند
media_public_base: str = "/media" media_public_base: str = "/media"
media_max_upload_mb: int = 10 # رندرهای معماری تا ~۳۰ مگابایت — لبه (Traefik api-bodylimit = ۴۰MB) باید همیشه بزرگ‌تر باشد
media_max_upload_mb: int = 35
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG # نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
site_url: str = "" site_url: str = ""
# مسیر بیلد فرانت‌اند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات # مسیر بیلد فرانت‌اند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
frontend_dist: str = "../frontend/dist" frontend_dist: str = "../frontend/dist"
# پیش‌نمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکس‌شدنی.
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
# هدر noindex روی همه پاسخ‌ها + robots.txt مسدودکننده.
preview_mode: bool = False
settings = Settings() settings = Settings()
+11
View File
@@ -0,0 +1,11 @@
"""ساخت جداول پایه (baseline) — پیش از مهاجرت‌های Alembic.
روی دیتابیس تازه، همهٔ جدول‌ها از مدل‌های فعلی ساخته می‌شوند؛ روی دیتابیس موجود
بی‌اثر است (create_all جدول‌های موجود را دست نمی‌زند). از این‌جا به بعد تغییرات
اسکیما فقط با revisionهای Alembic اعمال می‌شوند.
"""
from . import models # noqa: F401 — ثبت مدل‌ها
from .database import Base, engine
Base.metadata.create_all(bind=engine)
+23 -2
View File
@@ -1,15 +1,17 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول
from .api import admin, forms, public from .api import admin, forms, public
from .config import settings from .config import settings
from .database import Base, engine from .database import Base, engine
from .migrations import run_all as run_migrations
from .seo_pages import router as seo_router from .seo_pages import router as seo_router
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال می‌شوند
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام می‌دهد.
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations()
app = FastAPI( app = FastAPI(
title="Aramesh Building Group API", title="Aramesh Building Group API",
@@ -29,6 +31,19 @@ app.add_middleware(
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# پاسخ‌های JSON /api — فشرده‌سازی وقتی بدنه قابل‌توجه است
app.add_middleware(GZipMiddleware, minimum_size=1024)
# پیش‌نمایش خصوصی — لایه دوم جلوگیری از ایندکس (لایه اول، گیت ورود Traefik است).
# روی همه پاسخ‌ها هدر noindex می‌نشیند تا حتی در صورت حذف گیت، گوگل چیزی ذخیره نکند.
# همیشه ثبت می‌شود و پرچم در زمان هر درخواست خوانده می‌شود (خاموش‌کردن انتشار = فقط تغییر .env).
@app.middleware("http")
async def _preview_noindex(request, call_next):
response = await call_next(request)
if settings.preview_mode:
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return response
app.include_router(public.router, prefix="/api", tags=["عمومی"]) app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"]) app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"])
@@ -37,6 +52,12 @@ app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه # robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
app.include_router(seo_router, tags=["سئو"]) app.include_router(seo_router, tags=["سئو"])
# متریک‌های Prometheus — فقط Prometheus در شبکه داخلی اسکرپ می‌کند
# (Traefik مسیر /metrics را نمی‌شناسد، پس از بیرون در دسترس نیست)
from prometheus_fastapi_instrumentator import Instrumentator # noqa: E402
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
@app.get("/") @app.get("/")
def root(): def root():
+28 -1
View File
@@ -27,8 +27,16 @@ EN_COLUMNS: list[tuple[str, str, str]] = [
("projects", "year_en", "VARCHAR(64)"), ("projects", "year_en", "VARCHAR(64)"),
("projects", "area_en", "VARCHAR(64)"), ("projects", "area_en", "VARCHAR(64)"),
("projects", "floors_en", "VARCHAR(64)"), ("projects", "floors_en", "VARCHAR(64)"),
("projects", "units_en", "VARCHAR(64)"), ("projects", "land_area", "VARCHAR(64)"),
("projects", "pilot_floors", "VARCHAR(32)"),
("projects", "units_residential", "VARCHAR(32)"),
("projects", "units_commercial", "VARCHAR(32)"),
("projects", "units_office", "VARCHAR(32)"),
("projects", "client_en", "VARCHAR(128)"), ("projects", "client_en", "VARCHAR(128)"),
("projects", "project_type", "VARCHAR(128)"),
("projects", "project_type_en", "VARCHAR(128)"),
("projects", "usage", "VARCHAR(128)"),
("projects", "usage_en", "VARCHAR(128)"),
("projects", "summary_en", "TEXT"), ("projects", "summary_en", "TEXT"),
("projects", "body_en", "JSON"), ("projects", "body_en", "JSON"),
("articles", "title_en", "VARCHAR(256)"), ("articles", "title_en", "VARCHAR(256)"),
@@ -145,6 +153,25 @@ def run_ddl_migrations() -> None:
conn.execute( conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''") text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
) )
# نقش‌ها — 'admin' همه‌کاره، 'creator' فقط نویسندهٔ محتوا
conn.execute(
text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
)
# نویسندهٔ مقاله — نقش creator فقط مقاله‌های خودش را می‌بیند/ویرایش می‌کند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author_id INTEGER")
)
# متادیتای تصویر شاخص مقاله + پرسش‌وپاسخ‌های پایان مقاله (سئو)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_title VARCHAR(256) NOT NULL DEFAULT ''")
)
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs JSON"))
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs_en JSON"))
# سطرهای موجود ستون تازه را NULL می‌گیرند — آرایه خالی یعنی «بدون پرسش‌وپاسخ»
conn.execute(text("UPDATE articles SET faqs = '[]'::json WHERE faqs IS NULL"))
# ستون‌های انگلیسی — رشته‌ها با پیش‌فرض خالی، JSON بدون پیش‌فرض (NULL = بدون ترجمه) # ستون‌های انگلیسی — رشته‌ها با پیش‌فرض خالی، JSON بدون پیش‌فرض (NULL = بدون ترجمه)
for table, column, col_type in EN_COLUMNS: for table, column, col_type in EN_COLUMNS:
if col_type == "JSON": if col_type == "JSON":
+20 -2
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -20,6 +21,8 @@ class AdminUser(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True) username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
# 'admin' همه‌کاره — 'creator' فقط نویسندهٔ محتوا (پیش‌نویس مقاله‌های خودش)
role: Mapped[str] = mapped_column(String(16), default='admin')
class Brand(Base): class Brand(Base):
@@ -71,10 +74,18 @@ class Project(Base):
area_en: Mapped[str] = mapped_column(String(64), default="") area_en: Mapped[str] = mapped_column(String(64), default="")
floors: Mapped[str] = mapped_column(String(64), default="") floors: Mapped[str] = mapped_column(String(64), default="")
floors_en: Mapped[str] = mapped_column(String(64), default="") floors_en: Mapped[str] = mapped_column(String(64), default="")
units: Mapped[str] = mapped_column(String(64), default="") land_area: Mapped[str] = mapped_column(String(64), default="")
units_en: Mapped[str] = mapped_column(String(64), default="") pilot_floors: Mapped[str] = mapped_column(String(32), default="")
units_residential: Mapped[str] = mapped_column(String(32), default="")
units_commercial: Mapped[str] = mapped_column(String(32), default="")
units_office: Mapped[str] = mapped_column(String(32), default="")
client: Mapped[str] = mapped_column(String(128), default="") client: Mapped[str] = mapped_column(String(128), default="")
client_en: Mapped[str] = mapped_column(String(128), default="") client_en: Mapped[str] = mapped_column(String(128), default="")
# مشخصات تکمیلی پروژه — به درخواست تیم: نوع پروژه و کاربری
project_type: Mapped[str] = mapped_column(String(128), default="")
project_type_en: Mapped[str] = mapped_column(String(128), default="")
usage: Mapped[str] = mapped_column(String(128), default="")
usage_en: Mapped[str] = mapped_column(String(128), default="")
summary: Mapped[str] = mapped_column(Text, default="") summary: Mapped[str] = mapped_column(Text, default="")
summary_en: Mapped[str] = mapped_column(Text, default="") summary_en: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list) body: Mapped[list] = mapped_column(JSON, default=list)
@@ -95,12 +106,19 @@ class Article(Base):
category: Mapped[str] = mapped_column(String(64), default="") category: Mapped[str] = mapped_column(String(64), default="")
category_en: Mapped[str] = mapped_column(String(64), default="") category_en: Mapped[str] = mapped_column(String(64), default="")
image: Mapped[str] = mapped_column(Text, default="") image: Mapped[str] = mapped_column(Text, default="")
# متن جایگزین و عنوان تصویر شاخص — سئو و دسترس‌پذیری
image_alt: Mapped[str] = mapped_column(String(256), default="")
image_title: Mapped[str] = mapped_column(String(256), default="")
read: Mapped[str] = mapped_column(String(32), default="") read: Mapped[str] = mapped_column(String(32), default="")
read_en: Mapped[str] = mapped_column(String(32), default="") read_en: Mapped[str] = mapped_column(String(32), default="")
excerpt: Mapped[str] = mapped_column(Text, default="") excerpt: Mapped[str] = mapped_column(Text, default="")
excerpt_en: Mapped[str] = mapped_column(Text, default="") excerpt_en: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف) body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
# پرسش و پاسخ‌های پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
faqs: Mapped[list] = mapped_column(JSON, default=list)
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقاله‌های ادمین NULL
# draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود # draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود
status: Mapped[str] = mapped_column(String(16), default="published") status: Mapped[str] = mapped_column(String(16), default="published")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
+66 -15
View File
@@ -1,38 +1,74 @@
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login. """محدودسازی نرخ درخواست — پنجرهٔ لغزان درون‌حافظه‌ای.
پیش‌فرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک می‌کند. برای ورود ادمین (بر پایه تلاش ناموفق) و فرم‌های عمومی (بر پایه هر ارسال)
شمارنده در حافظه همین پروسه است — با ری‌استارت بک‌اند پاک می‌شود و همین کافی است. استفاده می‌شود. شمارنده‌ها در حافظهٔ همین پروسه‌اند — با ری‌استارت پاک می‌شوند؛
به همین دلیل uvicorn تک‌کارگر نگه داشته شده است.
""" """
import time import time
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, status
MAX_FAILED_ATTEMPTS = 5
WINDOW_SECONDS = 15 * 60
_failed: dict[str, list[float]] = {}
def client_ip(request: Request) -> str: def client_ip(request: Request) -> str:
"""IP واقعی کلاینت — پشت Traefik از X-Forwarded-For خوانده می‌شود
(uvicorn با --proxy-headers هم client.host را درست می‌کند)."""
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if forwarded: if forwarded:
return forwarded.split(",")[0].strip() return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown" return request.client.host if request.client else "unknown"
def _prune(key: str, now: float) -> list[float]: class RateLimiter:
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS] """پنجرهٔ لغزان بر پایه کلید (عملاً IP) — رویدادها پس از پنجره منقضی می‌شوند."""
_failed[key] = recent
return recent def __init__(self, max_events: int, window_seconds: int, message: str):
self.max_events = max_events
self.window_seconds = window_seconds
self.message = message
self._events: dict[str, list[float]] = {}
def _recent(self, key: str, now: float) -> list[float]:
recent = [t for t in self._events.get(key, []) if now - t < self.window_seconds]
self._events[key] = recent
return recent
def _reject(self, recent: list[float]) -> None:
now = time.monotonic()
retry_after = max(1, int(self.window_seconds - (now - recent[0])))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=self.message,
headers={"Retry-After": str(retry_after)},
)
def hit(self, request: Request) -> None:
"""یک رویداد ثبت می‌کند؛ اگر سهمیه کلید پر باشد 429 برمی‌گرداند."""
key = client_ip(request)
now = time.monotonic()
recent = self._recent(key, now)
if len(recent) >= self.max_events:
self._reject(recent)
self._events.setdefault(key, []).append(now)
def clear(self, request: Request) -> None:
self._events.pop(client_ip(request), None)
# ورود ادمین: ۵ تلاش ناموفق در ۱۵ دقیقه — فقط شکست‌ها شمرده می‌شوند
_failed: dict[str, list[float]] = {}
LOGIN_MAX_FAILED_ATTEMPTS = 5
LOGIN_WINDOW_SECONDS = 15 * 60
def ensure_allowed(request: Request) -> None: def ensure_allowed(request: Request) -> None:
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد می‌شود.""" """اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد می‌شود."""
key = client_ip(request) key = client_ip(request)
recent = _prune(key, time.monotonic()) now = time.monotonic()
if len(recent) >= MAX_FAILED_ATTEMPTS: recent = [t for t in _failed.get(key, []) if now - t < LOGIN_WINDOW_SECONDS]
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0]))) _failed[key] = recent
if len(recent) >= LOGIN_MAX_FAILED_ATTEMPTS:
retry_after = max(1, int(LOGIN_WINDOW_SECONDS - (now - recent[0])))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید", detail="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید",
@@ -47,3 +83,18 @@ def record_failure(request: Request) -> None:
def clear(request: Request) -> None: def clear(request: Request) -> None:
"""با ورود موفق، تاریخچه تلاش‌های ناموفق IP پاک می‌شود.""" """با ورود موفق، تاریخچه تلاش‌های ناموفق IP پاک می‌شود."""
_failed.pop(client_ip(request), None) _failed.pop(client_ip(request), None)
# فرم تماس: ۵ ارسال در ۱۵ دقیقه برای هر IP
contact_limiter = RateLimiter(
max_events=5,
window_seconds=15 * 60,
message="تعداد پیام‌های ارسالی زیاد است — کمی بعد دوباره امتحان کنید",
)
# خبرنامه: ۳ عضویت در ساعت برای هر IP
newsletter_limiter = RateLimiter(
max_events=3,
window_seconds=60 * 60,
message="تعداد درخواست‌ها زیاد است — کمی بعد دوباره امتحان کنید",
)
+58
View File
@@ -0,0 +1,58 @@
"""پاک‌سازی ورودی‌های غنی در زمان ذخیره.
بدنه مقاله‌ها سند TipTap (ProseMirror) است و فرانت‌اند همان را با
renderToHTMLString به HTML تبدیل و مستقیم تزریق می‌کند؛ متن گره‌ها در آن
مسیر escape می‌شود، اما آدرس‌های href/src عیناً وارد HTML می‌شوند — پس
اسکیم‌های خطرناک (javascript: و data: و ...) باید در سمت سرور حذف شوند.
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
# اسکیم‌های مجاز برای href/src — مسیرهای نسبی و لنگر هم بی‌اسکیم‌اند و می‌گذرند
SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
URL_ATTRS = ("href", "src")
def _clean_url(value: Any) -> str | None:
"""آدرس امن را برمی‌گرداند؛ در غیر این صورت None (مشخصه حذف می‌شود)."""
if not isinstance(value, str):
return value if value is not None else None
stripped = value.strip().replace("\n", "").replace("\r", "").replace("\t", "")
if not stripped:
return None
parsed = urlparse(stripped)
if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
return None
return stripped
def _walk(node: Any) -> Any:
if isinstance(node, list):
return [_walk(item) for item in node]
if not isinstance(node, dict):
return node
cleaned: dict[str, Any] = {}
for key, value in node.items():
if key == "attrs" and isinstance(value, dict):
attrs = dict(value)
for attr in URL_ATTRS:
if attr in attrs:
cleaned_url = _clean_url(attrs[attr])
if cleaned_url is None:
attrs.pop(attr, None)
else:
attrs[attr] = cleaned_url
cleaned[key] = attrs
else:
cleaned[key] = _walk(value)
return cleaned
def sanitize_doc(doc: Any) -> Any:
"""سند TipTap را درجا پاک می‌کند (سایر ساختارها دست‌نخورده برمی‌گردند)."""
if not isinstance(doc, dict):
return doc
return _walk(doc)
+42 -6
View File
@@ -3,6 +3,8 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
from .sanitize import sanitize_doc
# ---------- پایه ---------- # ---------- پایه ----------
@@ -55,10 +57,17 @@ class ProjectOut(ORMModel):
area_en: str = "" area_en: str = ""
floors: str floors: str
floors_en: str = "" floors_en: str = ""
units: str land_area: str = ""
units_en: str = "" pilot_floors: str = ""
units_residential: str = ""
units_commercial: str = ""
units_office: str = ""
client: str client: str
client_en: str = "" client_en: str = ""
project_type: str = ""
project_type_en: str = ""
usage: str = ""
usage_en: str = ""
summary: str summary: str
summary_en: str = "" summary_en: str = ""
body: list[str] body: list[str]
@@ -68,6 +77,7 @@ class ProjectOut(ORMModel):
class ArticleOut(ORMModel): class ArticleOut(ORMModel):
id: int id: int
slug: str slug: str
author_id: int | None = None
title: str title: str
title_en: str = "" title_en: str = ""
date: str date: str
@@ -75,12 +85,17 @@ class ArticleOut(ORMModel):
category: str category: str
category_en: str = "" category_en: str = ""
image: str image: str
image_alt: str = ""
image_title: str = ""
read: str read: str
read_en: str = "" read_en: str = ""
excerpt: str excerpt: str
excerpt_en: str = "" excerpt_en: str = ""
body: dict body: dict
body_en: Optional[dict] = None body_en: Optional[dict] = None
# پرسش‌وپاسخ‌های پایان مقاله — [{question, answer}]؛ سطرهای قدیمی ممکن است NULL باشند
faqs: Optional[list[dict]] = None
faqs_en: Optional[list[dict]] = None
status: str status: str
seo_keyphrase: str seo_keyphrase: str
@@ -386,10 +401,14 @@ class ContactIn(BaseModel):
name: str = Field(min_length=3, max_length=128) name: str = Field(min_length=3, max_length=128)
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$") phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
note: str = Field(default="", max_length=2000) note: str = Field(default="", max_length=2000)
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمی‌کند
website: str = Field(default="", max_length=128)
class NewsletterIn(BaseModel): class NewsletterIn(BaseModel):
email: EmailStr email: EmailStr
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمی‌کند
website: str = Field(default="", max_length=128)
# ---------- ورودی‌های پنل مدیریت ---------- # ---------- ورودی‌های پنل مدیریت ----------
@@ -403,6 +422,7 @@ class LoginIn(BaseModel):
class TokenOut(BaseModel): class TokenOut(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
role: str = "admin"
class ChangePasswordIn(BaseModel): class ChangePasswordIn(BaseModel):
@@ -440,14 +460,21 @@ class ProjectIn(BaseModel):
year: str = "" year: str = ""
year_en: str = "" year_en: str = ""
status: Literal["done", "progress"] = "done" status: Literal["done", "progress"] = "done"
land_area: str = ""
pilot_floors: str = ""
units_residential: str = ""
units_commercial: str = ""
units_office: str = ""
project_type: str = ""
project_type_en: str = ""
usage: str = ""
usage_en: str = ""
image: str = "" image: str = ""
gallery: list[str] = [] gallery: list[str] = []
area: str = "" area: str = ""
area_en: str = "" area_en: str = ""
floors: str = "" floors: str = ""
floors_en: str = "" floors_en: str = ""
units: str = ""
units_en: str = ""
client: str = "" client: str = ""
client_en: str = "" client_en: str = ""
summary: str = "" summary: str = ""
@@ -460,12 +487,13 @@ EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
def validate_doc_body(value: object) -> dict: def validate_doc_body(value: object) -> dict:
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد.""" """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و
آدرس‌های href/src آن به اسکیم‌های امن محدود شوند."""
if not value: if not value:
return dict(EMPTY_DOC) return dict(EMPTY_DOC)
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list): if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}") raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
return value return sanitize_doc(value)
class ArticleIn(BaseModel): class ArticleIn(BaseModel):
@@ -477,12 +505,16 @@ class ArticleIn(BaseModel):
category: str = "" category: str = ""
category_en: str = "" category_en: str = ""
image: str = "" image: str = ""
image_alt: str = ""
image_title: str = ""
read: str = "" read: str = ""
read_en: str = "" read_en: str = ""
excerpt: str = "" excerpt: str = ""
excerpt_en: str = "" excerpt_en: str = ""
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
faqs: list[dict] = []
faqs_en: list[dict] = []
status: Literal["draft", "published"] = "draft" status: Literal["draft", "published"] = "draft"
seo_keyphrase: str = Field(default="", max_length=128) seo_keyphrase: str = Field(default="", max_length=128)
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
@@ -779,12 +811,16 @@ class ArticlePatch(BaseModel):
category: Optional[str] = None category: Optional[str] = None
category_en: Optional[str] = None category_en: Optional[str] = None
image: Optional[str] = None image: Optional[str] = None
image_alt: Optional[str] = None
image_title: Optional[str] = None
read: Optional[str] = None read: Optional[str] = None
read_en: Optional[str] = None read_en: Optional[str] = None
excerpt: Optional[str] = None excerpt: Optional[str] = None
excerpt_en: Optional[str] = None excerpt_en: Optional[str] = None
body: Optional[dict] = None body: Optional[dict] = None
body_en: Optional[dict] = None body_en: Optional[dict] = None
faqs: Optional[list[dict]] = None
faqs_en: Optional[list[dict]] = None
status: Optional[Literal["draft", "published"]] = None status: Optional[Literal["draft", "published"]] = None
seo_keyphrase: Optional[str] = None seo_keyphrase: Optional[str] = None
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
+45 -2
View File
@@ -188,6 +188,12 @@ def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
@router.get("/robots.txt", response_class=Response) @router.get("/robots.txt", response_class=Response)
def robots(db: Session = Depends(get_db)): def robots(db: Session = Depends(get_db)):
# پیش‌نمایش خصوصی — هیچ خزنده‌ای نباید چیزی ذخیره کند
if settings.preview_mode:
return Response(
content="User-agent: *\nDisallow: /\n",
media_type="text/plain; charset=utf-8",
)
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""] lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
if settings.site_url: if settings.site_url:
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}") lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
@@ -206,6 +212,13 @@ def _sitemap_entry(loc: str, fa: str, en: str) -> str:
@router.get("/sitemap.xml", response_class=Response) @router.get("/sitemap.xml", response_class=Response)
def sitemap(db: Session = Depends(get_db)): def sitemap(db: Session = Depends(get_db)):
# پیش‌نمایش خصوصی — نقشه سایت خالی تا هیچ نشانی‌ای اعلام نشود
if settings.preview_mode:
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n'
)
return Response(content=xml, media_type="application/xml; charset=utf-8")
fa_urls = list(STATIC_PATHS) fa_urls = list(STATIC_PATHS)
fa_urls += [ fa_urls += [
f"/blog/{a.slug}" f"/blog/{a.slug}"
@@ -262,7 +275,7 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
brand = _brand_name(db) brand = _brand_name(db)
title, excerpt = _article_pair(article, "fa") title, excerpt = _article_pair(article, "fa")
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2]) first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
jsonld = { jsonld: object = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
"headline": title, "headline": title,
@@ -273,6 +286,10 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
"publisher": {"@type": "Organization", "name": brand}, "publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"), "mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
} }
# پرسش‌وپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایه‌ای از دو اسکیما)
faq_schema = _faq_jsonld(article, "fa")
if faq_schema:
jsonld = [jsonld, faq_schema]
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>" noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell( return _shell(
title=f"{title} | {brand}", title=f"{title} | {brand}",
@@ -325,12 +342,35 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
# ---------- صفحات انگلیسی (/en/...) ---------- # ---------- صفحات انگلیسی (/en/...) ----------
def _faq_jsonld(article: models.Article, lang: str) -> dict | None:
"""اسکیمای FAQPage برای نتایج غنی گوگل — از پرسش‌وپاسخ‌های پایان مقاله.
نسخه انگلیسی فقط وقتی ترجمه ثبت شده باشد جایگزین می‌شود."""
faqs = article.faqs if isinstance(article.faqs, list) else []
if lang == "en":
faqs_en = article.faqs_en if isinstance(article.faqs_en, list) else None
if faqs_en:
faqs = faqs_en
entities = [
{"@type": "Question", "name": (f.get("question") or "").strip(),
"acceptedAnswer": {"@type": "Answer", "text": (f.get("answer") or "").strip()}}
for f in faqs
if isinstance(f, dict) and (f.get("question") or "").strip()
]
if not entities:
return None
return {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": entities,
}
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse: def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
brand = _brand_name(db, "en") brand = _brand_name(db, "en")
title, excerpt = _article_pair(article, "en") title, excerpt = _article_pair(article, "en")
body = article.body_en if isinstance(article.body_en, dict) else article.body body = article.body_en if isinstance(article.body_en, dict) else article.body
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2]) first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
jsonld = { jsonld: object = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
"headline": title, "headline": title,
@@ -341,6 +381,9 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
"publisher": {"@type": "Organization", "name": brand}, "publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"), "mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
} }
faq_schema = _faq_jsonld(article, "en")
if faq_schema:
jsonld = [jsonld, faq_schema]
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>" noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell( return _shell(
title=f"{title} | {brand}", title=f"{title} | {brand}",
+49
View File
@@ -0,0 +1,49 @@
"""محیط Alembic — متادیتا از مدل‌های SQLAlchemy می‌آید و آدرس دیتابیس از تنظیمات."""
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app import models # noqa: F401 — ثبت مدل‌ها در متادیتا
from app.config import settings
from app.database import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_url() -> str:
return os.environ.get("DATABASE_URL", settings.database_url)
def run_migrations_offline() -> None:
context.configure(
url=get_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,25 @@
"""نقطه شروع زنجیره مهاجرت‌ها — جداول پایه با create_all ساخته می‌شوند.
ساخت اولیه ۲۹ جدول بر عهده Base.metadata.create_all است (در CMD کانتینر پیش از
این مهاجرت اجرا می‌شود و برای دیتابیس‌های موجود بی‌اثر است). از این نسخه به بعد
هر تغییر اسکیما باید یک revision واقعی Alembic باشد — نه create_all و نه
تغییر دستی — تا مسیر ارتقا و بازگشت آزمایش‌پذیر بماند.
Revision ID: 0001
Revises:
Create Date: 2026-09-18
"""
from typing import Sequence, Union
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+8
View File
@@ -11,3 +11,11 @@ bcrypt==4.2.1
email-validator==2.2.0 email-validator==2.2.0
boto3==1.35.0 boto3==1.35.0
python-multipart==0.0.18 python-multipart==0.0.18
# پاک‌سازی HTML سمت سرور (تیپ‌تپ و فیلدهای غنی)
nh3==0.2.20
# بهینه‌سازی تصویر در زمان آپلود (تغییر اندازه/فشرده‌سازی مجدد)
Pillow==11.1.0
# متریک‌های Prometheus برای مانیتورینگ (در /metrics داخلی)
prometheus-fastapi-instrumentator==7.0.0
# مهاجرت‌های نسخه‌دار اسکیما
alembic==1.14.0
+4
View File
@@ -74,8 +74,12 @@ def reset_rate_limiter():
from app import rate_limit from app import rate_limit
rate_limit._failed.clear() rate_limit._failed.clear()
rate_limit.contact_limiter._events.clear()
rate_limit.newsletter_limiter._events.clear()
yield yield
rate_limit._failed.clear() rate_limit._failed.clear()
rate_limit.contact_limiter._events.clear()
rate_limit.newsletter_limiter._events.clear()
@pytest.fixture() @pytest.fixture()
+403 -2
View File
@@ -1,6 +1,7 @@
"""تست‌های API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیش‌نویس مقاله و سئو.""" """تست‌های API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیش‌نویس مقاله و سئو."""
import re import re
import socket
DOC = {"type": "doc", "content": [{"type": "paragraph"}]} DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
@@ -8,12 +9,24 @@ DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
# ---------- عمومی ---------- # ---------- عمومی ----------
def test_health(client): def test_live(client):
res = client.get("/api/health") """liveness — پروسه زنده است، بدون وابستگی."""
res = client.get("/api/live")
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["status"] == "ok" assert res.json()["status"] == "ok"
def test_health_deep(client):
"""بررسی عمق — DB (SQLite در تست) سالم است؛ MinIO در محیط تست در دسترس نیست
پس /api/health باید 503 و وضعیت degraded با جزئیات هر وابستگی بدهد."""
res = client.get("/api/health")
assert res.status_code == 503
body = res.json()
assert body["status"] == "degraded"
assert body["checks"]["database"] == "ok"
assert body["checks"]["storage"] == "error"
def test_site_hides_draft_articles(client): def test_site_hides_draft_articles(client):
res = client.get("/api/site") res = client.get("/api/site")
assert res.status_code == 200 assert res.status_code == 200
@@ -268,3 +281,391 @@ def test_en_backfill_fills_only_empty_fields(auth_client, client):
assert res.status_code == 200 assert res.status_code == 200
backfill_en_content() backfill_en_content()
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name" assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
# ---------- محافظت فرم‌ها و پاک‌سازی ----------
def test_contact_honeypot_saves_nothing(client, auth_client):
"""دامنهٔ پنهان پرشده یعنی ربات — پاسخ موفقِ قلابی، بدون ذخیره."""
res = client.post(
"/api/contact",
json={"name": "ربات تستی", "phone": "09120000000", "website": "spam"},
)
assert res.status_code == 201
assert res.json()["id"] == 0 # رکورد قلابی — در دیتابیس نیست
assert auth_client.get("/api/admin/messages").json() == []
def test_contact_rate_limited(client, auth_client):
"""ارسال ششم در پنجرهٔ ۱۵ دقیقه‌ای رد می‌شود (۵ مجاز برای هر IP)."""
for _ in range(5):
res = client.post(
"/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"}
)
assert res.status_code == 201, res.text
res = client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
assert res.status_code == 429
assert "Retry-After" in res.headers
def test_newsletter_honeypot_saves_nothing(client, auth_client):
res = client.post(
"/api/newsletter", json={"email": "bot@example.com", "website": "spam"}
)
assert res.status_code == 201
assert res.json()["id"] == 0
assert auth_client.get("/api/admin/subscribers").json() == []
def test_message_and_subscriber_csv_export(auth_client):
auth_client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
res = auth_client.get("/api/admin/messages/export.csv")
assert res.status_code == 200
assert res.headers["content-type"].startswith("text/csv")
assert "کاربر تماس" in res.text
auth_client.post("/api/newsletter", json={"email": "sale@example.com"})
res = auth_client.get("/api/admin/subscribers/export.csv")
assert res.status_code == 200
assert "sale@example.com" in res.text
def test_article_body_url_sanitization(auth_client):
"""اسکیم‌های خطرناک در href/src سند TipTap در زمان ذخیره حذف می‌شوند."""
res = auth_client.post(
"/api/admin/articles",
json={
"slug": "sanitized-post",
"title": "مقاله پاک‌سازی‌شده",
"status": "published",
"body": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{"type": "link", "attrs": {"href": "javascript:alert(1)"}},
],
"text": "لینک خطرناک",
}
],
}
],
},
},
)
assert res.status_code == 201, res.text
body = res.json()["body"]
marks = body["content"][0]["content"][0].get("marks", [])
assert all("href" not in m.get("attrs", {}) for m in marks)
# ---------- نام‌گذاری تصاویر، درون‌ریزی و پرسش‌وپاسخ مقاله ----------
class _FakeS3:
def __init__(self, *args, **kwargs):
self.objects = {}
def put_object(self, **kwargs):
self.objects[kwargs["Key"]] = kwargs["Body"]
def _mock_storage(monkeypatch):
"""MinIO در تست در دسترس نیست — کلاینت S3 و DNS عمومی جعل می‌شوند."""
fake = _FakeS3()
monkeypatch.setattr("app.api.admin.boto3.client", lambda *a, **k: fake)
monkeypatch.setattr(
"app.api.admin.socket.getaddrinfo",
lambda host, port, *a, **k: [(socket.AF_INET, None, None, "", ("93.184.216.34", 0))],
)
return fake
def _png_bytes() -> bytes:
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", (8, 8), (200, 120, 60)).save(buf, "PNG")
return buf.getvalue()
def test_upload_with_readable_name(auth_client, monkeypatch):
_mock_storage(monkeypatch)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("raw name.png", _png_bytes(), "image/png")},
data={"name": "برج الهیه tower!"},
)
assert res.status_code == 200, res.text
key = res.json()["key"]
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + نرمال‌شدن به WebP
assert "برج-الهیه-tower" in key
assert key.endswith(".webp")
assert res.json()["content_type"] == "image/webp"
assert "!" not in key
def _animated_gif_bytes() -> bytes:
from PIL import Image
import io
buf = io.BytesIO()
first = Image.new("RGB", (12, 8), (200, 120, 60))
second = Image.new("RGB", (12, 8), (60, 120, 200))
first.save(buf, "GIF", save_all=True, append_images=[second], duration=100, loop=0)
return buf.getvalue()
def test_upload_jpeg_converted_to_webp(auth_client, monkeypatch):
_mock_storage(monkeypatch)
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", (3000, 1500), (10, 10, 10)).save(buf, "JPEG", quality=90)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("tower.jpg", buf.getvalue(), "image/jpeg")},
)
assert res.status_code == 200, res.text
body = res.json()
assert body["content_type"] == "image/webp"
assert body["key"].endswith(".webp")
# سقف ضلع ۲۰۰۰ پیکسل روی خروجی اعمال شده
assert body["width"] == 2000 and body["height"] == 1000
def test_upload_animated_gif_becomes_animated_webp(auth_client, monkeypatch):
fake = _mock_storage(monkeypatch)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("anim.gif", _animated_gif_bytes(), "image/gif")},
)
assert res.status_code == 200, res.text
assert res.json()["content_type"] == "image/webp"
from PIL import Image
import io
stored = next(iter(fake.objects.values()))
out = Image.open(io.BytesIO(stored))
assert out.format == "WEBP"
assert getattr(out, "n_frames", 1) == 2 # حرکت از بین نرفته
def test_upload_undecodable_file_rejected(auth_client, monkeypatch):
_mock_storage(monkeypatch)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("notes.txt", b"this is not a media file at all", "text/plain")},
)
assert res.status_code == 415
def test_upload_video_becomes_animated_webp(auth_client, monkeypatch):
# ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن WebP متحرک
import shutil as _shutil
import subprocess as _subprocess
import tempfile as _tempfile
import os as _os
_mock_storage(monkeypatch)
if _shutil.which("ffmpeg") is None:
res = auth_client.post(
"/api/admin/upload",
files={"file": ("clip.mp4", b"\x00\x00\x00\x18ftypmp42", "video/mp4")},
)
assert res.status_code == 415
return
with _tempfile.TemporaryDirectory() as tmp:
clip = _os.path.join(tmp, "clip.mp4")
_subprocess.run(
["ffmpeg", "-y", "-v", "error", "-f", "lavfi",
"-i", "testsrc=duration=0.5:size=128x96:rate=10", clip],
check=True, capture_output=True, timeout=30,
)
with open(clip, "rb") as fh:
clip_bytes = fh.read()
res = auth_client.post(
"/api/admin/upload",
files={"file": ("clip.mp4", clip_bytes, "video/mp4")},
)
assert res.status_code == 200, res.text
assert res.json()["content_type"] == "image/webp"
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
_mock_storage(monkeypatch)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("Elahieh Tower.jpg", _png_bytes(), "image/jpeg")},
data={"name": ""},
)
assert res.status_code == 200
assert "Elahieh-Tower" in res.json()["key"]
def test_import_image_rejects_internal_host(auth_client):
# SSRF — میزبان حلقه‌محلی/خصوصی رد می‌شود پیش از هر درخواست شبکه
res = auth_client.post("/api/admin/import-image", json={"url": "http://127.0.0.1:9000/x.png"})
assert res.status_code == 400
res = auth_client.post("/api/admin/import-image", json={"url": "http://192.168.1.5/x.png"})
assert res.status_code == 400
res = auth_client.post("/api/admin/import-image", json={"url": "file:///etc/passwd"})
assert res.status_code == 400
def test_import_image_rejects_bad_content_type(auth_client, monkeypatch):
_mock_storage(monkeypatch)
import io as _io
class FakeResponse:
def __init__(self):
self.headers = {"Content-Type": "text/html"}
def read(self, n):
return b"<html></html>"
def __enter__(self):
return self
def __exit__(self, *args):
return False
def fake_urlopen(request, timeout):
return FakeResponse()
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", fake_urlopen)
res = auth_client.post("/api/admin/import-image", json={"url": "https://example.com/page"})
assert res.status_code == 415
def test_import_image_happy_path(auth_client, monkeypatch):
_mock_storage(monkeypatch)
class FakeResponse:
def __init__(self):
self.headers = {"Content-Type": "image/png"}
def read(self, n):
return _png_bytes()
def __enter__(self):
return self
def __exit__(self, *args):
return False
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", lambda request, timeout: FakeResponse())
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
assert res.status_code == 200, res.text
assert res.json()["url"].startswith("/media/")
assert res.json()["key"].endswith(".webp")
assert "Elahieh-pic" in res.json()["key"]
def test_article_persists_image_meta_and_faqs(auth_client, client):
created = auth_client.post(
"/api/admin/articles",
json={"slug": "meta-article", "title": "متادیتا", "body": DOC},
)
assert created.status_code == 201, created.text
article_id = created.json()["id"]
faqs = [{"question": "هزینه چقدر است؟", "answer": "بستگی به پروژه دارد."}]
res = auth_client.patch(
f"/api/admin/articles/{article_id}",
json={
"image_alt": "نمای برج الهیه",
"image_title": "برج آرامش",
"faqs": faqs,
"faqs_en": [{"question": "What is the cost?", "answer": "Depends on the project."}],
},
)
assert res.status_code == 200, res.text
assert res.json()["image_alt"] == "نمای برج الهیه"
assert res.json()["image_title"] == "برج آرامش"
assert res.json()["faqs"] == faqs
# انتشار و سپس خروجی عمومی — همین مقادیر برمی‌گردد
assert auth_client.patch(f"/api/admin/articles/{article_id}", json={"status": "published"}).status_code == 200
public = client.get("/api/articles/meta-article").json()
assert public["image_alt"] == "نمای برج الهیه"
assert public["faqs"] == faqs
assert auth_client.delete(f"/api/admin/articles/{article_id}").status_code == 204
def test_blog_shell_includes_faqpage_jsonld(auth_client, client):
created = auth_client.post(
"/api/admin/articles",
json={
"slug": "faq-article",
"title": "مقاله پرسش‌وپاسخ",
"body": DOC,
"status": "published",
"faqs": [{"question": "زمان ساخت؟", "answer": "۳۰ ماه."}],
},
)
assert created.status_code == 201, created.text
res = client.get("/blog/faq-article")
assert res.status_code == 200
assert "FAQPage" in res.text
assert "زمان ساخت؟" in res.text
# نسخه انگلیسی بدون ترجمه → همان فارسی؛ با ترجمه → انگلیسی
en = client.get("/en/blog/faq-article").text
assert "FAQPage" in en
article_id = created.json()["id"]
auth_client.patch(
f"/api/admin/articles/{article_id}",
json={"faqs_en": [{"question": "How long?", "answer": "Thirty months."}]},
)
en = client.get("/en/blog/faq-article").text
assert "How long?" in en
assert "زمان ساخت؟" not in en
auth_client.delete(f"/api/admin/articles/{article_id}")
def test_blog_shell_without_faqs_has_no_faqpage(client):
res = client.get("/blog/published-post")
assert res.status_code == 200
assert "FAQPage" not in res.text
# ---------- پیش‌نمایش خصوصی (برای انتشار عمومی خاموش می‌شود) ----------
def test_preview_mode_blocks_robots_and_sitemap(client, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "preview_mode", True)
try:
robots = client.get("/robots.txt")
assert robots.status_code == 200
assert "Disallow: /" in robots.text
assert "Sitemap:" not in robots.text
sitemap = client.get("/sitemap.xml")
assert sitemap.status_code == 200
assert "published-post" not in sitemap.text
# لایه دوم: هدر noindex روی همه پاسخ‌ها
assert "noindex" in client.get("/api/health").headers.get("X-Robots-Tag", "")
finally:
monkeypatch.setattr(settings, "preview_mode", False)
def test_public_mode_allows_indexing(client):
robots = client.get("/robots.txt").text
assert "Disallow: /admin" in robots
assert "Sitemap:" in robots
assert "noindex" not in client.get("/api/health").headers.get("X-Robots-Tag", "")
+12
View File
@@ -0,0 +1,12 @@
# کانتکست بیلد باریک
__pycache__/
**/__pycache__/
*.pyc
.venv/
venv/
.env
.env.*
README.md
Dockerfile
.dockerignore
test_pure.py
+28
View File
@@ -0,0 +1,28 @@
# سرویس چت‌بات — این مقادیر را در .env ریشه پروژه بگذارید (نه اینجا)
# این فایل فقط مستندسازی متغیرهاست.
# --- اجباری ---
# دیتابیس سایت (فقط خواندن) — در داکر از docker-compose.yml تزریق می‌شود
# DATABASE_URL=postgresql+psycopg://aramesh:PASSWORD@db:5432/arameshplaza
# LLM سازگار با OpenAI — بدون این چت‌بات پاسخ نمی‌دهد
# مثال سرور داخلی: LLM_BASE_URL=http://172.16.22.21:4460/v1
# مثال سرویس ابری: LLM_BASE_URL=https://api.example.com/v1
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=openai/gpt-oss-120b
# --- اختیاری ---
# توکن محافظت از POST /chat/reindex — اگر خالی باشد مسیر ۴۰۳ می‌دهد
CHAT_REINDEX_TOKEN=
# ری‌رنکر را روی سرور کم‌رم خاموش کنید (RERANKER_ENABLED=false)
RERANKER_ENABLED=true
# وزن جست‌وجوی معنایی در ترکیب نمره‌ها (۰ تا ۱) — پیش‌فرض ۰.۵
ALPHA_DENSE=0.5
# تعداد سند نهایی به LLM — پیش‌فرض ۳
BEST_OF_K=3
# بازسازی خودکار ایندکس پس از تغییر محتوا (ثانیه، ۰ = خاموش)
INDEX_REFRESH_SECONDS=600
# محدودیت نرخ: حداکثر پیام در پنجره زمانی (پیش‌فرض ۲۰ پیام در ۳۰۰ ثانیه)
RATE_LIMIT_MESSAGES=20
RATE_LIMIT_WINDOW=300
+34
View File
@@ -0,0 +1,34 @@
# سرویس چت‌بات — پایتون + مدل‌های بازیابی (CPU)
FROM python:3.11-slim
WORKDIR /srv
# همهٔ وابستگی‌های سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند —
# بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابل‌اعتماد نیست
COPY requirements.txt /srv/chatbot/requirements.txt
# torch روی PyPI پیش‌فرض لینوکس نسخهٔ CUDA است و کل استک nvidia را می‌آورد
# (ایمیج ~۹.۷GB روی سرور بدون GPU). نسخهٔ CPU حدود ۲۰۰MB است — همین اول نصب
# می‌شود تا pip در گام بعد آن را برآورده‌شده ببیند.
# روی ماشینی با GPU می‌توان این خط را حذف کرد.
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu "torch>=2.2"
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
COPY . /srv/chatbot/
# مدل امبدینگ/ری‌رنکر در /data کش می‌شود (والیوم داکر) — بردارها در pgvector می‌روند و فایل ایندکس جدا وجود ندارد
ENV HF_HOME=/data/hf \
HF_CACHE_DIR=/data/hf
VOLUME /data
# کاربر بدون دسترسی root — فقط /data باید نوشتنی باشد (کش مدل‌ها)
RUN useradd --system --create-home appuser \
&& mkdir -p /data \
&& chown -R appuser:appuser /srv/chatbot /data
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/chat/healthz')" || exit 1
CMD ["uvicorn", "chatbot.app:api", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
+121
View File
@@ -0,0 +1,121 @@
# دستیار هوشمند سایت (چت‌بات)
سرویس گفتگوی محتوای سایت «گروه ساختمانی آرامش» — بازدیدکننده به فارسی سؤال می‌پرسد و
دستیار **فقط از روی محتوای خود سایت** (مقاله‌ها، پروژه‌ها، خدمات، سؤالات متداول، اطلاعات تماس و…)
پاسخ می‌دهد؛ اگر پاسخ در محتوا نباشد، به‌جای ساخت جواب، صادقانه اعلام می‌کند.
این سرویس پورت خط لولهٔ پروژهٔ `llm-srerver-chatbot` (نسخه v13) با چهار تغییر اصلی برای این سایت است:
1. **پایگاه sparse به‌جای SPLADE از BM25 استفاده می‌کند** — SPLADE انگلیسی‌محور است و روی
فارسی عملاً بی‌فایده؛ BM25 با نرمال‌سازی فارسی (ی/ک عربی، اعراب، ارقام، نیم‌فاصله) جایگزین شده است.
2. **ذخیره‌ساز برداری pgvector است، نه FAISS** — بردارها در یک دیتابیس جدا (`aramesh_chatbot`)
روی همان نمونه Postgres نگهداری می‌شوند؛ همگام‌سازی افزایشی است (فقط سطرهای جدید/تغییریافته
امبد می‌شوند)، بکاپ‌گیری با خود دیتابیس است و فایل ایندکس جدا وجود ندارد.
3. **تأخیر کمتر** — مسیریابی `alpha` با LLM و گسترش چند‌پرسشی (multi-query) به‌صورت پیش‌فرض
خاموش‌اند (با متغیر محیطی قابل فعال‌سازی)؛ بازنویسی پرسش فقط وقتی تاریخچه گفتگو وجود دارد اجرا می‌شود.
4. **استریم واقعی توکن‌به‌توکن** با `astream_events` به‌جای دیف پیام‌های کامل.
## خط لوله هر پیام
```
پرسش کاربر
│
├─[اگر تاریخچه هست] بازنویسی پرسش با LLM (حل ضمیرها و اشاره‌ها: «اون دومی» → موضوع واقعی)
│
├─ بازیابی ترکیبی: pgvector (معنایی، کسینوسی روی HNSW — MiniLM چندزبانه)
│ + BM25 (کلیدواژه، متن نرمال‌شده فارسی)
│ نمره‌ها min-max → ترکیب α×معنایی + (1-α)×کلیدواژه
│
├─ برش خوشه‌ای (K-Means K=2 + silhouette ≥ 0.55) — مرز طبیعی کیفیت
│
├─ ری‌رنک با BAAI/bge-reranker-v2-m3 → نگه‌داشتن ۳ سند برتر
│
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
```
ایندکس از محتوای PostgreSQL سایت ساخته می‌شود (`articles` منتشرشده، `projects`، `services`،
`faqs`، `brand`، `offices`، `investment_models`، `process_steps`، `why_us`، `team`،
`testimonials`، `timeline`، `story`). متن‌های بلند به قطعات ~۷۰۰ نویسه‌ای هم‌پوشان برش می‌خورند.
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار و `content_hash` دارد و همگام‌سازی
فقط سطرهای جدید/تغییریافته را امبد می‌کند. دیتابیس و جدول در شروع سرویس خودکار ساخته می‌شوند.
- **بازسازی خودکار**: یک حلقهٔ پس‌زمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
می‌کند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام می‌شود
(امبد اجباری همه: `POST /chat/reindex`).
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگ‌گراف) — با ری‌استارت پاک می‌شود.
## متغیرهای محیطی (در `.env` ریشه پروژه)
| متغیر | پیش‌فرض | توضیح |
|---|---|---|
| `LLM_BASE_URL` | خالی | **اجباری** — نشانی سرویس LLM سازگار با OpenAI. بدون آن چت‌بات بالا می‌آید ولی پاسخ نمی‌دهد |
| `LLM_API_KEY` | خالی | کلید API (برای سرور داخلی `not-needed` بگذارید) |
| `LLM_MODEL` | `openai/gpt-oss-120b` | نام مدل |
| `CHAT_REINDEX_TOKEN` | خالی | توکن `POST /chat/reindex` — اگر خالی باشد آن مسیر ۴۰۳ می‌دهد |
| `CHATBOT_DB_NAME` | `aramesh_chatbot` | نام دیتابیس اختصاصی چت‌بات روی همان نمونه Postgres (بردارها اینجا می‌روند) |
| `EMBEDDING_DIM` | `384` | بعد بردار مدل امبدینگ — با تعویض مدل امبدینگ باید هماهنگ شود |
| `RERANKER_ENABLED` | `true` | روی سرور کم‌رم (زیر ~۴GB) `false` بگذارید |
| `ALPHA_DENSE` | `0.5` | وزن جست‌وجوی معنایی در ترکیب نمره‌ها (۰ تا ۱) |
| `BEST_OF_K` | `3` | تعداد سند نهایی که به LLM می‌رود |
| `INDEX_REFRESH_SECONDS` | `600` | بازسازی خودکار ایندکس پس از تغییر محتوا (۰ = خاموش) |
| `RATE_LIMIT_MESSAGES` / `RATE_LIMIT_WINDOW` | `20` / `300` | سقف پیام هر IP در پنجره زمانی (ثانیه) |
`DATABASE_URL` در داکر از `docker-compose.yml` تزریق می‌شود — دیتابیس سایت فقط خوانده
می‌شود و نوشتن (بردارها) فقط در دیتابیس جدا `aramesh_chatbot` انجام می‌گیرد.
## پیش‌نیاز pgvector
سرویس `db` استک از ایمیج `pgvector/pgvector:pg17` استفاده می‌کند (همان postgres:17 +
افزونهٔ vector). اگر Postgres سایت روی نمونهٔ دیگری است، باید افزونهٔ pgvector روی آن
فعال باشد؛ در غیر این صورت شروع چت‌بات با خطای واضح متوقف می‌شود.
## مسیرها
| مسیر | توضیح |
|---|---|
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکن‌به‌توکن پاسخ (`{"content": …}` و در پایان `{"done": true}`) |
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
| `GET /chat/eval?message=…` | مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابی‌شده با thread تازه |
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
## اجرا
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
```bash
# در .env ریشه:
LLM_BASE_URL=http://172.16.22.21:4460/v1 # یا هر سرویس سازگار با OpenAI
LLM_API_KEY=not-needed
```
اولین بوت مدل‌ها را دانلود می‌کند (~۱.۵GB: امبدینگ + ری‌رنکر) و در والیوم
`chatbot_cache` کش می‌شود؛ بوت‌های بعدی چند ثانیه طول می‌کشد.
اجرای محلی بدون داکر:
```bash
cd chatbot
python -m venv .venv && source .venv/bin/activate # ویندوز: .venv\Scripts\activate
pip install -r requirements.txt
export DATABASE_URL="postgresql+psycopg://aramesh:PASS@localhost:5432/arameshplaza"
export LLM_BASE_URL="http://…" LLM_MODEL="openai/gpt-oss-120b"
uvicorn chatbot.app:api --port 8000 # از ریشه پوشه والد
```
## منابع مورد نیاز
- **رم**: با ری‌رنکر ~۴GB (مدل ۵۶۸M پارامتری روی CPU)؛ بدون ری‌رنکر ~۲GB
- **CPU**: امبدینگ و ری‌رنک روی CPU اجرا می‌شوند؛ GPU لازم نیست
- **شبکه**: دسترسی به PostgreSQL و به نشانی `LLM_BASE_URL`
## نکات امنیتی
- دیتابیس سایت فقط **خواندن**؛ نوشتن فقط در دیتابیس اختصاصی `aramesh_chatbot` انجام می‌شود
- محدودسازی نرخ per-IP فعال است (پیش‌فرض ۲۰ پیام در ۵ دقیقه)
- CORS باز است ولی کوکی/اعتبارنامه‌ای رد و بدل نمی‌شود؛ در تولید همه‌چیز هم‌مبدأ (پشت Traefik) است
+1
View File
@@ -0,0 +1 @@
"""سرویس چت‌بات سایت آرامش — بازیابی ترکیبی روی محتوای PostgreSQL."""
+284
View File
@@ -0,0 +1,284 @@
"""سرویس چت‌بات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
import asyncio
import json
import logging
import time
import uuid
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from langchain_core.messages import AIMessage, HumanMessage
from . import content as content_mod
from . import pipeline
from . import vectorstore
from .config import settings
from .indexing import INDEX, sync_index
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
)
log = logging.getLogger("chatbot")
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
_refresh_task: asyncio.Task | None = None
@asynccontextmanager
async def lifespan(_app: FastAPI):
try:
await asyncio.to_thread(sync_index)
except Exception as e:
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
global _refresh_task
_refresh_task = asyncio.create_task(_refresh_loop())
yield
_refresh_task.cancel()
async def _refresh_loop() -> None:
interval = settings.index_refresh_seconds
if interval <= 0:
return
while True:
await asyncio.sleep(interval)
try:
# همگام‌سازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد می‌شوند
await asyncio.to_thread(sync_index)
except Exception as e:
log.warning("Background index refresh failed: %s", e)
api = FastAPI(
title="Aramesh Site Chatbot",
version="1.0.0",
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ری‌رنکر BGE",
lifespan=lifespan,
)
api.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
_hits: dict[str, deque] = defaultdict(deque)
_last_sweep = 0.0
def _rate_limited(ip: str) -> bool:
global _last_sweep
now = time.time()
# پاک‌سازی دوره‌ای کلیدهای بی‌مصرف تا دیکشنری بی‌نهایت بزرگ نشود
if now - _last_sweep > settings.rate_limit_window:
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
for k in stale:
_hits.pop(k, None)
_last_sweep = now
window = _hits[ip]
while window and now - window[0] > settings.rate_limit_window:
window.popleft()
if len(window) >= settings.rate_limit_messages:
return True
window.append(now)
return False
def _client_ip(request: Request | None) -> str:
if request is None:
return "?"
forwarded = request.headers.get("x-forwarded-for")
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
# --- کمکی‌های SSE ---
def _sse(payload: dict) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _chunk_text(chunk) -> str:
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخش‌ها)."""
content = getattr(chunk, "content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
return ""
async def _stream_answer(message: str, thread_id: str):
if not INDEX.ready:
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
return
if pipeline.get_llm() is None:
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
return
config = {"configurable": {"thread_id": thread_id}}
inputs = {"messages": [HumanMessage(content=message)]}
try:
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
# فقط توکن‌های پاسخ نهایی — نه فراخوانی‌های کمکی (بازنویسی/گسترش پرسش)
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
text = _chunk_text(event["data"]["chunk"])
if text:
yield _sse({"content": text, "done": False})
yield _sse({"content": "", "done": True})
except Exception as e:
log.exception("Streaming failed")
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
# --- مسیرها ---
@api.get("/chat/healthz")
async def healthz():
return {
"status": "ok",
"index_ready": INDEX.ready,
"documents": len(INDEX.docs),
"vectors": await asyncio.to_thread(vectorstore.vector_count),
"llm_configured": bool(settings.llm_base_url),
"reranker_enabled": settings.reranker_enabled,
}
@api.get("/chat/suggestions")
async def suggestions():
return {"questions": _get_suggestions()}
@api.get("/chat/eval")
async def eval_question(message: str = Query(...)):
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابی‌شده، با thread تازه."""
if not INDEX.ready:
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
if pipeline.get_llm() is None:
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
config = {"configurable": {"thread_id": thread_id}}
inputs = {"messages": [HumanMessage(content=message)]}
final_state = await pipeline.graph.ainvoke(inputs, config=config)
answer = ""
for msg in reversed(final_state.get("messages", [])):
if isinstance(msg, AIMessage):
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
break
return {
"question": message,
"contextualized_query": final_state.get("contextualized_query") or message,
"answer": answer,
"contexts": final_state.get("documents", []),
"thread_id": thread_id,
}
@api.post("/chat/reindex")
async def reindex(request: Request):
"""همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود."""
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
raise HTTPException(status_code=403, detail="invalid reindex token")
stats = await asyncio.to_thread(sync_index, True)
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval می‌سازد
@api.get("/chat/{scope}/stream")
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
return await stream_chat(message=message, thread_id=thread_id, request=request)
@api.get("/chat/{scope}/eval")
async def eval_scoped(scope: str, message: str = Query(...)):
return await eval_question(message=message)
@api.get("/chat/{scope}/suggestions")
async def suggestions_scoped(scope: str):
return {"questions": _get_suggestions()}
@api.get("/chat/stream")
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
"""استریم توکن‌به‌توکن پاسخ با SSE."""
if _rate_limited(_client_ip(request)):
raise HTTPException(status_code=429, detail="تعداد پیام‌های شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
async def generate():
async for chunk in _stream_answer(message, thread_id):
yield chunk
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
# --- پیشنهاد پرسش‌ها (کش‌شده) ---
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
def _get_suggestions() -> list[str]:
global _suggestions_cache
now = time.time()
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
return _suggestions_cache[1]
questions: list[str] = []
if INDEX.ready:
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
model = pipeline.get_llm()
if model and (faqs or titles):
material = "\n".join([d["text"][:200] for d in faqs] + titles)
try:
prompt = (
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
"احتمالاً می‌پرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
'فقط آرایه JSON رشته‌ای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
f"محتوا:\n{material[:2000]}"
)
resp = model.invoke([HumanMessage(content=prompt)])
text = resp.content if isinstance(resp.content, str) else ""
if text.startswith("```"):
text = text.split("```")[1]
text = text[4:] if text.startswith("json") else text
import re
match = re.search(r"\[.*\]", text, re.DOTALL)
if match:
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
except Exception as e:
log.warning("Suggestion generation failed: %s", e)
if not questions and faqs:
# جایگزین: خود پرسش‌های متداول
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
_suggestions_cache = (now, questions)
return questions
if __name__ == "__main__":
import uvicorn
uvicorn.run(api, host="0.0.0.0", port=8000)
+57
View File
@@ -0,0 +1,57 @@
"""تنظیمات سرویس چت‌بات — از متغیرهای محیطی یا فایل .env خوانده می‌شود."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# دیتابیس سایت (فقط خواندن) — همان DATABASE_URL بک‌اند
database_url: str = "postgresql+psycopg://aramesh:aramesh@localhost:5432/arameshplaza"
# دیتابیس اختصاصی چت‌بات روی همان نمونه Postgres — بردارها اینجا نوشته می‌شوند
# تا دیتابیس سایت فقط‌خواندنی بماند؛ در شروع سرویس خودکار ساخته می‌شود
chatbot_db_name: str = "aramesh_chatbot"
# LLM سازگار با OpenAI — اجباری؛ بدون آن پاسخ‌گویی غیرفعال است
llm_base_url: str = ""
llm_api_key: str = ""
llm_model: str = "openai/gpt-oss-120b"
# مدل‌های بازیابی
embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
# بعد بردار خروجی مدل امبدینگ (MiniLM-L12 = 384) — با تغییر مدل باید عوض شود
embedding_dim: int = 384
reranker_model: str = "BAAI/bge-reranker-v2-m3"
reranker_enabled: bool = True
reranker_device: str = "cpu"
# وزن جست‌وجوی معنایی در ترکیب نمره‌ها (۰ = فقط کلیدواژه، ۱ = فقط معنایی)
alpha_dense: float = 0.5
# تعداد سند نهایی که به LLM داده می‌شود
best_of_k: int = 3
# تعداد نتیجه هر پایگاه (معنایی و کلیدواژه) برای هر پرس‌وجو
retrieve_k: int = 12
# بازنویسی پرس‌وجو با تاریخچه گفتگو (فقط وقتی تاریخچه هست صدا زده می‌شود)
contextualize_enabled: bool = True
# گسترش پرس‌وجو به چند نوع — برای پیکره کوچک پیش‌فرض خاموش (تأخیر کمتر)
query_expansion: bool = False
# مسیر کش مدل‌ها (در داکر روی والیوم /data سوار می‌شود)
hf_cache_dir: str = "/data/hf"
# محدودسازی نرخ درخواست هر IP (تعداد پیام در پنجره زمانی به ثانیه)
rate_limit_messages: int = 20
rate_limit_window: int = 300
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار می‌کند
reindex_token: str = ""
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
suggestions_ttl: int = 600
site_context_ttl: int = 120
# بازسازی خودکار ایندکس وقتی محتوای دیتابیس عوض شود (۰ = خاموش)
index_refresh_seconds: int = 600
settings = Settings()
+315
View File
@@ -0,0 +1,315 @@
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقاله‌ها و
پروژه‌ها هم می‌تواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند.
"""
import logging
from typing import Any
from .persian import normalize
log = logging.getLogger(__name__)
# URL پایه صفحات — چت‌بات مسیر نسبی می‌دهد؛ فرانت‌اند روی همان دامنه است
_ARTICLE_URL = "/blog/{slug}"
_PROJECT_URL = "/projects/{slug}"
def _psycopg_dsn(database_url: str) -> str:
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل می‌کند."""
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
import psycopg
from psycopg.rows import dict_row
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
cur = conn.execute(sql)
return cur.fetchall()
def tiptap_blocks(node: Any) -> list[str]:
"""متن بلوک‌به‌بلوک از سند TipTap (یا هر JSON تو در تو) بیرون می‌کشد."""
if not isinstance(node, dict):
return []
ntype = node.get("type")
if ntype in ("paragraph", "heading", "codeBlock"):
text = _inline_text(node).strip()
return [text] if text else []
blocks: list[str] = []
for child in node.get("content") or []:
blocks.extend(tiptap_blocks(child))
return blocks
def _inline_text(node: dict) -> str:
parts: list[str] = []
for child in node.get("content") or []:
ctype = child.get("type")
if ctype == "text":
parts.append(child.get("text", ""))
elif ctype == "hardBreak":
parts.append(" ")
elif ctype == "image":
alt = (child.get("attrs") or {}).get("alt") or ""
if alt:
parts.append(f"[تصویر: {alt}]")
else:
parts.append(_inline_text(child))
return "".join(parts)
def body_to_text(body: Any) -> str:
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
if not body:
return ""
if isinstance(body, list):
# فرمت قدیمی: آرایه رشته‌ای پاراگراف‌ها
return "\n".join(str(p).strip() for p in body if str(p).strip())
blocks = tiptap_blocks(body)
return "\n".join(b for b in blocks if b)
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
text = (text or "").strip()
if not text:
return None
return {"text": text, "type": dtype, "title": title, "url": url}
def _clean(value: Any) -> str:
return normalize(str(value or "")).strip()
def fetch_site_documents(database_url: str) -> list[dict]:
"""همه محتوای عمومی سایت را به سند تبدیل می‌کند (بدون وابستگی به ORM بک‌اند)."""
dsn = _psycopg_dsn(database_url)
docs: list[dict] = []
# --- برند و اطلاعات تماس (رکورد یگانه) ---
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
if brand_rows:
b = brand_rows[0]
contact = "\n".join(
filter(None, [
f"نام مجموعه: {_clean(b['name'])}",
f"تلفن تماس: {_clean(b['phone'])}",
f"ایمیل: {_clean(b['email'])}",
f"آدرس: {_clean(b['address'])}",
])
)
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
if d:
docs.append(d)
# --- دفاتر ---
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
d = _doc(
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
"office", f"دفتر {_clean(row['city'])}", "/contact",
)
if d:
docs.append(d)
# --- خدمات ---
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM services ORDER BY position'):
d = _doc(
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
"service", _clean(row["title"]), "/services",
)
if d:
docs.append(d)
# --- پرسش‌های متداول ---
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
d = _doc(
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
"faq", _clean(row["question"]), "/faq",
)
if d:
docs.append(d)
# --- پروژه‌ها ---
for row in _fetch_rows(
dsn,
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
):
meta = "\n".join(filter(None, [
f"مکان: {_clean(row['location'])}" if row["location"] else "",
f"سال: {_clean(row['year'])}" if row["year"] else "",
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیل‌شده'}",
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
]))
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
if d:
docs.append(d)
# --- مقاله‌ها (فقط منتشرشده) ---
for row in _fetch_rows(
dsn,
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
):
text = "\n".join(filter(None, [
f"دسته‌بندی: {_clean(row['category'])}" if row["category"] else "",
_clean(row["excerpt"]),
body_to_text(row["body"]),
]))
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
if d:
docs.append(d)
# --- مدل‌های سرمایه‌گذاری ---
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
d = _doc(
"\n".join(filter(None, [
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
f"نکات: {points}" if points else "",
])),
"investment", _clean(row["title"]), "/investment",
)
if d:
docs.append(d)
# --- مراحل همکاری ---
for row in _fetch_rows(dsn, 'SELECT step, title, "desc" FROM process_steps ORDER BY position'):
d = _doc(
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
"process", _clean(row["title"]), "/investment",
)
if d:
docs.append(d)
# --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما ---
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
d = _doc(
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
"team", _clean(row["name"]), "/about",
)
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
d = _doc(
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
"testimonial", _clean(row["name"]), "/",
)
if d:
docs.append(d)
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
d = _doc(
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
"timeline", _clean(row["title"]), "/about",
)
if d:
docs.append(d)
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
if story_rows and story_rows[0]:
s = story_rows[0]
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
if d:
docs.append(d)
log.info("Fetched %d documents from site database", len(docs))
return docs
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
"""متن‌های بلند (مقاله/پروژه) را به قطعات هم‌پوشان تقسیم می‌کند.
هر قطعه یک doc_id پایدار می‌گیرد («type:ترتیب در همان url:شماره قطعه») تا
همگام‌سازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
from .vectorstore import chunk_content_hash
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=["\n", "۔", ".", "،", " ", ""],
)
# شماره ردیف هر سند در میان سندهای هم‌type و هم-url (برای FAQs که url یکسان دارند)
occurrence: dict[str, int] = {}
out: list[dict] = []
for doc in docs:
key = f"{doc['type']}|{doc['url']}"
occ = occurrence.get(key, 0)
occurrence[key] = occ + 1
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
for i, piece in enumerate(pieces, 1):
chunk = {
"doc_id": f"{doc['type']}:{occ}:{i}",
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
"type": doc["type"],
"title": doc["title"],
"url": doc["url"],
}
chunk["content_hash"] = chunk_content_hash(chunk)
out.append(chunk)
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
return out
def content_fingerprint(docs: list[dict]) -> str:
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام می‌شود."""
import hashlib
import json
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
# --- زمینه همیشه‌تازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
_site_context_cache: tuple[float, str] | None = None
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
"""نام برند، تماس و فهرست خدمات — با کش کوتاه‌مدت تا هر پیام یک کوئری سبک بزند."""
global _site_context_cache
import time as _time
now = _time.time()
if _site_context_cache and now - _site_context_cache[0] < ttl:
return _site_context_cache[1]
try:
dsn = _psycopg_dsn(database_url)
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
lines: list[str] = []
if brand:
b = brand[0]
lines += [
f"نام مجموعه: {_clean(b['name'])}",
f"تلفن تماس: {_clean(b['phone'])}",
f"ایمیل: {_clean(b['email'])}",
f"آدرس: {_clean(b['address'])}",
]
if services:
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
ctx = "\n".join(lines)
_site_context_cache = (now, ctx)
return ctx
except Exception as e:
log.warning("Site context fetch failed: %s", e)
return _site_context_cache[1] if _site_context_cache else ""
+20
View File
@@ -0,0 +1,20 @@
"""مدل امبدینگ — با لود تنبل تا اولین استفاده (برای تست‌های سبک، torch لود نمی‌شود)."""
import os
from functools import lru_cache
from .config import settings
@lru_cache(maxsize=1)
def get_embeddings():
from langchain_huggingface import HuggingFaceEmbeddings
os.makedirs(settings.hf_cache_dir, exist_ok=True)
return HuggingFaceEmbeddings(
model_name=settings.embedding_model,
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": False},
cache_folder=settings.hf_cache_dir,
)
+55
View File
@@ -0,0 +1,55 @@
"""ایندکس ترکیبی روی pgvector + BM25 در حافظه.
بردارها در pgvector (دیتابیس اختصاصی چت‌بات) به‌صورت افزایشی همگام می‌شوند —
فقط سطرهای جدید یا تغییر یافته امبد می‌شوند. BM25 چون سبک است در هر شروع
از روی همان سطرها ساخته می‌شود و نیازی به کش دیسکی ندارد.
"""
import logging
import time
from rank_bm25 import BM25Okapi
from . import content as content_mod
from .config import settings
from .persian import tokenize
from . import vectorstore
log = logging.getLogger(__name__)
class SiteIndex:
"""حالت در حافظهٔ بازیابی — سندها (برای BM25) و وضعیت آماده بودن."""
def __init__(self) -> None:
self.docs: list[dict] = []
self.bm25: BM25Okapi | None = None
@property
def ready(self) -> bool:
return self.bm25 is not None and bool(self.docs)
INDEX = SiteIndex()
def sync_index(force_rebuild: bool = False) -> dict:
"""همگام‌سازی کامل: محتوا → قطعه → pgvector (افزایشی) → ساخت BM25.
خروجی: آمار همگام‌سازی. خطای اتصال به دیتابیس بالا فرستاده می‌شود.
"""
start = time.time()
vectorstore.ensure_schema()
docs = content_mod.fetch_site_documents(settings.database_url)
chunks = content_mod.chunk_documents(docs)
stats = vectorstore.sync_chunks(chunks, force=force_rebuild)
INDEX.docs = vectorstore.fetch_all_chunks()
corpus_tokens = [tokenize(d["text"]) for d in INDEX.docs]
INDEX.bm25 = BM25Okapi(corpus_tokens) if corpus_tokens else None
log.info("Index ready in %.1fs (%d vectors, %d docs, %s)",
time.time() - start, len(INDEX.docs), len(chunks), stats)
return stats
+79
View File
@@ -0,0 +1,79 @@
"""نرمال‌سازی متن فارسی و توکن‌سازی برای جست‌وجوی کلیدواژه (BM25).
بدون وابستگی سنگین — فقط استانداردسازی نویسه‌ها، حذف اعراب و نیم‌فاصله‌های
مشکلی و شکستن کلمه‌های مرکب روی نیم‌فاصله برای بهبود بازیابی.
"""
import re
# نویسه‌های عربی که در متن فارسی اشتباهاً استفاده می‌شوند
_CHAR_MAP = {
"\u064a": "\u06cc", # ي → ی
"\u0643": "\u06a9", # ك → ک
"\u0623": "\u0627", # أ → ا
"\u0625": "\u0627", # إ → ا
"\u0671": "\u0627", # ٱ → ا
"\u0629": "\u0647", # ة → ه
"\u0624": "\u0648", # ؤ → و
"\u0626": "\u06cc", # ئ → ی
"\u0640": "", # ـ (کشیده) حذف
}
# اعراب و علامت‌های اضافی
_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655\u0640]")
# ارقام فارسی و عربی → ASCII
_DIGITS = str.maketrans({
"\u06f0": "0", "\u06f1": "1", "\u06f2": "2", "\u06f3": "3", "\u06f4": "4",
"\u06f5": "5", "\u06f6": "6", "\u06f7": "7", "\u06f8": "8", "\u06f9": "9",
"\u0660": "0", "\u0661": "1", "\u0662": "2", "\u0663": "3", "\u0664": "4",
"\u0665": "5", "\u0666": "6", "\u0667": "7", "\u0668": "8", "\u0669": "9",
})
# واژه‌های دستوری پرتکرار — در جست‌وجو بی‌معنا هستند
STOPWORDS = frozenset("""
و در به از که با این آن است هست بود بودن شد شدن شود کرد کردن کرده کند کنی
برای را های ها هایی هم نیز اما یا اگر تا بر روی زیر بین پس قبل بعد همه هر چه
چیزی کس کسی کجا چرا چگونه چطور چند یکی دو سه ما شما آنها ایشان من تو او
کدام کی چی چه‌قدر چقدر
می نخواهد خواهد باشد باشند هستیم هستند بودم بودیم دارد دارند داشته داد دهد
بله خیر آیا فقط نیز خیلی بسیار کمی دیگر دیگری همان همین وقتی زمانی وقتیکه
اساساً لطفاً میشه میکنه هستن هستم
""".split())
# علائم نگارشی فارسی/لاتین — قبل از توکن‌سازی حذف می‌شوند (نیم‌فاصله جزو اینها نیست)
_PUNCT_RE = re.compile(r"[،؛؟!,;:\?\"'()\[\]{}«»….*#@&%+=/\\|~^$\u066d\u066c\u066b]+")
# توکن: حروف/ارقام فارسی و لاتین + نیم‌فاصله در میان کلمه
_TOKEN_RE = re.compile(r"[\w\u0600-\u06ff\u200c]+")
def normalize(text: str) -> str:
"""نویسه‌های غیراستاندارد عربی/فارسی را به شکل یکدست فارسی می‌برد."""
if not text:
return ""
out = []
for ch in text:
out.append(_CHAR_MAP.get(ch, ch))
text = "".join(out)
text = _DIACRITICS.sub("", text)
text = text.translate(_DIGITS)
# فاصله‌های تکراری را یکی کن — نیم‌فاصله عمداً حفظ می‌شود (می‌شود)
text = re.sub(r"[ \t\r\n]+", " ", text)
return text.strip()
def tokenize(text: str) -> list[str]:
"""توکن‌های نرمال‌شده برای BM25 — کلمه‌های مرکب روی نیم‌فاصله هم جدا ایندکس می‌شوند."""
text = _PUNCT_RE.sub(" ", normalize(text).lower())
tokens = []
for raw in _TOKEN_RE.findall(text):
if raw in STOPWORDS or len(raw) < 2:
continue
tokens.append(raw)
# «می‌شود» → می + شود (فراخوانی بیشتر برای بازیابی)
if "\u200c" in raw:
for part in raw.split("\u200c"):
if len(part) >= 2 and part not in STOPWORDS:
tokens.append(part)
return tokens
+191
View File
@@ -0,0 +1,191 @@
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفه‌جویی در تأخیر)
۲) بازیابی ترکیبی با چند نوع‌واژه (اختیاری)
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
"""
import logging
import re
import json as _json
from typing import Annotated, List, Optional
from typing_extensions import TypedDict
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from . import content as content_mod
from .config import settings
from .retrieval import retrieve_final
log = logging.getLogger(__name__)
FALLBACK_MESSAGE = (
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
"برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید."
)
LLM_UNAVAILABLE = (
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چت‌بات تنظیم کنید."
)
_llm = None
def get_llm() -> ChatOpenAI | None:
"""LLM تولید — اگر پیکربندی نشده باشد None برمی‌گرداند."""
global _llm
if _llm is None and settings.llm_base_url:
_llm = ChatOpenAI(
model=settings.llm_model,
base_url=settings.llm_base_url,
api_key=settings.llm_api_key or "not-needed",
temperature=0,
streaming=True,
)
return _llm
class GraphState(TypedDict):
messages: Annotated[List, add_messages]
documents: List[str]
contextualized_query: Optional[str]
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
def contextualize_query(question: str, history: list) -> str:
"""ضمیرها و اشاره‌های پرسش را با تاریخچه گفتگو مشخص می‌کند («اون دومی» → موضوع واقعی)."""
model = get_llm()
if model is None or not history:
return question
try:
history_text = "\n".join(
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
for m in history[-6:]
)
prompt = (
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
"- ضمیرها و اشاره‌ها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
"- فقط متن پرسش بازنویسی‌شده را برگردان؛ هیچ توضیحی ننویس.\n\n"
f"تاریخچه گفتگو:\n{history_text}\n\n"
f"پرسش کاربر: {question}\n\n"
"پرسش مستقل:"
)
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
if not rewritten or len(rewritten) > len(question) * 4:
return question
if rewritten != question:
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
return rewritten
except Exception as e:
log.warning("Contextualization failed: %s", e)
return question
def expand_query(query: str) -> list[str]:
"""گسترش پرسش به چند نوع‌واژه (اختیاری — پیش‌فرض خاموش برای پیکره کوچک)."""
model = get_llm()
if model is None:
return [query]
try:
prompt = (
"برای جست‌وجو در محتوای سایت یک گروه ساختمانی، ۳ نوع‌واژه جایگزین برای این پرسش بساز: "
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
f"پرسش: {query}"
)
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
text = resp.content if isinstance(resp.content, str) else ""
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
if match:
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
if variants:
log.info("Query variants: %s", [query] + variants)
return [query] + variants
except Exception as e:
log.warning("Query expansion failed: %s", e)
return [query]
# --- گام ۲: بازیابی ---
def retrieve_node(state: GraphState) -> dict:
messages = state.get("messages", [])
if not messages:
return {"documents": []}
question = messages[-1].content
history = messages[:-1]
query = contextualize_query(question, history) if settings.contextualize_enabled else question
variants = expand_query(query) if settings.query_expansion else [query]
docs = retrieve_final(query, variants)
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
return {
"documents": doc_texts,
"contextualized_query": query if query != question else None,
}
# --- گام ۳: تولید پاسخ ---
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
reminder = (
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
if contextualized_query else ""
)
return (
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ می‌دهی.\n"
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
f"--- اسناد بازیابی‌شده مرتبط با پرسش ---\n{docs_block}\n"
f"{reminder}"
"--- قوانین پاسخ ---\n"
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ به‌هیچ‌وجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفه‌ای و محترمانه داشته باشد.\n"
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در داده‌ها آمده است."
)
def generate_node(state: GraphState) -> dict:
messages = state.get("messages", [])
question = messages[-1].content if messages else ""
model = get_llm()
if model is None:
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
documents = state.get("documents") or []
system = build_system_prompt(documents, state.get("contextualized_query"))
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
history = messages[:-1]
reply = model.invoke(
[SystemMessage(content=system), *history, HumanMessage(content=question)],
config={"tags": ["generate"]},
)
return {"messages": [reply]}
# --- گراف ---
memory = MemorySaver()
workflow = StateGraph(GraphState)
workflow.add_node("retrieve", retrieve_node)
workflow.add_node("generate", generate_node)
workflow.set_entry_point("retrieve")
workflow.add_edge("retrieve", "generate")
workflow.add_edge("generate", END)
graph = workflow.compile(checkpointer=memory)
+20
View File
@@ -0,0 +1,20 @@
# سرویس چت‌بات — بازیابی ترکیبی روی محتوای سایت
fastapi>=0.115
uvicorn[standard]>=0.30
pydantic-settings>=2.3
# گراف گفتگو و LLM
langgraph>=0.2.60
langchain>=0.3
langchain-openai>=0.2
langchain-text-splitters>=0.3
# بازیابی — بردارها در pgvector می‌روند؛ BM25 و خوشه‌بندی در حافظه
sentence-transformers>=3.0
langchain-huggingface>=0.1
rank-bm25>=0.2.2
scikit-learn>=1.4
numpy>=1.26
# دیتابیس (فقط خواندن)
psycopg[binary]>=3.1
+50
View File
@@ -0,0 +1,50 @@
"""مدل ری‌رنکر BGE — با لود تنبل و امتیازدهی دسته‌ای."""
import logging
import os
from functools import lru_cache
from .config import settings
log = logging.getLogger(__name__)
class _CrossEncoder:
"""غلاف سبک روی AutoModelForSequenceClassification — API شبیه rank_bm25."""
def __init__(self) -> None:
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
os.makedirs(settings.hf_cache_dir, exist_ok=True)
self._torch = torch
self.tokenizer = AutoTokenizer.from_pretrained(settings.reranker_model, cache_dir=settings.hf_cache_dir)
self.model = AutoModelForSequenceClassification.from_pretrained(
settings.reranker_model, cache_dir=settings.hf_cache_dir
)
device = settings.reranker_device if settings.reranker_device == "cuda" and torch.cuda.is_available() else "cpu"
self.model.to(device)
self.model.eval()
self.device = device
log.info("Reranker loaded on %s", device)
def score(self, pairs: list[list[str]], batch_size: int = 16) -> list[float]:
torch = self._torch
out: list[float] = []
with torch.no_grad():
for i in range(0, len(pairs), batch_size):
inputs = self.tokenizer(
pairs[i:i + batch_size],
padding=True,
truncation=True,
max_length=512,
return_tensors="pt",
).to(self.device)
logits = self.model(**inputs).logits.squeeze(-1)
out.extend(logits.cpu().tolist())
return out
@lru_cache(maxsize=1)
def get_reranker() -> _CrossEncoder:
return _CrossEncoder()
+165
View File
@@ -0,0 +1,165 @@
"""بازیابی ترکیبی — پورت خط لوله v13 با سه تغییر برای این سایت:
۱) پایگاه sparse به‌جای SPLADE (انگلیسی‌محور و بی‌فایده روی فارسی) از BM25
با نرمال‌سازی فارسی استفاده می‌کند.
۲) جست‌وجوی معنایی به‌جای FAISS با pgvector و فاصله کسینوسی در SQL انجام
می‌شود (همگام‌سازی افزایشی، فیلترپذیر، بدون فایل ایندکس جدا).
۳) مسیریابی alpha با LLM حذف شده و وزن ثابت تنظیم‌پذیر است — برای پیکرهٔ
کوچک سایت، هزینهٔ یک فراخوانی LLM اضافی به‌ازای هر پیام نمی‌ارزد.
بقیهٔ منطق (نرمال‌سازی نمره، فیوژن وزن‌دار، برش خوشه‌بندی، ری‌رنک) عین v13 است.
"""
import logging
import time
from . import vectorstore
from .config import settings
from .indexing import INDEX
from .persian import tokenize
log = logging.getLogger(__name__)
def dense_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی معنایی pgvector — خروجی: (سند، شباهت کسینوسی)."""
return vectorstore.dense_search(query, top_k)
def keyword_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی کلیدواژه BM25 روی متن نرمال‌شده فارسی."""
if INDEX.bm25 is None:
return []
try:
tokens = tokenize(query)
if not tokens:
return []
scores = INDEX.bm25.get_scores(tokens)
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
return [(INDEX.docs[i], float(scores[i])) for i in ranked if scores[i] > 0]
except Exception as e:
log.error("BM25 retrieval failed: %s", e)
return []
def _minmax(scores: list[float]) -> list[float]:
if not scores:
return []
lo, hi = min(scores), max(scores)
if hi == lo:
return [0.5] * len(scores)
return [(s - lo) / (hi - lo) for s in scores]
def hybrid_retrieve(query: str, alpha: float | None = None, top_k: int | None = None) -> list[tuple[dict, float]]:
"""ترکیب وزن‌دار نمره معنایی و کلیدواژه؛ خروجی مرتب‌شده بر اساس نمره ترکیبی."""
alpha = settings.alpha_dense if alpha is None else alpha
top_k = top_k or settings.retrieve_k
dense = dense_retrieve(query, top_k)
keyword = keyword_retrieve(query, top_k)
log.info("Retrieval '%s': dense=%d, keyword=%d", query[:60], len(dense), len(keyword))
if not dense and not keyword:
return []
dense_norm = _minmax([s for _, s in dense])
keyword_norm = _minmax([s for _, s in keyword])
pool: dict = {} # doc_id → {doc, dense_max, kw_max}
for (doc, _), norm in zip(dense, dense_norm):
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
entry["dense_max"] = max(entry["dense_max"], norm)
for (doc, _), norm in zip(keyword, keyword_norm):
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
entry["kw_max"] = max(entry["kw_max"], norm)
combined = [
(entry["doc"], alpha * entry["dense_max"] + (1 - alpha) * entry["kw_max"])
for entry in pool.values()
]
combined.sort(key=lambda x: x[1], reverse=True)
return combined
def cluster_cutoff(scored: list[tuple[dict, float]], silhouette_threshold: float = 0.55) -> list[tuple[dict, float]]:
"""برش خوشه‌ای v13 — مرز طبیعی کیفیت به‌جای top-k دلخواه.
روی نمره‌های ۱بعدی K-Means با K=2 می‌زند؛ اگر جدایی خوشه‌ها معنادار
باشد (silhouette ≥ 0.55) فقط خوشهٔ بالا را نگه می‌دارد.
"""
import numpy as np
n = len(scored)
if n < 4:
return scored
try:
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
scores = np.array([s for _, s in scored], dtype=float).reshape(-1, 1)
kmeans = KMeans(n_clusters=2, random_state=42, n_init=10)
labels = kmeans.fit_predict(scores)
sil = silhouette_score(scores, labels)
if sil < silhouette_threshold:
log.info("Clustering: silhouette=%.3f < %.2f — keeping all %d docs", sil, silhouette_threshold, n)
return scored
centers = kmeans.cluster_centers_.flatten()
good_label = int(np.argmax(centers))
cutoff = int(np.sum(labels == good_label))
cutoff = max(cutoff, 1)
log.info("Clustering: silhouette=%.3f — cutting at %d/%d docs", sil, cutoff, n)
return scored[:cutoff]
except Exception as e:
log.warning("Clustering failed (%s) — keeping all docs", e)
return scored
def rerank(query: str, scored: list[tuple[dict, float]], top_k: int) -> list[tuple[dict, float]]:
"""ری‌رنک کراس‌انکودر BGE — مرتب‌سازی نهایی بر اساس شباهت واقعی پرسش/سند."""
if not scored:
return []
if not settings.reranker_enabled:
return scored[:top_k]
try:
from .reranker import get_reranker
start = time.time()
pairs = [[query, doc["text"]] for doc, _ in scored]
scores = get_reranker().score(pairs)
rescored = sorted(
((doc, float(s)) for (doc, _), s in zip(scored, scores)),
key=lambda x: x[1],
reverse=True,
)
log.info("Reranked %d docs in %.2fs — top scores: %s",
len(scored), time.time() - start,
[f"{s:.2f}" for _, s in rescored[:3]])
return rescored[:top_k]
except Exception as e:
log.error("Reranking failed (%s) — falling back to fusion order", e)
return scored[:top_k]
def retrieve_final(query: str, query_variants: list[str] | None = None) -> list[dict]:
"""خط کامل بازیابی برای یک پرسش: هر نوع → فیوژن → برش خوشه → ری‌رنک → K نهایی."""
variants = query_variants or [query]
pool: dict = {}
for variant in variants:
for doc, score in hybrid_retrieve(variant):
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "score": 0.0})
entry["score"] = max(entry["score"], score)
combined = sorted(((e["doc"], e["score"]) for e in pool.values()),
key=lambda x: x[1], reverse=True)
if not combined:
return []
filtered = cluster_cutoff(combined)
# ری‌رنک روی نتیجه‌های برش‌خورده (سقف retrieve_k تا تأخیر کنترل شود)
rerank_candidates = filtered[: settings.retrieve_k]
final = rerank(query, rerank_candidates, settings.best_of_k)
log.info("Final retrieval: %d docs (from %d candidates, %d variants)",
len(final), len(combined), len(variants))
return [doc for doc, _ in final]
+70
View File
@@ -0,0 +1,70 @@
"""تست‌های خالص (بدون مدل/دیتابیس) برای ماژول‌های چت‌بات — اجرا: python test_pure.py"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
from chatbot.persian import normalize, tokenize
docs = [
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
]
chunks = chunk_documents(docs)
ids = [c["doc_id"] for c in chunks]
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
art = [c for c in chunks if c["type"] == "article"]
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
c = chunks[0]
assert c["content_hash"] == chunk_content_hash(c)
c2 = dict(c, title="عنوان عوض شد")
# hash فیلد کپی‌شده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
assert chunk_content_hash(c2) != c["content_hash"]
chunks2 = chunk_documents(docs)
assert [x["doc_id"] for x in chunks2] == ids
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
settings_dsn = chatbot_dsn()
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
assert "+psycopg" not in settings_dsn
# نرمال‌سازی و توکن‌سازی فارسی
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
# استخراج متن TipTap و فرمت قدیمی
tiptap_doc = {
"type": "doc",
"content": [
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
{"type": "paragraph", "content": [
{"type": "text", "text": "این برج در تهران "},
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
{"type": "hardBreak"},
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
]},
{"type": "bulletList", "content": [
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
]},
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
],
}
blocks = tiptap_blocks(tiptap_doc)
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
assert body_to_text(["الف", "ب"]) == "الف\nب"
assert body_to_text(None) == ""
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
+217
View File
@@ -0,0 +1,217 @@
"""ذخیره‌ساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
نگهداری می‌شوند تا دیتابیس سایت فقط‌خواندنی بماند. همگام‌سازی افزایشی است:
فقط سطرهای جدید/تغییریافته امبد می‌شوند (کلید: doc_id پایدار + hash محتوا).
"""
import hashlib
import json
import logging
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from .config import settings
log = logging.getLogger(__name__)
def _strip_driver(dsn: str) -> str:
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
def chatbot_dsn() -> str:
"""DSN دیتابیس اختصاصی چت‌بات — از DATABASE_URL با تعویض نام دیتابیس ساخته می‌شود."""
parts = urlsplit(_strip_driver(settings.database_url))
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
def _admin_dsn() -> str:
"""DSN دیتابیس postgres برای ساخت دیتابیس چت‌بات (فقط یک‌بار)."""
parts = urlsplit(_strip_driver(settings.database_url))
return urlunsplit(parts._replace(path="/postgres"))
def ensure_schema() -> None:
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا می‌شود."""
import psycopg
from psycopg import errors as pg_errors
try:
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
try:
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
except pg_errors.DuplicateDatabase:
pass
except Exception as e:
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS chatbot_chunks (
doc_id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
model TEXT NOT NULL,
content JSONB NOT NULL,
embedding vector({settings.embedding_dim}) NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
"""
)
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
"""همگام‌سازی افزایشی قطعات با جدول pgvector.
- سطرهایی که دیگر وجود ندارند حذف می‌شوند
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert می‌شوند
- force=True همه سطرها را از نو امبد می‌کند
خروجی: آمار عملیات برای لاگ.
"""
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .embeddings import get_embeddings
model = settings.embedding_model
wanted = {c["doc_id"]: c for c in chunks}
# row_factory لازم است: پایین‌تر row["model"]/row["doc_id"] خوانده می‌شود و
# پیش‌فرض psycopg3 تاپل برمی‌گرداند (خطای tuple indices must be integers).
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
row_factory=dict_row) as conn:
existing: dict[str, str] = {}
if not force:
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
if row["model"] == model:
existing[row["doc_id"]] = row["content_hash"]
else:
conn.execute("DELETE FROM chatbot_chunks")
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
if to_delete:
conn.execute(
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
[to_delete],
)
to_upsert = [
c for c in chunks
if existing.get(c["doc_id"]) != c["content_hash"]
]
inserted = updated = 0
if to_upsert:
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
for chunk, vec in zip(to_upsert, vectors):
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
conn.execute(
"""
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
VALUES (%s, %s, %s, %s, %s::vector)
ON CONFLICT (doc_id) DO UPDATE SET
content_hash = EXCLUDED.content_hash,
model = EXCLUDED.model,
content = EXCLUDED.content,
embedding = EXCLUDED.embedding
""",
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
)
if chunk["doc_id"] in existing:
updated += 1
else:
inserted += 1
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
"unchanged": len(wanted) - len(to_upsert)}
log.info("Vector sync: %s", stats)
return stats
def fetch_all_chunks() -> list[dict]:
"""همه قطعات ذخیره‌شده (برای ساخت BM25 در حافظه)."""
import psycopg
from psycopg.rows import dict_row
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
row_factory=dict_row) as conn:
rows = conn.execute(
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
).fetchall()
return [
{
"doc_id": r["doc_id"],
"content_hash": r["content_hash"],
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
}
for r in rows
]
def vector_count() -> int:
"""تعداد بردارهای ذخیره‌شده — برای healthz (خطا → ۰)."""
try:
import psycopg
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
except Exception:
return 0
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
"""
try:
import psycopg
from psycopg.rows import dict_row as _dict_row
from .embeddings import get_embeddings
vec = get_embeddings().embed_query(query)
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
rows = conn.execute(
f"""
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
FROM chatbot_chunks
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(literal, literal, top_k),
).fetchall()
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
except Exception as e:
log.warning("Dense search failed (%s) — falling back to keyword only", e)
return []
def _row_to_doc(row: Any) -> dict:
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
return {
"doc_id": row["doc_id"],
"text": content.get("text", ""),
"type": content.get("type", ""),
"title": content.get("title", ""),
"url": content.get("url", ""),
}
def chunk_content_hash(chunk: dict) -> str:
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید به‌روز شود."""
payload = json.dumps(
{k: chunk[k] for k in ("text", "type", "title", "url")},
ensure_ascii=False, sort_keys=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
+189
View File
@@ -0,0 +1,189 @@
# لایه مانیتورینگ — Prometheus، Grafana، Loki، Alertmanager و صادرکننده‌ها
#
# توسعه: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
# تولید: سه فایل با هم: base + monitoring + prod (بخش مانیتورینگ README)
#
# داشبورد: grafana.localhost (توسعه) — رمز از GRAFANA_ADMIN_PASSWORD در .env
# هشدارها: Prometheus → Alertmanager → تلگرام (ALERT_TELEGRAM_* در .env)
# منابع: مجموعاً حدود ۱ تا ۱.۵ گیگابایت RAM — با حذف هر سرویس، بقیه سرپا می‌مانند.
x-default-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
prometheus:
image: prom/prometheus:v2.53.0
restart: unless-stopped
logging: *default-logging
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --storage.tsdb.retention.size=2GB
volumes:
- ./monitoring/prometheus:/etc/prometheus:ro
- prometheus_data:/prometheus
networks: [monitoring, edge]
environment:
# پروب عمومی — در تولید SITE_URL در .env تنظیم می‌شود (مثل https://aramesh.example)
SITE_URL: ${SITE_URL:-http://frontend/}
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 5s
retries: 5
start_period: 15s
deploy:
resources:
limits:
memory: 768M
alertmanager:
image: prom/alertmanager:v0.27.0
restart: unless-stopped
logging: *default-logging
command:
- --config.file=/etc/alertmanager/alertmanager.rendered.yml
- --storage.path=/alertmanager
# فایل رندرشدهٔ alertmanager.rendered.yml را scripts/stack.sh از قالب
# alertmanager.yml با مقادیر .env (ALERT_BOT_TOKEN/ALERT_CHAT_ID) می‌سازد —
# Alertmanager خودش متغیر محیطی را در کانفیگ جایگزین نمی‌کند
volumes:
- ./monitoring/alertmanager:/etc/alertmanager:ro
- alertmanager_data:/alertmanager
networks: [monitoring]
deploy:
resources:
limits:
memory: 128M
# منابع هر کانتینر — روی Linux سرور کامل کار می‌کند؛ روی Docker Desktop ویندوز
# ممکن است محدود باشد (حذفش بقیه استک را نمی‌شکند)
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
restart: unless-stopped
logging: *default-logging
privileged: true
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
networks: [monitoring]
deploy:
resources:
limits:
memory: 192M
# منابع میزبان + سنجهٔ تازگی پشتیبان (backups/*.prom نوشتهٔ scripts/backup.sh)
node-exporter:
image: quay.io/prometheus/node-exporter:v1.8.2
restart: unless-stopped
logging: *default-logging
command:
- --path.rootfs=/host
- --collector.textfile.directory=/textfile
volumes:
- /:/host:ro
- ./backups:/textfile:ro
networks: [monitoring]
deploy:
resources:
limits:
memory: 64M
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
restart: unless-stopped
logging: *default-logging
environment:
DATA_SOURCE_NAME: postgresql://aramesh:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}@db:5432/arameshplaza?sslmode=disable
networks: [internal, monitoring]
deploy:
resources:
limits:
memory: 64M
blackbox-exporter:
image: prom/blackbox-exporter:v0.25.0
restart: unless-stopped
logging: *default-logging
# edge برای رسیدن به سرویس‌هایی که پروب می‌شوند (frontend/minio/umami/chatbot/backend)
networks: [monitoring, edge]
deploy:
resources:
limits:
memory: 64M
# لاگ همهٔ کانتینرها — جست‌وجو در Grafana؛ خطاهای بک‌اند از همین‌جا دیده می‌شوند
loki:
image: grafana/loki:3.1.1
restart: unless-stopped
logging: *default-logging
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./monitoring/loki:/etc/loki:ro
- loki_data:/loki
networks: [monitoring]
deploy:
resources:
limits:
memory: 512M
promtail:
image: grafana/promtail:3.1.1
restart: unless-stopped
logging: *default-logging
command: -config.file=/etc/promtail/promtail-config.yml
volumes:
- ./monitoring/promtail:/etc/promtail:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- promtail_positions:/positions
networks: [monitoring]
depends_on: [loki]
deploy:
resources:
limits:
memory: 128M
grafana:
image: grafana/grafana:11.1.0
restart: unless-stopped
logging: *default-logging
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD را در .env تنظیم کنید}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
networks: [monitoring, edge]
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || wget -q --spider http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
labels:
- traefik.enable=true
- traefik.http.routers.grafana.rule=Host(`grafana.localhost`)
- traefik.http.routers.grafana.entrypoints=web
- traefik.http.services.grafana.loadbalancer.server.port=3000
deploy:
resources:
limits:
memory: 384M
networks:
monitoring:
volumes:
prometheus_data:
alertmanager_data:
loki_data:
grafana_data:
promtail_positions:
+98 -16
View File
@@ -1,15 +1,28 @@
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۴۳. # لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۳.
# پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور. # پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور.
# #
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # bash scripts/stack.sh deploy-prod
# (معادل: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build)
# #
# همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند. # همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند.
# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
#
# ---- پیش‌نمایش خصوصی و انتشار (scripts/preview-mode.sh) ----
# در حالت پیش‌نمایش (PREVIEW_MODE=on) کل سایت پشت Basic Auth است و noindex می‌گیرد:
# تیم با یک نام کاربری/رمز مشترک وارد می‌شود، محتوا را ویرایش می‌کند و هیچ‌کس دیگری —
# از جمله گوگل — به سایت دسترسی ندارد. برای انتشار عمومی:
# bash scripts/preview-mode.sh off → deploy مجدد → ثبت sitemap در Search Console.
#
# DOMAIN دامنه اصلی است و DOMAIN_ALIAS دامنه دوم (هر دو مسیر و گواهی می‌گیرند).
# در زمان انتشار، برای جلوگیری از محتوای تکراری، دامنه دوم را به اصلی ریدایرکت کنید:
# ALIAS_REDIRECT=on (در .env) — ریدایرکت ۳۰۱ دائمی به دامنه اصلی.
services: services:
traefik: traefik:
command: !override command: !override
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https - --entrypoints.web.http.redirections.entrypoint.scheme=https
@@ -18,6 +31,9 @@ services:
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true - --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
- --ping=true
- --accesslog=true
- --metrics.prometheus=true
ports: !override ports: !override
- "80:80" - "80:80"
- "443:443" - "443:443"
@@ -25,26 +41,40 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt - letsencrypt_data:/letsencrypt
backend: # MinIO کنسول (رمز root) و API مستقیمش فقط از localhost سرور در دسترس باشند —
environment: # از راه دور با تونل SSH: ssh -L 9001:127.0.0.1:9001 server
# سایت هم‌مبدأ است — CORS لازم نیست
CORS_ORIGINS: '[]'
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
- traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le
minio: minio:
ports: !override
- "127.0.0.1:9001:9001"
- "127.0.0.1:19000:9000"
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`) - traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=websecure - traefik.http.routers.media.entrypoints=websecure
- traefik.http.routers.media.tls.certresolver=le - traefik.http.routers.media.tls.certresolver=le
- traefik.http.routers.media.priority=100
- traefik.http.routers.media.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}media-rewrite,sec-headers
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
- traefik.http.services.media.loadbalancer.server.port=9000
backend:
environment:
# سایت هم‌مبدأ است — CORS لازم نیست
CORS_ORIGINS: '[]'
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
# پیش‌نمایش خصوصی — هدر noindex + robots مسدودکننده (لایه دوم؛ لایه اول گیت Traefik)
PREVIEW_MODE: ${PREVIEW_MODE:-off}
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
- traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
frontend: frontend:
labels: labels:
@@ -52,6 +82,58 @@ services:
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`) - traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=websecure - traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls.certresolver=le - traefik.http.routers.web.tls.certresolver=le
- traefik.http.routers.web.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
# تعریف یک‌بارهٔ میان‌افزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده می‌شود
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
# ---- گیت پیش‌نمایش خصوصی ----
# ورود با نام کاربری/رمز مشترک (htpasswd) — تا وقتی PREVIEW_MIDDLEWARES تنظیم است فعال می‌شود
- traefik.http.middlewares.preview-auth.basicauth.users=${PREVIEW_AUTH_USERS:-disabled:nope}
- traefik.http.middlewares.preview-auth.basicauth.realm=Aramesh private preview
# لایه دوم جلوگیری از ایندکس — حتی اگر گیت حذف شد، گوگل چیزی ذخیره نکند
- traefik.http.middlewares.preview-noindex.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow, noarchive
# ---- ریدایرکت دامنه دوم به اصلی (زمان انتشار: ALIAS_REDIRECT=on) ----
- traefik.http.middlewares.to-primary.redirectregex.regex=^https?://[^/]+(.*)
- traefik.http.middlewares.to-primary.redirectregex.replacement=https://${DOMAIN}$$1
- traefik.http.middlewares.to-primary.redirectregex.permanent=true
- traefik.http.routers.alias-redirect.rule=Host(`${DOMAIN_ALIAS:-invalid.invalid}`)
- traefik.http.routers.alias-redirect.entrypoints=websecure
- traefik.http.routers.alias-redirect.tls.certresolver=le
- traefik.http.routers.alias-redirect.priority=1000
- traefik.http.routers.alias-redirect.middlewares=${ALIAS_REDIRECT_MIDDLEWARES:-sec-headers}
- traefik.http.routers.alias-redirect.service=web
chatbot:
labels:
- traefik.enable=true
- traefik.http.routers.chat.rule=Host(`${DOMAIN}`) && PathPrefix(`/chat`)
- traefik.http.routers.chat.entrypoints=websecure
- traefik.http.routers.chat.tls.certresolver=le
- traefik.http.routers.chat.priority=100
- traefik.http.routers.chat.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
umami:
labels:
- traefik.enable=true
- traefik.http.routers.analytics.rule=Host(`analytics.${DOMAIN}`)
- traefik.http.routers.analytics.entrypoints=websecure
- traefik.http.routers.analytics.tls.certresolver=le
- traefik.http.routers.analytics.middlewares=sec-headers
- traefik.http.services.analytics.loadbalancer.server.port=3000
# داشبورد Grafana (docker-compose.monitoring.yml) در تولید با TLS نیاز به روتر
# Host(`grafana.DOMAIN`) دارد — چون آن سرویس فقط با فایل monitoring بالا می‌آید،
# برچسب‌هایش را در override سرور بگذارید (README، بخش مانیتورینگ):
# docker-compose.override.yml روی سرور:
# services:
# grafana:
# labels:
# - traefik.http.routers.grafana.rule=Host(`grafana.YOURDOMAIN`)
# - traefik.http.routers.grafana.entrypoints=websecure
# - traefik.http.routers.grafana.tls.certresolver=le
# - traefik.http.services.grafana.loadbalancer.server.port=3000
volumes: volumes:
letsencrypt_data: letsencrypt_data:
+230 -20
View File
@@ -1,71 +1,121 @@
# مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید: # مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید:
# cp .env.example .env # cp .env.example .env (یا: bash scripts/gen-env.sh)
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README). # برای انتشار با TLS، روی این فایل docker-compose.prod.yml هم سوار شود (README):
# bash scripts/stack.sh deploy-prod
#
# لایه‌های اختیاری:
# docker-compose.monitoring.yml — Prometheus/Grafana/Loki (پروفایل زیرساخت)
# پروفایل ops — پشتیبان ساعتی دیتابیس
# پروفایل tools — ابزار mc برای پشتیبان‌گیری دستی
x-default-logging: &default-logging
# بدون این، لاگ json-file بی‌مرز رشد می‌کند و دیسک سرور را پر می‌کند
driver: json-file
options:
max-size: "10m"
max-file: "3"
services: services:
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO) # نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند)، /media (MinIO)،
# /chat (چت‌بات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
traefik: traefik:
image: traefik:v3 # v3.3+ قدیمی‌ترین کلاینت داکر را دارد که با Engineهای جدید (API 1.5x) مشکل دارد —
# با v3.6 پین شده است
image: traefik:v3.6
restart: unless-stopped restart: unless-stopped
logging: *default-logging
command: command:
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
# فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید # فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`) - --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
# healthcheck داخلی + متریک‌ها برای Prometheus + لاگ دسترسی
- --ping=true
- --accesslog=true
- --metrics.prometheus=true
ports: ports:
- "${TRAEFIK_HTTP_PORT:-5173}:80" - "${TRAEFIK_HTTP_PORT:-5173}:80"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
networks: [edge]
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
memory: 256M
db: db:
image: postgres:17-alpine # pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چت‌بات
image: pgvector/pgvector:pg17
restart: unless-stopped restart: unless-stopped
logging: *default-logging
environment: environment:
POSTGRES_USER: aramesh POSTGRES_USER: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
POSTGRES_DB: arameshplaza POSTGRES_DB: arameshplaza
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
shm_size: 256m
networks: [internal]
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"] test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 10s
deploy:
resources:
limits:
memory: 512M
minio: minio:
image: minio/minio:latest image: minio/minio:RELEASE.2025-04-22T22-12-26Z
restart: unless-stopped restart: unless-stopped
logging: *default-logging
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes: volumes:
- minio_data:/data - minio_data:/data
networks: [edge, internal]
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 10s
ports: ports:
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید # در تولید، docker-compose.prod.yml این‌ها را فقط روی 127.0.0.1 می‌بندد —
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو می‌شوند) # کنسول رمز root دارد و نباید به اینترنت باز باشد
- "9001:9001"
- "19000:9000"
labels: labels:
- traefik.enable=true - traefik.enable=true
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
- traefik.docker.network=aramesh-backend_edge
- traefik.http.routers.media.rule=PathPrefix(`/media`) - traefik.http.routers.media.rule=PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=web - traefik.http.routers.media.entrypoints=web
- traefik.http.routers.media.priority=100 - traefik.http.routers.media.priority=100
- traefik.http.routers.media.middlewares=media-rewrite
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*) - traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1 - traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
- traefik.http.routers.media.middlewares=media-rewrite
- traefik.http.services.media.loadbalancer.server.port=9000 - traefik.http.services.media.loadbalancer.server.port=9000
deploy:
resources:
limits:
memory: 512M
# یک‌بار اجرا می‌شود: ساخت باکت، بازگذاشتن خواندن عمومی و پیش‌بارگذاری تصویرهای seed. # یک‌بار اجرا می‌شود: ساخت باکت، بازگذاشتن خواندن عمومی و پیش‌بارگذاری تصویرهای seed
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبت‌اند) روی یک حجم تازه # فقط وقتی باکت خالی است — تا هر deploy، کپی تکراری تصویرها را تکرار نکند.
# وارد باکت می‌شوند تا هر نسخه تازه‌کلون/بیلد، همان عکس‌ها را داشته باشد؛
# روی حجم موجود، mc cp فایل‌های تکراری را رد می‌کند و داده‌تازه را نمی‌خورد.
minio-setup: minio-setup:
image: minio/mc:latest image: minio/mc:RELEASE.2025-04-08T15-39-49Z
depends_on: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
@@ -76,19 +126,51 @@ services:
volumes: volumes:
- ./seed-media:/seed-media:ro - ./seed-media:/seed-media:ro
restart: "no" restart: "no"
networks: [internal]
logging: *default-logging
entrypoint: > entrypoint: >
/bin/sh -c " /bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing local/$$MINIO_BUCKET && mc mb --ignore-existing local/$$MINIO_BUCKET &&
mc anonymous set download local/$$MINIO_BUCKET && mc anonymous set download local/$$MINIO_BUCKET &&
echo 'minio-setup: bucket ready' && COUNT=$$(mc ls --recursive local/$$MINIO_BUCKET | wc -l) &&
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ && if [ \"$$COUNT\" -eq 0 ]; then
echo 'minio-setup: seed media mirrored' echo 'minio-setup: bucket ready — mirroring seed media';
mc mirror --overwrite /seed-media local/$$MINIO_BUCKET/;
else
echo \"minio-setup: bucket ready — $$COUNT object(s) already present, seed mirror skipped\";
fi
"
# یک‌بار اجرا می‌شود: دیتابیس‌های جانبی روی همان نمونهٔ Postgres
# (umami برای آنالیتیکس، aramesh_chatbot برای بردارهای چت‌بات)
db-init:
image: postgres:17-alpine
restart: "no"
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGUSER: aramesh
PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
networks: [internal]
logging: *default-logging
entrypoint: >
/bin/sh -c "
set -e;
for DBN in umami aramesh_chatbot; do
if ! psql -d arameshplaza -tAc \"SELECT 1 FROM pg_database WHERE datname='$$DBN'\" | grep -q 1; then
psql -d arameshplaza -c \"CREATE DATABASE $$DBN\";
fi;
echo \"db-init: $$DBN ready\";
done
" "
backend: backend:
build: ./backend build: ./backend
restart: unless-stopped restart: unless-stopped
logging: *default-logging
environment: environment:
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید} SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
@@ -106,11 +188,14 @@ services:
FRONTEND_DIST: /srv/frontend FRONTEND_DIST: /srv/frontend
volumes: volumes:
- ./frontend/dist:/srv/frontend:ro - ./frontend/dist:/srv/frontend:ro
networks: [edge, internal]
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
db-init:
condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 15s interval: 15s
@@ -119,9 +204,17 @@ services:
start_period: 20s start_period: 20s
labels: labels:
- traefik.enable=true - traefik.enable=true
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
- traefik.docker.network=aramesh-backend_edge
- traefik.http.routers.api.rule=PathPrefix(`/api`) - traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=web - traefik.http.routers.api.entrypoints=web
- traefik.http.routers.api.priority=100 - traefik.http.routers.api.priority=100
# لایه دوم محافظت از فرم‌ها — سیلاب درخواست را پیش از رسیدن به بک‌اند می‌بُرد؛
# محدودسازی دقیق هر فرم در خود بک‌اند است (rate_limit.py)
- traefik.http.routers.api.middlewares=api-flood
- traefik.http.middlewares.api-flood.ratelimit.average=50
- traefik.http.middlewares.api-flood.ratelimit.period=1s
- traefik.http.middlewares.api-flood.ratelimit.burst=150
- traefik.http.services.api.loadbalancer.server.port=8000 - traefik.http.services.api.loadbalancer.server.port=8000
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA # robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
# مسیرهای /en/... هم همه به بک‌اند می‌روند تا متای سئوی انگلیسی و html انگلیسی تزریق شود # مسیرهای /en/... هم همه به بک‌اند می‌روند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
@@ -129,29 +222,111 @@ services:
- traefik.http.routers.seo.entrypoints=web - traefik.http.routers.seo.entrypoints=web
- traefik.http.routers.seo.priority=50 - traefik.http.routers.seo.priority=50
- traefik.http.routers.seo.service=api - traefik.http.routers.seo.service=api
# /metrics فقط برای Prometheus — روتری تعریف نمی‌شود، از بیرون در دسترس نیست
deploy:
resources:
limits:
memory: 768M
# فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است # فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend: frontend:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped
logging: *default-logging
depends_on: depends_on:
- backend backend:
- minio condition: service_healthy
networks: [edge]
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"] test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 5s
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.web.rule=PathPrefix(`/`) - traefik.http.routers.web.rule=PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=web - traefik.http.routers.web.entrypoints=web
- traefik.http.routers.web.priority=1 - traefik.http.routers.web.priority=1
- traefik.http.services.web.loadbalancer.server.port=80 - traefik.http.services.web.loadbalancer.server.port=80
deploy:
resources:
limits:
memory: 64M
# دستیار هوشمند — RAG روی محتوای سایت با pgvector
# اولین اجرا مدل‌ها را دانلود می‌کند (~۵۰۰MB روی حجم chatbot_hf) — کند است، طبیعی است
chatbot:
build: ./chatbot
restart: unless-stopped
logging: *default-logging
environment:
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-openai/gpt-oss-120b}
CHAT_REINDEX_TOKEN: ${CHAT_REINDEX_TOKEN:-}
volumes:
- chatbot_hf:/data
networks: [edge, internal]
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
labels:
- traefik.enable=true
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
- traefik.docker.network=aramesh-backend_edge
- traefik.http.routers.chat.rule=PathPrefix(`/chat`)
- traefik.http.routers.chat.entrypoints=web
- traefik.http.routers.chat.priority=100
- traefik.http.routers.chat.middlewares=api-flood
- traefik.http.services.chat.loadbalancer.server.port=8000
deploy:
resources:
limits:
memory: 2G
# آنالیتیکس محصول (Umami) — بازدید، ارجاع، رخدادهای سفارشی (فرم/تلفن/چت‌بات)
# داشبورد: analytics.localhost (توسعه) یا analytics.DOMAIN (تولید)
umami:
image: ghcr.io/umami-software/umami:postgresql-v2.17.0
restart: unless-stopped
logging: *default-logging
environment:
DATABASE_URL: postgresql://aramesh:${POSTGRES_PASSWORD}@db:5432/umami
# اسکریپت ردیاب به ارجاع‌دهندهٔ واقعی نیاز دارد — پشت Traefik
CLIENT_IP_HEADER: x-forwarded-for
DISABLE_TELEMETRY: 1
networks: [edge, internal]
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
healthcheck:
# 127.0.0.1 نه localhost — «localhost» به ::1 حل می‌شود و umami فقط IPv4 گوش می‌دهد
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/heartbeat || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
labels:
- traefik.enable=true
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
- traefik.docker.network=aramesh-backend_edge
- traefik.http.routers.analytics.rule=Host(`analytics.localhost`)
- traefik.http.routers.analytics.entrypoints=web
- traefik.http.services.analytics.loadbalancer.server.port=3000
deploy:
resources:
limits:
memory: 512M
# ابزار پشتیبان‌گیری — فقط با پروفایل tools اجرا می‌شود (scripts/backup.sh این کار را می‌کند) # ابزار پشتیبان‌گیری — فقط با پروفایل tools اجرا می‌شود (scripts/backup.sh این کار را می‌کند)
mc: mc:
image: minio/mc:latest image: minio/mc:RELEASE.2025-04-08T15-39-49Z
profiles: ["tools"] profiles: ["tools"]
depends_on: depends_on:
minio: minio:
@@ -159,7 +334,42 @@ services:
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
networks: [internal]
# پشتیبان ساعتی خودکار دیتابیس — بدون نیاز به cron میزبان؛ فقط با پروفایل ops
# docker compose --profile ops up -d db-backup
# RPO را از ۲۴ ساعت (cron شبانه) به ۱ ساعت می‌رساند؛ scripts/backup.sh برای
# پشتیبان کامل (دیتابیس + تصاویر MinIO) سر جایش است.
db-backup:
image: prodrigestivill/postgres-backup-local:17
profiles: ["ops"]
restart: unless-stopped
logging: *default-logging
depends_on:
db:
condition: service_healthy
environment:
POSTGRES_HOST: db
POSTGRES_DB: arameshplaza
POSTGRES_USER: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
SCHEDULE: "@hourly"
BACKUP_NUM_KEEP: "48"
BACKUP_DIR: /backups
volumes:
- backups:/backups
networks: [internal]
deploy:
resources:
limits:
memory: 128M
networks:
edge: # سرویس‌هایی که Traefik به آن‌ها مسیریابی می‌کند
internal: # دیتابیس و ذخیره‌سازی — فرانت‌اند راهی به این شبکه ندارد
volumes: volumes:
db_data: db_data:
minio_data: minio_data:
chatbot_hf:
backups:
+4
View File
@@ -0,0 +1,4 @@
# فقط Caddyfile و dist/ به کانتکست بیلد می‌روند — node_modules و سورس عظیم‌اند و لازم نیستند
*
!Caddyfile
!dist
+23 -6
View File
@@ -1,12 +1,29 @@
{
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
admin off
}
:80 { :80 {
root * /srv root * /srv
encode gzip encode zstd gzip
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمی‌گردند # فایل‌های هش‌دار بیلد: کش بلندمدت بی‌رحم — ولی هرگز fallback به index.html نداشته باشند؛
try_files {path} /index.html # پاسخ 200ٔ HTML برای یک مسیر /assets/* نبوده (مثلاً هنگام پنجرهٔ تعویض کانتینر در استقرار)
file_server # توسط CDN (WCDN، سیاست SMART) با همین هدر immutable یک سال کش می‌شود و اسکریپت ماژول
# را برای همه بازدیدکننده‌ها می‌شکند. فایل ناموجود در assets باید 404 بدهد، نه HTML.
# کش بلندمدت برای فایل‌های هش‌دار بیلد
@assets path /assets/* @assets path /assets/*
handle @assets {
file_server
}
# مسیریابی SPA — مسیرهای ناشناختهٔ غیرasset به index.html برمی‌گردند
handle {
try_files {path} /index.html
file_server
}
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
@notassets not path /assets/*
header @notassets Cache-Control "no-cache"
header @assets Cache-Control "public, immutable, max-age=31536000" header @assets Cache-Control "public, immutable, max-age=31536000"
} }
+11 -1
View File
@@ -4,7 +4,17 @@
FROM caddy:2-alpine FROM caddy:2-alpine
# نسخهٔ alpine کاربر caddy ندارد — اینجا ساخته می‌شود؛ /config و /data مسیرهای
# storage سی‌دی هستند و باید برای کاربر نوشتنی باشند
RUN addgroup -S caddy \
&& adduser -S -G caddy -s /sbin/nologin caddy \
&& mkdir -p /config /data \
&& chown -R caddy:caddy /config /data
COPY Caddyfile /etc/caddy/Caddyfile COPY Caddyfile /etc/caddy/Caddyfile
COPY dist /srv COPY --chown=caddy:caddy dist /srv
# بدون root — فقط فایل‌های استاتیک سرو می‌شوند و TLS را Traefik تمام می‌کند
USER caddy
EXPOSE 80 EXPOSE 80
+10
View File
@@ -24,6 +24,16 @@
document.documentElement.setAttribute('dir', 'ltr'); document.documentElement.setAttribute('dir', 'ltr');
} }
</script> </script>
<script type="module">
// تحلیل رفتار (Umami) — در زمان بیلد تزریق می‌شود؛ VITE_UMAMI_SRC خالی یعنی خاموش
if (import.meta.env.VITE_UMAMI_SRC) {
const s = document.createElement('script');
s.defer = true;
s.src = import.meta.env.VITE_UMAMI_SRC;
s.setAttribute('data-website-id', import.meta.env.VITE_UMAMI_WEBSITE_ID ?? '');
document.head.appendChild(s);
}
</script>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>
+5
View File
@@ -5,6 +5,7 @@ import { Preloader } from './components/Preloader';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Footer } from './components/Footer'; import { Footer } from './components/Footer';
import { FloatingContact } from './components/FloatingContact'; import { FloatingContact } from './components/FloatingContact';
import { ChatWidget } from './components/ChatWidget';
import { Home } from './pages/Home'; import { Home } from './pages/Home';
import { About } from './pages/About'; import { About } from './pages/About';
import { ServicesPage } from './pages/ServicesPage'; import { ServicesPage } from './pages/ServicesPage';
@@ -21,6 +22,7 @@ import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar'; import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage'; import { AdminLoginPage } from './admin/AdminLoginPage';
import { ArticleWritePage } from './admin/ArticleWritePage'; import { ArticleWritePage } from './admin/ArticleWritePage';
import { ProjectWritePage } from './admin/ProjectWritePage';
import { LangProvider, useLang } from './i18n/lang'; import { LangProvider, useLang } from './i18n/lang';
// مسیرهای عمومی یک بار تعریف می‌شوند و دو بار سرو می‌شوند: ریشه فارسی و /en انگلیسی // مسیرهای عمومی یک بار تعریف می‌شوند و دو بار سرو می‌شوند: ریشه فارسی و /en انگلیسی
@@ -68,6 +70,8 @@ function AnimatedRoutes() {
<Route path="/admin/login" element={<AdminLoginPage />} /> <Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} /> <Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} /> <Route path="/admin/write/:id" element={<ArticleWritePage />} />
<Route path="/admin/projects/new" element={<ProjectWritePage />} />
<Route path="/admin/projects/:id" element={<ProjectWritePage />} />
{FA_ROUTES} {FA_ROUTES}
<Route path="*" element={<Home />} /> <Route path="*" element={<Home />} />
{EN_ROUTES} {EN_ROUTES}
@@ -106,6 +110,7 @@ function AppChrome() {
<AnimatedRoutes /> <AnimatedRoutes />
{!isAdminPage && <Footer />} {!isAdminPage && <Footer />}
{!isAdminPage && <FloatingContact />} {!isAdminPage && <FloatingContact />}
{!isAdminPage && <ChatWidget />}
{isAuthenticated && !isAdminPage && <AdminBar />} {isAuthenticated && !isAdminPage && <AdminBar />}
</> </>
); );
+50 -3
View File
@@ -1,8 +1,10 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Link } from 'react-router-dom'
import { KeyRoundIcon, XIcon } from 'lucide-react' import { KeyRoundIcon, XIcon } from 'lucide-react'
import { useAdmin } from './AdminContext' import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { useLang } from '../i18n/lang'
const STATUS_TEXT: Record<string, string> = { const STATUS_TEXT: Record<string, string> = {
idle: 'همگام‌سازی شد', idle: 'همگام‌سازی شد',
@@ -126,9 +128,18 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */ /** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() { export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin() const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang, syncEditLang, role, canManageSite } = useAdmin()
const { lang } = useLang()
const [pwOpen, setPwOpen] = useState(false) const [pwOpen, setPwOpen] = useState(false)
// زبان ویرایش از زبان صفحهٔ در حال نمایش پیروی می‌کند — ویرایش روی /en به ستون‌های
// انگلیسی می‌رود و روی صفحات فارسی به فارسی؛ وگرنه ویرایشِ دیده‌شده در /en ناخواسته
// متن فارسی را بازنویسی می‌کرد. کلید دستی پایین فقط برای ویرایش ستون زبان دیگر
// روی همین صفحه است و با ناوبری بعدی دوباره همگام می‌شود.
useEffect(() => {
if (isEditMode) syncEditLang(lang)
}, [isEditMode, lang, syncEditLang])
return ( return (
<> <>
<div <div
@@ -140,6 +151,7 @@ export function AdminBar() {
حالت مدیریت حالت مدیریت
</span> </span>
{canManageSite && (
<button <button
type="button" type="button"
role="switch" role="switch"
@@ -160,11 +172,12 @@ export function AdminBar() {
</span> </span>
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'} {isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
</button> </button>
)}
<button <button
type="button" type="button"
onClick={toggleEditLang} onClick={toggleEditLang}
title="تعیین می‌کند ویرایش‌ها روی کدام ستون زبان ذخیره شوند" title="معمولاً خودکار با زبان صفحه همگام می‌شود — این کلید فقط برای ویرایش ستون زبان دیگر روی همین صفحه است"
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${ className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
editLang === 'en' editLang === 'en'
? 'border-copper bg-copper/10 text-copper' ? 'border-copper bg-copper/10 text-copper'
@@ -188,6 +201,40 @@ export function AdminBar() {
رمز رمز
</button> </button>
{canManageSite && (
<>
<Link
to="/admin/write"
data-admin-link
className="border border-copper/50 px-3 py-1.5 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
نوشتن مقاله
</Link>
<button
type="button"
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی پیام‌ها', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
})}
title="دانلود پیام‌های فرم تماس به‌صورت CSV"
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
پیام‌ها (CSV)
</button>
<button
type="button"
onClick={() => void adminApi.downloadCsv('subscribers').catch((e) => {
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی خبرنامه', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
})}
title="دانلود مشترکان خبرنامه به‌صورت CSV"
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
خبرنامه (CSV)
</button>
</>
)}
<button <button
type="button" type="button"
onClick={logout} onClick={logout}
+53 -8
View File
@@ -4,17 +4,23 @@ import Swal from 'sweetalert2'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient' import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
import { ENTITIES, isTranslatable } from './entities' import { entityConfig, isTranslatable } from './entities'
export type SaveStatus = 'idle' | 'saving' | 'error' export type SaveStatus = 'idle' | 'saving' | 'error'
/** زبان ویرایش محتوا — رابط مدیریتی فارسی می‌ماند؛ این فقط تعیین می‌کند کدام ستون زبان ذخیره شود */ /** زبان ویرایش محتوا — رابط مدیریتی فارسی می‌ماند؛ این فقط تعیین می‌کند کدام ستون زبان ذخیره شود */
export type EditLang = 'fa' | 'en' export type EditLang = 'fa' | 'en'
export type AdminRole = 'admin' | 'creator'
interface AdminContextValue { interface AdminContextValue {
isAuthenticated: boolean isAuthenticated: boolean
/** نقش کاربر واردشده — 'creator' فقط نویسندهٔ محتواست */
role: AdminRole | null
/** فقط نقش admin به کل بخش‌های سایت دسترسی دارد */
canManageSite: boolean
isEditMode: boolean isEditMode: boolean
toggleEditMode: () => void toggleEditMode: () => void
login: (username: string, password: string) => Promise<void> login: (username: string, password: string) => Promise<AdminRole>
logout: () => void logout: () => void
saveStatus: SaveStatus saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */ /** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
@@ -22,6 +28,9 @@ interface AdminContextValue {
/** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */ /** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */
editLang: EditLang editLang: EditLang
toggleEditLang: () => void toggleEditLang: () => void
/** همگام‌سازی زبان ویرایش با زبان صفحهٔ در حال نمایش — کلید دستی فقط برای
* ویرایش ستون زبان دیگر روی همان صفحه است؛ در ناوبری دوباره از صفحه پیروی می‌کند */
syncEditLang: (lang: EditLang) => void
} }
const AdminContext = createContext<AdminContextValue | null>(null) const AdminContext = createContext<AdminContextValue | null>(null)
@@ -55,12 +64,26 @@ function showSaveError(error: unknown): void {
}) })
} }
/** نقش را از توکن JWT درمی‌آورد — فال بک sessionStorage */
function roleFromToken(token: string | null): AdminRole | null {
if (!token) return null
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
return payload?.role === 'creator' ? 'creator' : 'admin'
} catch {
return null
}
}
export function AdminProvider({ children }: { children: ReactNode }) { export function AdminProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken()) const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
const [role, setRole] = useState<AdminRole | null>(() =>
sessionStorage.getItem('aramesh_role') as AdminRole | null ?? roleFromToken(getToken()),
)
const [isEditMode, setIsEditMode] = useState<boolean>( const [isEditMode, setIsEditMode] = useState<boolean>(
() => sessionStorage.getItem('aramesh_edit_mode') === '1', () => sessionStorage.getItem('aramesh_edit_mode') === '1',
) )
const [editLang, setEditLang] = useState<EditLang>( const [editLang, setEditLangState] = useState<EditLang>(
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'), () => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
) )
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle') const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
@@ -74,24 +97,39 @@ export function AdminProvider({ children }: { children: ReactNode }) {
}, []) }, [])
const toggleEditLang = useCallback(() => { const toggleEditLang = useCallback(() => {
setEditLang((v) => { setEditLangState((v) => {
const next: EditLang = v === 'fa' ? 'en' : 'fa' const next: EditLang = v === 'fa' ? 'en' : 'fa'
sessionStorage.setItem('aramesh_edit_lang', next) sessionStorage.setItem('aramesh_edit_lang', next)
return next return next
}) })
}, []) }, [])
const login = useCallback(async (username: string, password: string) => { // بدون بازنویسی انتخاب دستی وقتی مقدار همان است — تا اثر همگام‌سازی بی‌دلیل رندر نکند
const syncEditLang = useCallback((next: EditLang) => {
setEditLangState((v) => {
if (v === next) return v
sessionStorage.setItem('aramesh_edit_lang', next)
return next
})
}, [])
const login = useCallback(async (username: string, password: string): Promise<AdminRole> => {
await adminApi.login(username, password) await adminApi.login(username, password)
const nextRole = roleFromToken(getToken()) ?? 'admin'
sessionStorage.setItem('aramesh_role', nextRole)
setRole(nextRole)
setIsAuthenticated(true) setIsAuthenticated(true)
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند // داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند
reload() reload()
return nextRole
}, [reload]) }, [reload])
const logout = useCallback(() => { const logout = useCallback(() => {
setToken(null) setToken(null)
setIsAuthenticated(false) setIsAuthenticated(false)
setIsEditMode(false) setIsEditMode(false)
setRole(null)
sessionStorage.removeItem('aramesh_role')
sessionStorage.removeItem('aramesh_edit_mode') sessionStorage.removeItem('aramesh_edit_mode')
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند // داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند
reload() reload()
@@ -132,7 +170,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
// توکن بیرون از این provider هم پاک می‌شود (پاسخ 401 بارگذاری سایت، خروج در زبانه // توکن بیرون از این provider هم پاک می‌شود (پاسخ 401 بارگذاری سایت، خروج در زبانه
// دیگر) — وضعیت ورود همگام می‌ماند و نوار ادمین برای نشست مرده باقی نمی‌ماند // دیگر) — وضعیت ورود همگام می‌ماند و نوار ادمین برای نشست مرده باقی نمی‌ماند
useEffect(() => { useEffect(() => {
const syncAuth = () => setIsAuthenticated(!!getToken()) const syncAuth = () => {
setIsAuthenticated(!!getToken())
setRole(roleFromToken(getToken()))
}
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth) window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.addEventListener('storage', syncAuth) window.addEventListener('storage', syncAuth)
return () => { return () => {
@@ -151,9 +192,12 @@ export function AdminProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('beforeunload', guard) return () => window.removeEventListener('beforeunload', guard)
}, [saveStatus]) }, [saveStatus])
const canManageSite = role === 'admin'
const value = useMemo<AdminContextValue>( const value = useMemo<AdminContextValue>(
() => ({ () => ({
isAuthenticated, isAuthenticated,
role,
canManageSite,
isEditMode, isEditMode,
toggleEditMode, toggleEditMode,
login, login,
@@ -162,8 +206,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
runSave, runSave,
editLang, editLang,
toggleEditLang, toggleEditLang,
syncEditLang,
}), }),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang], [isAuthenticated, role, canManageSite, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang, syncEditLang],
) )
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider> return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
@@ -201,7 +246,7 @@ export function useFieldSaver() {
} }
await adminApi.patch(resource, id, changes) await adminApi.patch(resource, id, changes)
updateSite((d) => { updateSite((d) => {
const config = ENTITIES[resource] const config = entityConfig(resource)
let node: Record<string, unknown> | undefined let node: Record<string, unknown> | undefined
if (config) { if (config) {
const target = d[config.dataKey as keyof SiteData] as unknown const target = d[config.dataKey as keyof SiteData] as unknown
+2 -2
View File
@@ -20,8 +20,8 @@ export function AdminLoginPage() {
setError('') setError('')
setSubmitting(true) setSubmitting(true)
try { try {
await login(username.trim(), password) const nextRole = await login(username.trim(), password)
navigate('/') navigate(nextRole === 'creator' ? '/admin/write' : '/')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'ورود ناموفق بود') setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
} finally { } finally {
+89 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Controller, useController, useForm } from 'react-hook-form' import { Controller, useController, useForm } from 'react-hook-form'
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom' import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react' import { ArrowRightIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { useAdmin } from './AdminContext' import { useAdmin } from './AdminContext'
@@ -54,6 +54,75 @@ function normalizeTags(value: unknown): string[] {
return [] return []
} }
/** یک ردیف پرسش‌وپاسخ مقاله */
type ArticleFaq = { question: string; answer: string }
/**
* پنل پرسش‌وپاسخ‌های پایان مقاله — مثل بخش متا دیسکریپشن زیر ویرایشگر.
* در زبان فارسی ستون faqs و در زبان انگلیسی faqs_en را ویرایش می‌کند؛
* نسخه انگلیسیِ خالی در سایت به فارسی برمی‌گردد. ذخیره‌سازی خودکار است.
*/
function FaqPanel({
faqs,
onChange,
}: {
faqs: ArticleFaq[]
onChange: (next: ArticleFaq[]) => void
}) {
const update = (index: number, patch: Partial<ArticleFaq>) => {
const next = faqs.map((f, i) => (i === index ? { ...f, ...patch } : f))
onChange(next)
}
const remove = (index: number) => onChange(faqs.filter((_, i) => i !== index))
const add = () => onChange([...faqs, { question: '', answer: '' }])
return (
<div className="mt-4 grid gap-4">
{faqs.map((faq, i) => (
<div key={i} className="grid gap-3 border border-ivory/10 bg-ink/60 p-4">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-light text-copper">پرسش {i + 1}</span>
<button
type="button"
onClick={() => remove(i)}
aria-label={`حذف پرسش ${i + 1}`}
className="flex h-7 w-7 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
<input
type="text"
dir="auto"
value={faq.question}
onChange={(e) => update(i, { question: e.target.value })}
placeholder="پرسش مخاطب…"
aria-label={`متن پرسش ${i + 1}`}
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
<textarea
rows={2}
dir="auto"
value={faq.answer}
onChange={(e) => update(i, { answer: e.target.value })}
placeholder="پاسخ کوتاه و روشن…"
aria-label={`پاسخ پرسش ${i + 1}`}
className="w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</div>
))}
<button
type="button"
onClick={add}
className="flex w-fit items-center gap-2 border border-dashed border-ivory/25 px-4 py-2 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
>
<PlusIcon className="h-3.5 w-3.5" />
افزودن پرسش‌وپاسخ
</button>
</div>
)
}
const STATUS_TEXT: Record<string, string> = { const STATUS_TEXT: Record<string, string> = {
idle: 'ذخیره شد', idle: 'ذخیره شد',
saving: 'در حال ذخیره…', saving: 'در حال ذخیره…',
@@ -65,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش). * مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
*/ */
export function ArticleWritePage() { export function ArticleWritePage() {
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin() const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, role } = useAdmin()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { data, updateSite } = useSiteData() const { data, updateSite } = useSiteData()
@@ -327,6 +396,7 @@ export function ArticleWritePage() {
</span> </span>
{createdId && ( {createdId && (
<> <>
{role === 'admin' && (
<button <button
type="button" type="button"
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })} onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
@@ -338,6 +408,7 @@ export function ArticleWritePage() {
> >
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'} {draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button> </button>
)}
<Link <Link
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`} to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
data-admin-link data-admin-link
@@ -346,6 +417,7 @@ export function ArticleWritePage() {
<EyeIcon className="h-3.5 w-3.5" /> <EyeIcon className="h-3.5 w-3.5" />
دیدن در سایت دیدن در سایت
</Link> </Link>
{role === 'admin' && (
<button <button
type="button" type="button"
onClick={remove} onClick={remove}
@@ -355,6 +427,7 @@ export function ArticleWritePage() {
> >
<Trash2Icon className="h-3.5 w-3.5" /> <Trash2Icon className="h-3.5 w-3.5" />
</button> </button>
)}
</> </>
)} )}
</div> </div>
@@ -458,6 +531,20 @@ export function ArticleWritePage() {
/> />
</section> </section>
{/* پرسش‌وپاسخ‌های پایان مقاله — سئوی FAQPage */}
<section className="mt-8 border border-ivory/15 bg-char p-6">
<h2 className="text-sm font-bold text-copper">پرسش‌های متداول مقاله</h2>
<p className="mt-1 text-xs font-light text-ivory/50">
در پایان مقاله با ظاهر آکاردئونی سایت نمایش داده می‌شود و با اسکیمای FAQPage
شانس نمایش در نتایج غنی گوگل را بالا می‌برد.
{editLang === 'en' && ' — ویرایش نسخه انگلیسی (خالی = نمایش متن فارسی)'}
</p>
<FaqPanel
faqs={(editLang === 'en' ? draft.faqs_en : draft.faqs) ?? []}
onChange={(next) => queueSave({ [suf('faqs')]: next })}
/>
</section>
{/* چکیده و متادیتا */} {/* چکیده و متادیتا */}
<SeoChecklist <SeoChecklist
input={{ input={{
+20 -11
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react' import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
@@ -7,14 +8,14 @@ import { useSiteData } from '../api'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import { useAdmin } from './AdminContext' import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { ENTITIES } from './entities' import { entityConfig } from './entities'
import { EntityDrawer } from './EntityDrawer' import { EntityDrawer } from './EntityDrawer'
type ItemWithId = Record<string, unknown> & { id: number } type ItemWithId = Record<string, unknown> & { id: number }
function useEntityList(resource: string): ItemWithId[] { function useEntityList(resource: string): ItemWithId[] {
const { data } = useSiteData() const { data } = useSiteData()
const config = ENTITIES[resource] const config = entityConfig(resource)
if (!config) throw new Error(`منبع ناشناخته: ${resource}`) if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[] return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
} }
@@ -69,14 +70,14 @@ export function EditableItem({
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */ /** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */
editHref?: string editHref?: string
}) { }) {
const { isAuthenticated, isEditMode, runSave } = useAdmin() const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
const { reload } = useSiteData() const { reload } = useSiteData()
const config = ENTITIES[resource] const config = entityConfig(resource)
const list = useEntityList(resource) const list = useEntityList(resource)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
if (!config) return <>{children}</> if (!config) return <>{children}</>
if (!isAuthenticated || !isEditMode) return <>{children}</> if (!isAuthenticated || !isEditMode || !canManageSite) return <>{children}</>
const index = list.findIndex((x) => x.id === item.id) const index = list.findIndex((x) => x.id === item.id)
@@ -163,17 +164,21 @@ export function AddItemButton({
resource, resource,
label, label,
variant = 'cell', variant = 'cell',
/** در صورت ارسال، پس از ساخته‌شدن رکورد به صفحهٔ ویرایش اختصاصی هدایت می‌شود (کشو باز نمی‌شود) */
editHrefBase,
}: { }: {
resource: string resource: string
label?: string label?: string
variant?: 'cell' | 'button' variant?: 'cell' | 'button'
editHrefBase?: string
}) { }) {
const { isAuthenticated, isEditMode, runSave } = useAdmin() const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
const navigate = useNavigate()
const { updateSite } = useSiteData() const { updateSite } = useSiteData()
const config = ENTITIES[resource] const config = entityConfig(resource)
const [newItem, setNewItem] = useState<ItemWithId | null>(null) const [newItem, setNewItem] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
const create = () => { const create = () => {
void runSave(async () => { void runSave(async () => {
@@ -183,6 +188,10 @@ export function AddItemButton({
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[] const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(created) if (Array.isArray(list)) list.push(created)
}) })
if (editHrefBase) {
navigate(`${editHrefBase}/${created.id}`)
return
}
setNewItem(created) setNewItem(created)
}) })
} }
@@ -240,13 +249,13 @@ export function SingletonPill({
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */ /** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>) createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
}) { }) {
const { isAuthenticated, isEditMode, runSave } = useAdmin() const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
const config = ENTITIES[resource] const config = entityConfig(resource)
const { data, updateSite } = useSiteData() const { data, updateSite } = useSiteData()
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [created, setCreated] = useState<ItemWithId | null>(null) const [created, setCreated] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
const item = const item =
created ?? created ??
(select (select
+347
View File
@@ -0,0 +1,347 @@
import { useEffect, useState } from 'react'
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
import { ArrowRightIcon, Trash2Icon } from 'lucide-react'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
import { ImageField } from '../components/admin/ImageField'
import { GalleryField } from '../components/admin/GalleryField'
type ProjectDraft = {
id: number
slug: string
title: string
title_en: string
location: string
location_en: string
year: string
year_en: string
status: 'done' | 'progress'
project_type: string
project_type_en: string
usage: string
usage_en: string
area: string
area_en: string
floors: string
floors_en: string
units: string
units_en: string
client: string
client_en: string
summary: string
summary_en: string
body: string[]
body_en: string[]
image: string
gallery: string[]
}
const EMPTY: ProjectDraft = {
id: 0,
slug: `project-${Date.now()}`,
title: '', title_en: '',
location: '', location_en: '',
year: '', year_en: '',
status: 'done',
project_type: '', project_type_en: '',
usage: '', usage_en: '',
area: '', area_en: '',
floors: '', floors_en: '',
units: '', units_en: '',
client: '', client_en: '',
summary: '', summary_en: '',
body: [], body_en: [],
image: '', gallery: [],
}
const INPUT =
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
/** جفت دوزبانه — فارسی و English همیشه هر دو دیده می‌شوند و با یک ذخیره ثبت می‌شوند */
function Pair({
label,
fa,
en,
textarea = false,
}: {
label: string
fa: { value: string; onChange: (v: string) => void }
en: { value: string; onChange: (v: string) => void }
textarea?: boolean
}) {
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
<div className="mt-1.5 grid gap-3 sm:grid-cols-2">
<div>
<span className="text-[10px] font-light text-ivory/40">فارسی</span>
{textarea ? (
<textarea dir="rtl" rows={4} className={`${INPUT} mt-1 leading-8`} {...fa} />
) : (
<input dir="rtl" type="text" className={`${INPUT} mt-1`} {...fa} />
)}
</div>
<div>
<span className="text-[10px] font-light text-ivory/40">English</span>
{textarea ? (
<textarea dir="ltr" rows={4} className={`${INPUT} mt-1 leading-8`} {...en} />
) : (
<input dir="ltr" type="text" className={`${INPUT} mt-1`} {...en} />
)}
</div>
</div>
</div>
)
}
/** صفحهٔ اختصاصی ویرایش/ساخت پروژه — به‌جای کشو؛ تصاویر فقط با آپلود به MinIO */
export function ProjectWritePage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const numericId = Number(id)
const navigate = useNavigate()
const { data, updateSite } = useSiteData()
const { isAuthenticated, runSave } = useAdmin()
const existing = !isNew && data?.projects.find((p) => p.id === numericId)
const [fetched, setFetched] = useState<ProjectDraft | null>(null)
const [draft, setDraft] = useState<ProjectDraft>(EMPTY)
const [saving, setSaving] = useState(false)
// اگر پروژه در دادهٔ سایت نبود (پیش‌نویس تازه و… ) مستقیم از مسیر مدیریتی خوانده می‌شود
useEffect(() => {
if (isNew || existing) return
let cancelled = false
adminApi
.get<ProjectDraft>('projects', numericId)
.then((p) => {
if (!cancelled) setFetched(p)
})
.catch(() => {
/* پروژه در دادهٔ سایت نبود — از مسیر مدیریتی خوانده می‌شود */
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, existing])
const project = existing ?? fetched
useEffect(() => {
if (!project) return
setDraft({ ...EMPTY, ...(project as unknown as ProjectDraft) })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [(project as unknown as ProjectDraft | undefined)?.id])
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
const set = <K extends keyof ProjectDraft>(key: K, value: ProjectDraft[K]) =>
setDraft((d) => ({ ...d, [key]: value }))
const fa = (key: keyof ProjectDraft) => ({
value: String(draft[key] ?? ''),
onChange: (v: string) => set(key, v as ProjectDraft[typeof key]),
})
const en = (key: keyof ProjectDraft) => ({
value: String(draft[`${String(key)}_en` as keyof ProjectDraft] ?? ''),
onChange: (v: string) => set(`${String(key)}_en` as keyof ProjectDraft, v as ProjectDraft[typeof key]),
})
const doSave = async () => {
if (!draft.title.trim() || !draft.slug.trim()) {
void Swal.fire({
icon: 'warning',
title: 'عنوان و نشانی (slug) لازم است',
background: '#1e1a16',
color: '#F5F3EF',
confirmButtonText: 'باشه',
buttonsStyling: false,
customClass: { popup: 'font-pinar border border-ivory/10' },
})
return
}
setSaving(true)
const ok = await runSave(async () => {
if (isNew || draft.id === 0) {
const created = await adminApi.create<ProjectDraft>('projects', { ...draft })
updateSite((d) => {
const list = d.projects as unknown as ProjectDraft[]
if (Array.isArray(list)) list.push(created)
})
navigate(`/admin/projects/${created.id}`, { replace: true })
} else {
await adminApi.patch('projects', draft.id, { ...draft })
updateSite((d) => {
const list = d.projects as unknown as ProjectDraft[]
const index = list.findIndex((x) => x.id === draft.id)
if (index >= 0) list[index] = { ...list[index], ...draft }
})
}
})
setSaving(false)
// ذخیرهٔ موفق پروژهٔ موجود → بازگشت به فهرست؛ بی‌صدا ماندن صفحه «ذخیره نشد» القا می‌کرد
if (ok && !isNew && draft.id !== 0) navigate('/projects', { replace: true })
}
const doDelete = async () => {
if (isNew || draft.id === 0) return
const result = await Swal.fire({
icon: 'warning',
title: 'حذف پروژه',
text: `«${draft.title}» برای همیشه حذف شود؟`,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
})
if (!result.isConfirmed) return
await runSave(async () => {
await adminApi.remove('projects', draft.id)
updateSite((d) => {
const list = d.projects as unknown as ProjectDraft[]
const index = list.findIndex((x) => x.id === draft.id)
if (index >= 0) list.splice(index, 1)
})
navigate('/projects', { replace: true })
})
}
/** تصویر شاخص و گالری بلافاصله ذخیره می‌شوند (پروژهٔ موجود)؛ در پروژهٔ تازه تا اولین ذخیره در فرم می‌مانند */
const persistImage = async (url: string) => {
setDraft((d) => ({ ...d, image: url }))
if (!isNew && draft.id !== 0) {
await adminApi.patch('projects', draft.id, { image: url })
}
return true
}
const persistGallery = async (urls: string[]) => {
setDraft((d) => ({ ...d, gallery: urls }))
if (!isNew && draft.id !== 0) {
await adminApi.patch('projects', draft.id, { gallery: urls })
}
return true
}
return (
<div dir="rtl" className="min-h-screen bg-ink pb-24 pt-24">
<div className="mx-auto max-w-4xl px-6 lg:px-10">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<Link
to="/projects"
data-admin-link
className="inline-flex items-center gap-2 text-xs font-light text-ivory/50 transition-colors hover:text-copper"
>
<ArrowRightIcon className="h-3.5 w-3.5" />
بازگشت به پروژه‌ها
</Link>
<h1 className="mt-3 text-3xl font-bold text-ivory">
{isNew ? 'پروژهٔ جدید' : `ویرایش «${draft.title}»`}
</h1>
</div>
<div className="flex items-center gap-3">
{!isNew && draft.id !== 0 && (
<button
type="button"
onClick={() => void doDelete()}
className="flex items-center gap-2 border border-red-400/40 px-5 py-2.5 text-sm font-light text-red-300 transition-colors duration-200 hover:bg-red-500/20"
>
<Trash2Icon className="h-4 w-4" />
حذف پروژه
</button>
)}
<button
type="button"
onClick={() => void doSave()}
disabled={saving}
className="flex items-center gap-2 bg-copper px-8 py-2.5 text-sm font-bold text-char transition-transform duration-200 hover:scale-105 disabled:opacity-60"
>
ذخیرهٔ پروژه
</button>
</div>
</div>
<div className="mt-10 space-y-8">
<Pair label="عنوان پروژه" fa={fa('title')} en={en('title')} />
<div className="grid gap-6 sm:grid-cols-2">
<div>
<p className="text-xs font-bold text-ivory/80">
نشانی (slug) <span className="font-light text-ivory/40">— در نشانی صفحه: /projects/…</span>
</p>
<input
dir="ltr"
type="text"
className={`${INPUT} mt-1.5`}
value={draft.slug}
onChange={(e) => set('slug', e.target.value)}
/>
</div>
<div>
<p className="text-xs font-bold text-ivory/80">وضعیت</p>
<select
className={`${INPUT} mt-1.5`}
value={draft.status}
onChange={(e) => set('status', e.target.value as ProjectDraft['status'])}
>
<option value="done">انجام‌شده (done)</option>
<option value="progress">در حال اجرا (progress)</option>
</select>
</div>
</div>
<Pair label="موقعیت" fa={fa('location')} en={en('location')} />
<Pair label="سال" fa={fa('year')} en={en('year')} />
<Pair label="نوع پروژه" fa={fa('project_type')} en={en('project_type')} />
<Pair label="کاربری" fa={fa('usage')} en={en('usage')} />
<Pair label="زیربنا" fa={fa('area')} en={en('area')} />
<Pair label="تعداد طبقات" fa={fa('floors')} en={en('floors')} />
<Pair label="واحدها" fa={fa('units')} en={en('units')} />
<Pair label="کارفرما" fa={fa('client')} en={en('client')} />
<Pair label="خلاصه" fa={fa('summary')} en={en('summary')} textarea />
<Pair
label="متن پروژه — هر پاراگراف یک خط"
fa={{
value: draft.body.join('\n'),
onChange: (v) => set('body', v.split('\n')),
}}
en={{
value: draft.body_en.join('\n'),
onChange: (v) => set('body_en', v.split('\n')),
}}
textarea
/>
<div className="border-t border-ivory/10 pt-8">
<ImageField
label="تصویر شاخص"
value={draft.image}
onSave={persistImage}
altFallback={`پروژه ${draft.title}`}
/>
</div>
<div className="border-t border-ivory/10 pt-8">
<GalleryField
label="گالری تصاویر پروژه — فقط آپلود به MinIO"
value={draft.gallery}
onSave={persistGallery}
namePrefix={draft.slug || 'project'}
/>
</div>
</div>
</div>
</div>
)
}
+38 -4
View File
@@ -3,6 +3,9 @@ import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token' const TOKEN_KEY = 'aramesh_admin_token'
/** سقف آپلود — هم‌اندازهٔ سقف کاربر در بک‌اند (media_max_upload_mb = ۳۵ با احتساب سربار multipart) */
const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
/** رویداد تغییر وضعیت ورود — لایه‌های دیگر (مثل نوار ادمین) با آن همگام می‌مانند */ /** رویداد تغییر وضعیت ورود — لایه‌های دیگر (مثل نوار ادمین) با آن همگام می‌مانند */
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change' export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
@@ -43,9 +46,11 @@ async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T>
const detail = const detail =
typeof data?.detail === 'string' typeof data?.detail === 'string'
? data.detail ? data.detail
: res.status === 409 : res.status === 413
? 'رکوردی با این مقدار تکراری وجود دارد' ? 'حجم فایل بیش از حد مجاز است'
: 'خطا در ذخیره‌سازی — دوباره تلاش کنید' : res.status === 409
? 'رکوردی با این مقدار تکراری وجود دارد'
: 'خطا در ذخیره‌سازی — دوباره تلاش کنید'
throw new HttpError(res.status, detail) throw new HttpError(res.status, detail)
} }
return res.status === 204 ? (null as T) : ((await res.json()) as T) return res.status === 204 ? (null as T) : ((await res.json()) as T)
@@ -87,15 +92,44 @@ export const adminApi = {
reorder: <T = unknown>(resource: string, ids: number[]) => reorder: <T = unknown>(resource: string, ids: number[]) =>
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }), authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
upload: async (file: File): Promise<{ url: string; key: string }> => { upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
// پیش‌چک سمت کاربر — بازخورد فوری به‌جای ۴۱۳ لبه/CDN که به‌صورت HTML می‌رسد
if (file.size > MAX_UPLOAD_BYTES) {
throw new HttpError(413, 'حجم فایل بیش از ۳۰ مگابایت است — فایل را کوچک‌تر کنید')
}
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
if (name && name.trim()) fd.append('name', name.trim())
return authFetch('/api/admin/upload', { method: 'POST', body: fd }) return authFetch('/api/admin/upload', { method: 'POST', body: fd })
}, },
/** درون‌ریزی تصویر از نشانی خارجی — دانلود و ذخیره در Object Storage خود سایت */
importImage: async (url: string): Promise<{ url: string; key: string }> =>
authFetch('/api/admin/import-image', { method: 'POST', body: JSON.stringify({ url }) }),
changePassword: (currentPassword: string, newPassword: string) => changePassword: (currentPassword: string, newPassword: string) =>
authFetch<{ ok: boolean }>('/api/auth/change-password', { authFetch<{ ok: boolean }>('/api/auth/change-password', {
method: 'POST', method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}), }),
// خروجی CSV — فایل تولیدی بک‌اند (با BOM برای Excel) مستقیم دانلود می‌شود
downloadCsv: async (kind: 'messages' | 'subscribers'): Promise<void> => {
const path = kind === 'messages' ? 'messages/export.csv' : 'subscribers/export.csv'
const token = getToken()
const res = await fetch(`${API_URL}/api/admin/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new HttpError(res.status, 'دریافت خروجی CSV ناموفق بود')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const disposition = res.headers.get('Content-Disposition') ?? ''
a.download = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `${kind}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
},
} }
+46 -3
View File
@@ -69,7 +69,10 @@ export const ENTITIES: Record<string, EntityConfig> = {
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'), F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true), F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true), F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'), F('land_area', 'مساحت عرصه (زمین)'), F('area', 'مساحت زیربنا (اعیان)'),
F('usage', 'کاربری ساختمان'), F('pilot_floors', 'طبقات روی همکف (پیلوت)'), F('floors', 'تعداد طبقات'),
F('units_residential', 'تعداد واحد مسکونی'), F('units_commercial', 'تعداد واحد تجاری'), F('units_office', 'تعداد واحد اداری'),
F('project_type', 'نوع پروژه'),
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'), F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
], ],
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] }, defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
@@ -173,6 +176,27 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)], fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
defaults: { label: 'آیتم جدید', href: '/' }, defaults: { label: 'آیتم جدید', href: '/' },
}, },
'status-labels': {
resource: 'status-labels', dataKey: 'status_labels', title: 'برچسب‌های وضعیت پروژه', kind: 'collection',
titleKey: 'label',
// نبودِ این تعریف یعنی isTranslatable همیشه false برمی‌گشت و ویرایشِ برچسب روی
// صفحهٔ انگلیسی به‌جای ستون label_en ستون فارسی را بازنویسی می‌کرد
fields: [F('label', 'برچسب'), F('key', 'کلید وضعیت', 'text', 'done یا progress', true)],
defaults: { key: 'done', label: 'برچسب جدید' },
},
'section-headings': {
resource: 'section-headings', dataKey: 'section_headings', title: 'تیتر بخش‌ها', kind: 'collection',
titleKey: 'title',
// تیتر/توضیح بخش‌ها فقط درجا (InlineEdit) ویرایش می‌شوند — نبودِ تعریف یعنی
// ویرایش روی صفحهٔ انگلیسی به‌جای ستون‌های _en ستون فارسی را بازنویسی می‌کرد
fields: [
F('key', 'کلید بخش', 'text', 'services / featured_projects / gallery / timeline / why_us / testimonials / team / articles', true),
F('label', 'برچسب بالای تیتر'), F('title', 'تیتر'), F('lead', 'توضیح', 'textarea'),
F('image', 'تصویر', 'image', undefined, true),
F('cta_label', 'متن دکمه'), F('cta_href', 'لینک دکمه', 'text', undefined, true),
],
defaults: { key: '', title: '', lead: '' },
},
seo_meta: { seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection', resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key', titleKey: 'page_key',
@@ -192,9 +216,20 @@ export const ENTITIES: Record<string, EntityConfig> = {
hero: { hero: {
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton', resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
titleKey: 'title', titleKey: 'title',
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')], // title و subtitle باید در فهرست باشند — تیتر و زیرتیتر درجا ویرایش می‌شوند و
// نبودشان یعنی isTranslatable false برمی‌گرداند و ویرایش روی /en متن فارسی را بازنویسی می‌کرد
fields: [F('title', 'تیتر اصلی'), F('title_highlight', 'واژه مسیِ تیتر'), F('subtitle', 'زیرتیتر', 'textarea'), F('kicker', 'متن بالای تیتر', 'textarea'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول')],
defaults: {}, defaults: {},
}, },
'page-heroes': {
resource: 'page-heroes', dataKey: 'page_heroes', title: 'هیروی صفحه‌ها', kind: 'collection',
titleKey: 'title',
fields: [
F('key', 'کلید صفحه', 'text', 'about / services / projects / investment / blog / faq / contact', true),
F('title', 'عنوان'), F('lead', 'سرآغاز', 'textarea'), F('image', 'تصویر', 'image', undefined, true),
],
defaults: { key: 'about', title: 'عنوان صفحه', lead: '', image: '' },
},
story: { story: {
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton', resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
titleKey: 'title', titleKey: 'title',
@@ -235,10 +270,18 @@ export const ENTITIES: Record<string, EntityConfig> = {
}, },
} }
/** تعریف موجودیت بر اساس resource (نشانی API) یا کلید فهرست — هر دو سبک در کد
* استفاده می‌شوند: save()/isTranslatable با resource (why-us) و رجکس‌های JSX با کلید
* (why_us، contact_section، process_steps و…) — هر دو باید پیدا شوند وگرنه رندر
* با «منبع ناشناخته» می‌شکند. */
export function entityConfig(resource: string): EntityConfig | undefined {
return ENTITIES[resource] ?? Object.values(ENTITIES).find((c) => c.resource === resource)
}
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */ /** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
export function isTranslatable(resource: string, name: string): boolean { export function isTranslatable(resource: string, name: string): boolean {
if (name.endsWith('_en')) return false if (name.endsWith('_en')) return false
const config = ENTITIES[resource] const config = entityConfig(resource)
return !!config?.fields.some((f) => f.name === name && !f.shared) return !!config?.fields.some((f) => f.name === name && !f.shared)
} }
+65
View File
@@ -0,0 +1,65 @@
// لایهٔ نازک تحلیل رفتار — OpenPanel.
//
// نمونهٔ OpenPanel از قبل روی این سرور بالا است (stats.bennu-api-bahrestan.ir) و
// پروژهٔ ERP هم از همان استفاده می‌کند؛ به‌جای اجرای یک ابزار تحلیل دوم (Umami)،
// همین یک نمونه با یک «پروژهٔ» دوم برای این سایت به کار می‌رود.
//
// اگر VITE_OPENPANEL_URL و VITE_OPENPANEL_CLIENT_ID در زمان بیلد تنظیم نشده باشند،
// همهٔ فراخوانی‌ها بی‌اثر و ارزان‌اند (مثل حالت فعلی Umami).
//
// مسیر Umami دست‌نخورده مانده تا اگر اسکریپت قدیمی جایی فعال بود از کار نیفتد.
declare global {
interface Window {
op?: (...args: unknown[]) => void
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
}
}
const OP_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim()
const OP_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim()
const OP_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim()
// شیم صف OpenPanel — تا رسیدن op1.js، فراخوانی‌ها در صف می‌مانند و بعد تخلیه می‌شوند.
function loadOpenPanelScript(src: string): void {
if (document.querySelector('script[data-openpanel]')) return
const shim = document.createElement('script')
shim.textContent =
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();'
document.head.appendChild(shim)
const script = document.createElement('script')
script.src = src
script.async = true
script.defer = true
script.dataset.openpanel = '1'
document.head.appendChild(script)
}
/** یک‌بار در شروع اپ صدا زده می‌شود؛ بدون تنظیمات، هیچ کاری نمی‌کند. */
export function initAnalytics(): void {
if (!OP_URL || !OP_CLIENT_ID) return
try {
if (OP_SCRIPT_URL) loadOpenPanelScript(OP_SCRIPT_URL)
window.op?.('init', {
apiUrl: OP_URL,
clientId: OP_CLIENT_ID,
trackScreenViews: true, // بازدید صفحه → کدام صفحات دیده می‌شوند
})
} catch {
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
}
}
/** رخداد سفارشی — در داشبورد OpenPanel نمایش داده می‌شود. */
export function track(event: string, data?: Record<string, unknown>) {
try {
window.op?.('track', event, data ?? {})
} catch {
/* ignore */
}
try {
window.umami?.track(event, data)
} catch {
/* ignore */
}
}
+24 -19
View File
@@ -249,6 +249,24 @@ export function SiteProvider({ children }: { children: ReactNode }) {
setError(false) setError(false)
const controller = new AbortController() const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS) const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
// درخواست‌های سایت و پیش‌نویس‌های ادمین «همزمان» شروع می‌شوند —
// پشت سر هم بودنشان یعنی ادمین دو برابر دیرتر محتوا را می‌بیند.
const draftsPromise: Promise<Article[] | null> = getToken()
? fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
signal: controller.signal,
})
.then(async (res) => {
if (res.status === 401) {
// توکن منقضی/نامعتبر — پاک می‌شود تا بارگذاری‌های بعدی درخواست بی‌فایده نفرستند
setToken(null)
return null
}
return res.ok ? ((await res.json()) as Article[]) : null
})
.catch(() => null)
: Promise.resolve(null)
fetch(`${API_URL}/api/site`, { signal: controller.signal }) fetch(`${API_URL}/api/site`, { signal: controller.signal })
.then((res) => { .then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -256,25 +274,12 @@ export function SiteProvider({ children }: { children: ReactNode }) {
}) })
.then(async (json) => { .then(async (json) => {
if (cancelled) return if (cancelled) return
// برای ادمین واردشده، پیش‌نویس مقاله‌ها از انتهای مدیریتی خوانده و ادغام می‌شوند — // بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند —
// بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند // پیش‌نویس‌ها (برای ادمین) از درخواست موازی ادغام می‌شوند
if (getToken()) { const drafts = await draftsPromise
try { if (drafts) {
const res = await fetch(`${API_URL}/api/admin/articles`, { const published = new Set(json.articles.map((a) => a.id))
headers: { Authorization: `Bearer ${getToken()}` }, json.articles = [...json.articles, ...drafts.filter((a) => a.status === 'draft' && !published.has(a.id))]
})
if (res.status === 401) {
// توکن منقضی/نامعتبر — پاک می‌شود تا بارگذاری‌های بعدی درخواست بی‌فایده نفرستند
setToken(null)
} else if (res.ok) {
const all = (await res.json()) as Article[]
const published = new Set(json.articles.map((a) => a.id))
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
json.articles = [...json.articles, ...drafts]
}
} catch {
/* بدون پیش‌نویس‌ها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
}
} }
if (!cancelled) { if (!cancelled) {
if (json.brand && json.hero && json.footer && json.nav) setData(json) if (json.brand && json.hero && json.footer && json.nav) setData(json)
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon } from 'lucide-react'
/** یک پرسش‌وپاسخ مقاله — همان شکل داده ستون faqs مقاله در دیتابیس */
export type ArticleFaq = { question: string; answer: string }
/**
* پرسش‌وپاسخ‌های پایان مقاله — همان رفتار آکاردئونی بخش FAQ سایت، بدون ویرایش مدیریتی.
* در سرچ کنسول/نتایج غنی گوگل، اسکیمای FAQPage آن جداگانه تزریق می‌شود.
*/
export function ArticleFaq({ faqs }: { faqs: ArticleFaq[] }) {
const [open, setOpen] = useState<number | null>(0)
if (!faqs.length) return null
return (
<section className="mt-16" aria-label="پرسش‌های متداول مقاله">
<div className="mt-6 h-px w-24 bg-copper/70" />
<ul className="mt-2">
{faqs.map((faq, i) => {
const isOpen = open === i
return (
<li key={i} className="border-b border-ivory/10">
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
>
<span
className={`text-base font-bold md:text-lg ${
isOpen ? 'text-copper' : 'text-ivory'
}`}
>
{faq.question}
</span>
<PlusIcon
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
isOpen ? 'rotate-45' : ''
}`}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 ps-2 text-sm font-light leading-9 text-ivory/60">
{faq.answer}
</p>
</motion.div>
)}
</AnimatePresence>
</li>
)
})}
</ul>
</section>
)
}
+110
View File
@@ -0,0 +1,110 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
// استایل‌های فوق‌لوکس شیشه‌ای (Glassmorphic) با رنگ‌های اختصاصی پروژه
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
copper: {
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
text: 'group-hover:text-char font-semibold',
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
},
outline: {
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبه‌ها
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
fill: 'bg-white/10',
text: 'group-hover:text-copper',
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
},
dark: {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-copper',
text: 'group-hover:text-char',
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
},
'dark-ivory': {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-ivory',
text: 'group-hover:text-char',
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
{/* خط انعکاس نوری شیشه در بالای دکمه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
{/* انیمیشن پر شدن موجی و نرم */}
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
/>
{/* متن و آیکون دکمه */}
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
>
{children}
</span>
</>
)
}
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشه‌ای
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
const style = STYLES[variant]
return (
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const style = STYLES[variant]
return (
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const style = STYLES[variant]
return (
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
import { track } from '../analytics';
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
streaming?: boolean;
error?: boolean;
};
type SseFrame = {
content?: string;
error?: string;
done?: boolean;
};
/** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */
function getThreadId(): string {
const KEY = 'chat_thread_id';
let id = sessionStorage.getItem(KEY);
if (!id) {
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
sessionStorage.setItem(KEY, id);
}
return id;
}
/** خواندن فریم‌های SSE از پاسخ fetch (استریم توکن‌به‌توکن) */
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
const reader = response.body?.getReader();
if (!reader) throw new Error('پاسخ استریمی ندارد');
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
try {
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
} catch {
// فریم ناقص — نادیده بگیر
}
}
}
}
}
export function ChatWidget() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const scrollEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const assistantIndexRef = useRef<number | null>(null);
// پیشنهاد پرسش‌ها فقط یک بار برای هر بازشدن اول
useEffect(() => {
if (!open || suggestions.length > 0) return;
fetch('/chat/suggestions')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
.catch(() => setSuggestions([]));
}, [open, suggestions.length]);
useEffect(() => {
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, open]);
const sendMessage = async (text: string) => {
const question = text.trim();
if (!question || isLoading) return;
track('chat-question')
setInput('');
setIsLoading(true);
setMessages((prev) => {
const next: ChatMessage[] = [
...prev,
{ role: 'user', content: question },
{ role: 'assistant', content: '', streaming: true },
];
assistantIndexRef.current = next.length - 1;
return next;
});
const patchAssistant = (patch: Partial<ChatMessage>) => {
setMessages((prev) => {
const idx = assistantIndexRef.current;
if (idx === null || !prev[idx]) return prev;
const updated = [...prev];
updated[idx] = { ...updated[idx], ...patch };
return updated;
});
};
try {
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
const response = await fetch(`/chat/stream?${params.toString()}`, {
headers: { Accept: 'text/event-stream' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let accumulated = '';
await readSse(response, (frame) => {
if (frame.error) {
patchAssistant({ content: frame.error, streaming: false, error: true });
return;
}
if (frame.content) {
accumulated += frame.content;
patchAssistant({ content: accumulated });
}
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
});
patchAssistant({ streaming: false });
} catch (err) {
patchAssistant({
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
streaming: false,
error: true,
});
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
return (
<>
{/* دکمه شناور — گوشه راست‌پایین (دکمه تماس سمت چپ است) */}
<button
type="button"
onClick={() => {
setOpen((v) => {
if (!v) track('chat-open');
return !v;
});
}}
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
</button>
{/* پنل گفتگو */}
{open && (
<div
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
{/* سربرگ */}
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
<SparklesIcon className="h-4 w-4" />
</span>
<div className="flex-1">
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="بستن"
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
<XIcon className="h-4 w-4" />
</button>
</div>
{/* پیام‌ها */}
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="mt-6 text-center">
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
سلام! هر سؤالی درباره خدمات، پروژه‌ها و مقالات
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
دارید بپرسید.
</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
msg.role === 'user'
? 'rounded-bl-md bg-copper text-char'
: msg.error
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
}`}>
{msg.content || (msg.streaming ? '…' : '')}
{msg.streaming && msg.content && (
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
)}
</div>
</div>
))}
<div ref={scrollEndRef} />
</div>
{/* پیشنهاد پرسش‌ها */}
{suggestions.length > 0 && messages.length === 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
{suggestions.map((q) => (
<button
key={q}
type="button"
onClick={() => sendMessage(q)}
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
{q}
</button>
))}
</div>
)}
{/* ورودی */}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
}}
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="پرسش خود را بنویسید…"
disabled={isLoading}
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
aria-label="ارسال"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
<SendIcon className="h-4 w-4 -scale-x-100" />
</button>
</form>
</div>
)}
</>
);
}
+14 -8
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react' import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Reveal } from './Reveal' import { Reveal } from './Reveal'
import { PremiumButton } from './PremiumButton' import { Button } from './Button'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext' import { useFieldSaver } from '../admin/AdminContext'
@@ -194,15 +194,17 @@ export function ContactSection() {
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70"> <p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} /> <InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
</p> </p>
<PremiumButton <Button
type="button"
variant="outline"
className="mt-8 font-light"
onClick={() => { onClick={() => {
setSent(false) setSent(false)
setForm({ name: '', phone: '', note: '' }) setForm({ name: '', phone: '', note: '' })
}} }}
className="mt-8 font-light"
> >
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} /> <InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
</PremiumButton> </Button>
</div> </div>
) : ( ) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2"> <form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
@@ -251,17 +253,21 @@ export function ContactSection() {
</div> </div>
<div className="md:col-span-2 flex justify-start"> <div className="md:col-span-2 flex justify-start">
<PremiumButton <Button
type="submit" type="submit"
variant="copper"
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3" className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
disabled={sending} disabled={sending}
> >
{sending ? ( {sending ? (
content.sending_label content.sending_label
) : ( ) : (
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} /> <>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</>
)} )}
</PremiumButton> </Button>
</div> </div>
</form> </form>
)} )}
@@ -2,6 +2,7 @@ import React from 'react';
import { PhoneIcon } from 'lucide-react'; import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
import { track } from '../analytics';
export function FloatingContact() { export function FloatingContact() {
const { brand } = useSite(); const { brand } = useSite();
@@ -11,6 +12,7 @@ export function FloatingContact() {
<a <a
href={brand.phone_href} href={brand.phone_href}
aria-label={t('callBrand')} aria-label={t('callBrand')}
onClick={() => track('phone-click', { from: 'floating' })}
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95" className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
> >
{/* پالس هاله نوری در پس‌زمینه مانند رادار */} {/* پالس هاله نوری در پس‌زمینه مانند رادار */}
+5 -67
View File
@@ -1,18 +1,7 @@
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang' import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
/**
* تنظیمات ثابت هر زبان.
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
*/
const LANGS = {
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
en: { label: 'English', sliderTranslate: 'translate-x-full' },
} as const
type LangKey = keyof typeof LANGS
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */ /** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) { export function LangToggle({ className = '' }: { className?: string }) {
const { lang, t } = useLang() const { lang, t } = useLang()
@@ -33,61 +22,10 @@ export function LangToggle({ className = '' }: { className?: string }) {
onClick={switchLang} onClick={switchLang}
aria-label={t('switchLangAria')} aria-label={t('switchLangAria')}
title={t('switchLangAria')} title={t('switchLangAria')}
dir="ltr" className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
className={[
// ── Capsule shell ──────────────────────────────────────────────
'relative flex items-center',
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
'bg-[#1a1715]',
// deep inset shadow → recessed track feel
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
// interaction
'cursor-pointer select-none',
'active:scale-[0.975] transition-transform duration-150 ease-lux',
className,
].join(' ')}
> >
{/* ── Inner Track ─────────────────────────────────────────────── <LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
این دیو داخلی باعث می‌شود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد <span>{t('switchLang')}</span>
*/}
<div className="relative w-full h-full flex items-center">
{/* ── Slider pill ─────────────────────────────────────────────── */}
<span
aria-hidden="true"
className={[
'absolute top-0 left-0', // چسبیده به لبه‌های Track داخلی
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
'bg-[#27221c] border border-[#d5874c]',
// subtle raised shadow — tactile without being flashy
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
'transition-transform duration-[380ms] ease-lux',
LANGS[lang as LangKey].sliderTranslate,
].join(' ')}
/>
{/* ── Labels ──────────────────────────────────────────────────── */}
<span className="relative z-10 flex w-full h-full">
{(Object.keys(LANGS) as LangKey[]).map((key) => {
const isActive = lang === key
return (
<span
key={key}
className={[
// each label occupies exactly half the capsule
'w-1/2 h-full flex justify-center items-center',
'text-[11.5px] font-semibold tracking-wide leading-none',
'transition-colors duration-[380ms] ease-lux',
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
].join(' ')}
>
{LANGS[key].label}
</span>
)
})}
</span>
</div>
</button> </button>
) )
} }
+8 -1
View File
@@ -74,8 +74,15 @@ export function PageHero({ pageKey }: PageHeroProps) {
</motion.p> </motion.p>
<div className="mt-8 h-px w-28 bg-copper" /> <div className="mt-8 h-px w-28 bg-copper" />
{/* ویرایش تصویر پس‌زمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */} {/* ویرایش هیروی صفحه (عنوان/سرآغاز/تصویر)، تصویر پس‌زمینه و سئو — فقط در حالت ویرایش ادمین */}
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0"> <div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
<SingletonPill
resource="page-heroes"
label="عنوان، سرآغاز و تصویر هیروی این صفحه"
select={(d) => d.page_heroes.find((h) => h.key === pageKey)}
// اگر ردیف هیروی این صفحه هنوز ساخته نشده باشد، همین‌جا ساخته می‌شود
createDefaults={() => ({ key: pageKey, title: '', lead: '', image: '' })}
/>
<SingletonPill <SingletonPill
resource="seo-meta" resource="seo-meta"
label="عنوان و توضیح سئوی این صفحه" label="عنوان و توضیح سئوی این صفحه"
+8 -1
View File
@@ -2,7 +2,14 @@ import { Link } from 'react-router-dom'
import { useStatusLabel } from '../api' import { useStatusLabel } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { PremiumButton } from './PremiumButton' import { PremiumButton } from './PremiumButton'
import type { StaticProject } from '../data/projectsData' export type StaticProject = {
id: string
slug: string
name: { en: string; fa: string }
location: { en: string; fa: string }
area: { en: string; fa: string }
status: 'done' | 'progress'
}
import type { ProjectCardItem } from '../data/site' import type { ProjectCardItem } from '../data/site'
type ProjectCardProps = { type ProjectCardProps = {
+4 -2
View File
@@ -110,7 +110,7 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
</p> </p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" /> <div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود می‌شود تا بریده نشود */} {/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود می‌شود تا بریده نشود */}
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]"> <ul className="no-scrollbar mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
{items.map((item) => { {items.map((item) => {
const isActive = item.id === activeId const isActive = item.id === activeId
return ( return (
@@ -120,7 +120,9 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
onClick={(event) => go(event, item.id)} onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined} aria-current={isActive ? 'location' : undefined}
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${ className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'ps-4 text-[13px]' : '' // H3 زیرمجموعهٔ H2 است: تورفتگی واضح‌تر و یک پله کوچک‌تر؛ با ps-4
// تفاوتش با H2 فقط ۴ پیکسل بود و به چشم نمی‌آمد.
item.level === 3 ? 'ps-6 text-[13px]' : ''
} ${ } ${
isActive isActive
? 'font-normal text-copper' ? 'font-normal text-copper'
+139 -7
View File
@@ -102,6 +102,42 @@ const CLOSED_LINK_MODAL: LinkModalState = {
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */ /** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */
const URL_LOOKBACK = 300 const URL_LOOKBACK = 300
/** آیا src تصویر خارج از میزبانی سایت است (مثل چسبانده‌شده از Outline)؟ */
function isExternalImageSrc(src: string): boolean {
if (!src || src.startsWith('data:')) return false
if (src.startsWith('/')) return false // مسیر نسبی/داخلی
try {
return new URL(src).origin !== window.location.origin
} catch {
return true
}
}
/**
* میزبانی مجدد تصاویر خارجی یک HTML — هر <img> با نشانی خارجی از سرور دانلود و در
* Object Storage خود سایت ذخیره و src آن بازنویسی می‌شود (چسباندن از Outline و مانند آن).
* تصویری که درون‌ریزی‌اش شکست بخورد با src اصلی می‌ماند.
*/
async function rehostExternalImages(html: string): Promise<string> {
const parsed = new DOMParser().parseFromString(html, 'text/html')
const imgs = Array.from(parsed.querySelectorAll('img[src]'))
const externals = imgs.filter((img) => isExternalImageSrc(img.getAttribute('src') ?? ''))
if (!externals.length) return html
await Promise.all(
externals.map(async (img) => {
const original = img.getAttribute('src') ?? ''
try {
const { url } = await adminApi.importImage(original)
img.setAttribute('src', url)
} catch {
// نشانی خارجی می‌ماند — شاید خودش کار کند
}
}),
)
return parsed.body.innerHTML
}
function ToolButton({ function ToolButton({
onClick, onClick,
active = false, active = false,
@@ -144,6 +180,8 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [direction, setDirection] = useState<TextDirection>('rtl') const [direction, setDirection] = useState<TextDirection>('rtl')
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL) const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
// مرحله نام‌گذاری درج تصویر از نوار ابزار — پیش‌فرض از نام خود فایل
const [pendingImage, setPendingImage] = useState<{ file: File; name: string } | null>(null)
const editor = useEditor({ const editor = useEditor({
extensions: ARTICLE_EXTENSIONS, extensions: ARTICLE_EXTENSIONS,
@@ -163,6 +201,22 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
void uploadAndInsert(file) void uploadAndInsert(file)
return true return true
} }
// محتوای HTML با تصویر خارجی (Outline و مانند آن) — تصاویر به میزبانی سایت منتقل می‌شوند
const html = event.clipboardData?.getData('text/html') ?? ''
if (html.includes('<img')) {
const parsed = new DOMParser().parseFromString(html, 'text/html')
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
isExternalImageSrc(img.getAttribute('src') ?? ''),
)
if (hasExternal) {
event.preventDefault()
void (async () => {
const transformed = await rehostExternalImages(html)
editor?.chain().focus().insertContent(transformed).run()
})()
return true
}
}
// چسباندن یک نشانی → مودال لینک بلافاصله باز می‌شود // چسباندن یک نشانی → مودال لینک بلافاصله باز می‌شود
const text = event.clipboardData?.getData('text/plain')?.trim() ?? '' const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
if (isUrlLike(text)) { if (isUrlLike(text)) {
@@ -186,6 +240,21 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
void uploadAndInsert(file) void uploadAndInsert(file)
return true return true
} }
const html = event.dataTransfer?.getData('text/html') ?? ''
if (html.includes('<img')) {
const parsed = new DOMParser().parseFromString(html, 'text/html')
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
isExternalImageSrc(img.getAttribute('src') ?? ''),
)
if (hasExternal) {
event.preventDefault()
void (async () => {
const transformed = await rehostExternalImages(html)
editor?.chain().focus().insertContent(transformed).run()
})()
return true
}
}
return false return false
}, },
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز می‌شود // پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز می‌شود
@@ -237,17 +306,20 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
editor.commands.setContent(incoming, { emitUpdate: false }) editor.commands.setContent(incoming, { emitUpdate: false })
}, [editor, contentKey, doc]) }, [editor, contentKey, doc])
async function uploadAndInsert(file: File) { async function uploadAndInsert(file: File, name?: string) {
if (!editor) return if (!editor) return
setUploading(true) setUploading(true)
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file) const { url } = await adminApi.upload(file, name)
// نام پیش‌فرض از نام فایل ساخته می‌شود و در نبود آن، مقدار پیش‌فرض ثابت می‌ماند // «نام تصویر»ای که کاربر پیش از بارگذاری نوشته، همان Alt تصویر می‌شود؛
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() // در نبود آن نام فایل و در نبود آن مقدار پیش‌فرض — تا نیازی به اصلاح دستی
// در جعبهٔ زیر تصویر نباشد.
const typedName = (name ?? '').trim()
const fileAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
editor editor
.chain() .chain()
.focus() .focus()
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT }) .setImage({ src: url, alt: typedName || fileAlt || DEFAULT_IMAGE_ALT })
.run() .run()
}) })
setUploading(false) setUploading(false)
@@ -407,16 +479,76 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
ref={fileRef} ref={fileRef}
type="file" type="file"
data-editor-file-input data-editor-file-input
accept="image/jpeg,image/png,image/webp,image/gif,image/avif" accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) void uploadAndInsert(file) if (file) {
setPendingImage({ file, name: file.name.replace(/\.[^.]+$/, '') })
}
e.target.value = '' e.target.value = ''
}} }}
/> />
</div> </div>
{/* مرحله نام‌گذاری تصویر — نام خوانای ذخیره‌سازی با یک کلیک تایید می‌شود */}
{pendingImage && (
<div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="نام تصویر"
onClick={() => setPendingImage(null)}
>
<div
dir="rtl"
className="w-full max-w-sm border border-ivory/15 bg-ink p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-sm font-bold text-copper">درج تصویر</h2>
<label htmlFor="image-name-input" className="mt-4 block text-xs font-light text-ivory/60">
نام فایل تصویر (برای نشانی ذخیره‌سازی — اختیاری)
</label>
<input
id="image-name-input"
type="text"
dir="auto"
autoFocus
value={pendingImage.name}
onChange={(e) => setPendingImage({ ...pendingImage, name: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const { file, name } = pendingImage
setPendingImage(null)
void uploadAndInsert(file, name)
}
}}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
<div className="mt-5 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => setPendingImage(null)}
className="border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
>
انصراف
</button>
<button
type="button"
onClick={() => {
const { file, name } = pendingImage
setPendingImage(null)
void uploadAndInsert(file, name)
}}
className="bg-copper px-6 py-2 text-xs font-bold text-char transition-transform hover:scale-105"
>
درج تصویر
</button>
</div>
</div>
</div>
)}
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */} {/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && ( {linkModal.open && (
<LinkModal <LinkModal
@@ -0,0 +1,153 @@
import { useRef, useState } from 'react'
import { ArrowDownIcon, ArrowUpIcon, ImagePlusIcon, Loader2Icon, XIcon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient'
type GalleryFieldProps = {
label: string
value: string[]
/** فهرست کامل نشانی‌ها را ذخیره می‌کند — همه از MinIO */
onSave: (urls: string[]) => Promise<boolean>
/** پیشوند نام ذخیره‌سازی فایل‌ها — مثلاً اسلاگ پروژه */
namePrefix?: string
}
/**
* گالری چندتصویری — فقط آپلود به MinIO.
* هیچ ورودی URL وجود ندارد؛ نشانی‌ها فقط از آپلود ساخته می‌شوند.
* هر تصویر: حذف، جابه‌جایی بالا/پایین. چند فایل همزمان قابل آپلود است.
*/
export function GalleryField({ label, value, onSave, namePrefix = 'image' }: GalleryFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(0)
const [error, setError] = useState('')
const commit = async (urls: string[]) => {
if (!(await onSave(urls))) setError('ذخیرهٔ گالری ناموفق بود — دوباره تلاش کنید')
}
const addFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return
setError('')
setUploading(files.length)
const next = [...value]
try {
let i = 0
for (const file of Array.from(files)) {
i += 1
const base = file.name.replace(/\.[^.]+$/, '')
const name = `${namePrefix}-${Date.now()}-${i}`
try {
const { url } = await adminApi.upload(file, `${name}-${base}`)
next.push(url)
} catch {
setError(`آپلود «${file.name}» ناموفق بود`)
} finally {
setUploading((n) => n - 1)
}
}
if (next.length !== value.length) await commit(next)
} finally {
setUploading(0)
if (inputRef.current) inputRef.current.value = ''
}
}
const removeAt = async (index: number) => {
const next = value.filter((_, i) => i !== index)
await commit(next)
}
const move = async (index: number, delta: number) => {
const target = index + delta
if (target < 0 || target >= value.length) return
const next = [...value]
;[next[index], next[target]] = [next[target], next[index]]
await commit(next)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
{value.length === 0 ? (
<div className="mt-2 flex h-24 w-full flex-col items-center justify-center gap-1 border border-dashed border-ivory/20 text-ivory/30">
<ImagePlusIcon className="h-5 w-5" aria-hidden="true" />
<span className="text-[11px] font-light">تصویری در گالری نیست</span>
</div>
) : (
<ul className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{value.map((url, index) => (
<li key={`${url}-${index}`} className="group relative overflow-hidden rounded-lg border border-ivory/15 bg-char">
<img src={url} alt="" className="h-24 w-full object-cover" />
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-char/80 px-1.5 py-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
<div className="flex gap-1">
<button
type="button"
aria-label="انتقال به قبل"
title="انتقال به قبل"
disabled={index === 0}
onClick={() => void move(index, -1)}
className="flex h-6 w-6 items-center justify-center border border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char disabled:opacity-40"
>
<ArrowUpIcon className="h-3 w-3" />
</button>
<button
type="button"
aria-label="انتقال به بعد"
title="انتقال به بعد"
disabled={index === value.length - 1}
onClick={() => void move(index, 1)}
className="flex h-6 w-6 items-center justify-center border border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char disabled:opacity-40"
>
<ArrowDownIcon className="h-3 w-3" />
</button>
</div>
<button
type="button"
aria-label="حذف تصویر"
title="حذف تصویر"
onClick={() => void removeAt(index)}
className="flex h-6 w-6 items-center justify-center border border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20"
>
<XIcon className="h-3 w-3" />
</button>
</div>
</li>
))}
</ul>
)}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading > 0}
className="mt-3 flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading > 0 ? (
<>
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
در حال آپلود {uploading} تصویر…
</>
) : (
<>
<ImagePlusIcon className="h-3.5 w-3.5" />
افزودن تصویر به گالری (آپلود به MinIO)
</>
)}
</button>
{error && <p className="mt-2 text-xs text-red-300">{error}</p>}
<input
ref={inputRef}
type="file"
multiple
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
className="hidden"
onChange={(event) => {
void addFiles(event.target.files)
}}
/>
</div>
)
}
+25 -3
View File
@@ -48,7 +48,10 @@ export function ImageField({
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const titleId = useId() const titleId = useId()
const altId = useId() const altId = useId()
const nameId = useId()
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
/** نام دلخواه فایل برای ذخیره‌سازی خوانا — پیش‌فرض از نام فایل انتخابی ساخته می‌شود */
const [fileBaseName, setFileBaseName] = useState('')
const showTitle = title !== undefined || !!onSaveTitle const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt const showAlt = alt !== undefined || !!onSaveAlt
@@ -73,13 +76,16 @@ export function ImageField({
setUploading(true) setUploading(true)
try { try {
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file) // نام ذخیره‌سازی: نام تایپ‌شده کاربر، وگرنه نام خود فایل بدون پسوند
const name = fileBaseName.trim() || file.name.replace(/\.[^.]+$/, '')
const { url } = await adminApi.upload(file, name)
const ok = await onSave(url) const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد') if (!ok) throw new Error('ذخیره نشد')
// پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند // پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showTitle) await titleField.commit() if (showTitle) await titleField.commit()
if (showAlt) await altField.commit() if (showAlt) await altField.commit()
}) })
setFileBaseName('')
} finally { } finally {
setUploading(false) setUploading(false)
} }
@@ -106,7 +112,7 @@ export function ImageField({
)} )}
</div> </div>
<div className="mt-3"> <div className="mt-3 flex flex-wrap items-end gap-3">
<button <button
type="button" type="button"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
@@ -120,6 +126,22 @@ export function ImageField({
)} )}
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'} {uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
</button> </button>
{/* نام ذخیره‌سازی فایل — خالی یعنی از نام خود فایل استفاده شود */}
<div className="w-56">
<label htmlFor={nameId} className="text-[11px] font-light text-ivory/50">
نام فایل تصویر (اختیاری)
</label>
<input
id={nameId}
type="text"
dir="auto"
value={fileBaseName}
onChange={(event) => setFileBaseName(event.target.value)}
placeholder="مثلاً: elahieh-tower"
className={META_FIELD_CLASS.replace('mt-1.5 ', '')}
/>
</div>
</div> </div>
{(showTitle || showAlt) && ( {(showTitle || showAlt) && (
@@ -169,7 +191,7 @@ export function ImageField({
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif" accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
className="hidden" className="hidden"
onChange={(event) => { onChange={(event) => {
void pick(event.target.files?.[0]) void pick(event.target.files?.[0])
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { NodeViewWrapper } from '@tiptap/react' import { NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react' import type { NodeViewProps } from '@tiptap/react'
import { PlusIcon, XIcon } from 'lucide-react' import { CloudUploadIcon, PlusIcon, XIcon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
type ImageAttrs = { type ImageAttrs = {
src: string src: string
@@ -19,25 +21,39 @@ const INLINE_FIELD_CLASS =
const ADD_CAPTION_CLASS = const ADD_CAPTION_CLASS =
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper' 'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
/** تصاویر میزبان خارجی (مثل چسبانده‌شده از Outline) قابل میزبانی مجدد هستند */
function isExternalImage(src: string): boolean {
if (!src) return false
if (src.startsWith('/media/') || src.startsWith('/')) return false
try {
return new URL(src, window.location.origin).origin !== window.location.origin
} catch {
return true
}
}
/** /**
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت: * نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
* <figure><img><figcaption>کپشن</figcaption></figure> * <figure><img><figcaption>کپشن</figcaption></figure>
* *
* ویرایش کاملاً درون‌خطی است (بدون پاپ‌اور/مودال): * ویرایش کاملاً درون‌خطی است (بدون پاپ‌اور/مودال):
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره می‌شود. * - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره می‌شود.
* - دکمه X کپشن را کامل حذف می‌کند: صفت کپشن به مقدار پیش‌فرض برمی‌گردد و عنصر * - دکمه X کپشن را کامل حذف می‌کند؛ «افزودن کپشن» دوباره جعبه را باز می‌کند.
* figcaption با ورودی‌اش از DOM ویرایشگر (و از خروجی ذخیره‌شده) حذف می‌شود؛ * - Alt و Title تصویر (نام تصویر برای سئو/دسترس‌پذیری) پس از درج هم قابل ویرایش‌اند.
* فقط تصویر می‌ماند و «افزودن کپشن» دوباره جعبه را باز می‌کند. * - تصویر میزبان خارجی دکمه «میزبانی مجدد» دارد: تصویر دانلود و در Object Storage
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت می‌شود. * خود سایت ذخیره می‌شود (ترمیم تصاویر چسبانده‌شده از Outline).
* - دکمه X روی تصویر کل تصویر را حذف می‌کند. * - دکمه X روی تصویر کل تصویر را حذف می‌کند.
*/ */
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) { export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs const { src, alt, title, caption } = node.attrs as ImageAttrs
const { runSave } = useAdmin()
const hasCaption = Boolean((caption ?? '').trim()) const hasCaption = Boolean((caption ?? '').trim())
const [captionOpen, setCaptionOpen] = useState(true) const [captionOpen, setCaptionOpen] = useState(true)
const [rehosting, setRehosting] = useState(false)
const captionInputRef = useRef<HTMLInputElement>(null) const captionInputRef = useRef<HTMLInputElement>(null)
const focusOnOpen = useRef(false) const focusOnOpen = useRef(false)
const showCaption = captionOpen || hasCaption const showCaption = captionOpen || hasCaption
const external = isExternalImage(src)
// فقط وقتی کاربر «افزودن کپشن» را می‌زند ورودی فوکوس می‌گیرد (نه هنگام بارگذاری مقاله) // فقط وقتی کاربر «افزودن کپشن» را می‌زند ورودی فوکوس می‌گیرد (نه هنگام بارگذاری مقاله)
useEffect(() => { useEffect(() => {
@@ -57,6 +73,16 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
setCaptionOpen(true) setCaptionOpen(true)
} }
/** میزبانی مجدد تصویر خارجی — دانلود از منبع و ذخیره در Object Storage سایت */
const rehost = () => {
if (rehosting) return
setRehosting(true)
void runSave(async () => {
const { url } = await adminApi.importImage(src)
updateAttributes({ src: url })
}).finally(() => setRehosting(false))
}
return ( return (
<NodeViewWrapper <NodeViewWrapper
className={`article-figure-node ${selected ? 'is-selected' : ''}`} className={`article-figure-node ${selected ? 'is-selected' : ''}`}
@@ -77,6 +103,42 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<XIcon className="h-3.5 w-3.5" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
{/* تصویر خارجی (مثل Outline) — منتقل به میزبانی سایت */}
{external && (
<button
type="button"
contentEditable={false}
onClick={rehost}
disabled={rehosting}
aria-label="میزبانی مجدد تصویر"
title="تصویر از منبع خارجی دانلود و در سایت ذخیره می‌شود"
className="absolute right-2 top-2 z-10 flex items-center gap-1.5 border border-copper/60 bg-ink/85 px-2.5 py-1 text-[11px] font-light text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char disabled:opacity-60"
>
<CloudUploadIcon className={`h-3.5 w-3.5 ${rehosting ? 'animate-pulse' : ''}`} />
{rehosting ? 'در حال انتقال…' : 'میزبانی مجدد'}
</button>
)}
{/* نام تصویر — Alt و Title پس از درج هم قابل ویرایش‌اند */}
<div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
<input
type="text"
value={alt ?? ''}
onChange={(event) => updateAttributes({ alt: event.target.value })}
placeholder="نام تصویر / متن جایگزین (Alt)…"
aria-label="متن جایگزین تصویر"
className={INLINE_FIELD_CLASS}
/>
<input
type="text"
value={title ?? ''}
onChange={(event) => updateAttributes({ title: event.target.value })}
placeholder="عنوان تصویر (Title)…"
aria-label="عنوان تصویر"
className={INLINE_FIELD_CLASS}
/>
</div>
{showCaption ? ( {showCaption ? (
<figcaption className="article-figure__caption" contentEditable={false}> <figcaption className="article-figure__caption" contentEditable={false}>
<input <input
+5 -4
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal'; import { Reveal } from '../Reveal';
import { PremiumButton } from '../PremiumButton'; import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard'; import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api'; import { useSectionHeading, useSite } from '../../api';
import { useLang } from '../../i18n/lang'; import { useLang } from '../../i18n/lang';
@@ -53,9 +53,10 @@ export function Articles() {
{heading.cta_label && heading.cta_href && ( {heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<PremiumButton to={href(heading.cta_href)} className="font-light"> <ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} /> <InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
</PremiumButton> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</div> </div>
</Reveal> </Reveal>
)} )}
+7 -9
View File
@@ -18,22 +18,20 @@ export function Faq({ hideHeading = false }: FaqProps) {
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title"> <section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
{/* Header — centered, full width */} <div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
{!hideHeading && ( {!hideHeading && (
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center"> <div className="lg:col-span-4">
<SectionHeading <SectionHeading
title={t('fallbackFaqTitle')} title={t('fallbackFaqTitle')}
lead={t('fallbackFaqLead')} lead={t('fallbackFaqLead')}
headingKey="faq" headingKey="faq"
align="center"
/> />
</div> </div>
)} )}
{/* Accordion list — constrained width, direction-aware */} <div className={hideHeading ? "" : "lg:col-span-8"}>
<div className="w-full max-w-3xl mx-auto">
<ul> <ul>
{faqs.map((faq, i) => { {faqs.map((faq, i) => {
const isOpen = open === i const isOpen = open === i
@@ -45,9 +43,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
type="button" type="button"
onClick={() => setOpen(isOpen ? null : i)} onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen} aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper" className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
> >
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}> <span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question} {faq.question}
</span> </span>
<PlusIcon <PlusIcon
@@ -65,7 +63,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden" className="overflow-hidden"
> >
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p> <p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -10,10 +10,9 @@ import { useFieldSaver } from '../../admin/AdminContext'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import type { ProjectStatus } from '../../data/site' import type { ProjectStatus } from '../../data/site'
import { projectsData } from '../../data/projectsData'
export function FeaturedProjects() { export function FeaturedProjects() {
const { status_labels } = useSite() const { status_labels, projects } = useSite()
const { t, href } = useLang() const { t, href } = useLang()
const heading = useSectionHeading('featured_projects') const heading = useSectionHeading('featured_projects')
const save = useFieldSaver() const save = useFieldSaver()
@@ -34,8 +33,8 @@ export function FeaturedProjects() {
const [status, setStatus] = useState<ProjectStatus>('done') const [status, setStatus] = useState<ProjectStatus>('done')
// Filter and slice static project data // سه پروژه از دیتابیس — ویرایش پنل همان‌جا اعمال می‌شود
const list = projectsData.filter((p) => p.status === status).slice(0, 3) const list = projects.filter((p) => p.status === status).slice(0, 3)
const BASE_IMG_URL = "/seed-media/2026/09/"; const BASE_IMG_URL = "/seed-media/2026/09/";
return ( return (
@@ -87,8 +86,7 @@ export function FeaturedProjects() {
className="grid gap-6 lg:grid-cols-3" className="grid gap-6 lg:grid-cols-3"
> >
{list.map((project, i) => { {list.map((project, i) => {
const dummyImageName = `${(i % 6) + 1}.png`; const imageUrl = project.image || undefined;
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
return ( return (
<motion.div <motion.div
+39 -14
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion' import { motion, useScroll, useTransform } from 'framer-motion'
import { ChevronDownIcon } from 'lucide-react' import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { PremiumButton } from '../PremiumButton' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
@@ -51,7 +51,7 @@ export function Hero() {
<motion.p <motion.p
initial={{ opacity: 0, y: 18 }} initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: 0.05, ease: [0.23, 1, 0.32, 1] }}
className="text-sm font-light text-copper tracking-wide" className="text-sm font-light text-copper tracking-wide"
> >
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} /> <InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
@@ -61,7 +61,7 @@ export function Hero() {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 28 }} initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: 0.12, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory leading-[1.3]" className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
> >
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} /> <InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
@@ -71,7 +71,7 @@ export function Hero() {
<motion.p <motion.p
initial={{ opacity: 0, y: 22 }} initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: 0.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75" className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
> >
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} /> <InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
@@ -81,23 +81,48 @@ export function Hero() {
<motion.div <motion.div
initial={{ opacity: 0, y: 22 }} initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4" className="mt-10 flex flex-wrap items-center gap-4"
> >
{/* دکمه اصلی */} {/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */}
<PremiumButton to={href(hero.cta_primary_href)}> <ButtonLink
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} /> to={href(hero.cta_primary_href)}
</PremiumButton> variant="copper"
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
>
<span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
</span>
</ButtonLink>
{/* دکمه ثانویه */} {/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md"> <ButtonLink
to={href(hero.cta_secondary_href)}
variant="outline"
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
>
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} /> <InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
</PremiumButton> </ButtonLink>
</motion.div> </motion.div>
</div> </div>
{/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */} {/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */}
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.36, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
>
<p className="text-3xl font-bold text-copper font-mono">
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
</p>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
</p>
</motion.div>
</motion.div> </motion.div>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */} {/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
+9 -8
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { CheckIcon } from 'lucide-react' import { CheckIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { PremiumButton } from '../PremiumButton' import { Button } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
@@ -98,7 +98,7 @@ export function Newsletter() {
} }
return ( return (
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title"> <section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
<SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" /> <SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" />
{/* هاله نور پس‌زمینه برای عمق بصری */} {/* هاله نور پس‌زمینه برای عمق بصری */}
@@ -142,18 +142,19 @@ export function Newsletter() {
)} )}
</div> </div>
<button <Button
type="submit" type="submit"
variant="dark"
disabled={sending} disabled={sending}
className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed" className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
> >
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" /> <SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink"> <span>
{sending ? newsletter.sending_label : ( {sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} /> <InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)} )}
</span> </span>
</button> </Button>
</form> </form>
</Reveal> </Reveal>
</div> </div>
+1 -1
View File
@@ -20,7 +20,7 @@ export function Services() {
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title"> <section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right"> <Reveal className="max-w-2xl text-start">
<h2 id="services-title" className="text-section font-bold text-ivory"> <h2 id="services-title" className="text-section font-bold text-ivory">
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} /> <InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
+5 -4
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { PremiumButton } from '../PremiumButton' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
@@ -89,9 +89,10 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && ( {showLink && (
<Reveal delay={0.22}> <Reveal delay={0.22}>
<PremiumButton to={href(story.link_href)} className="mt-10 font-light"> <ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} /> <InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
</PremiumButton> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</Reveal> </Reveal>
)} )}
-74
View File
@@ -1,74 +0,0 @@
export type BilingualText = { en: string; fa: string };
export type StaticProject = {
id: string;
slug: string;
name: BilingualText;
location: BilingualText;
area: BilingualText;
status: 'done' | 'progress';
};
export const projectsData: StaticProject[] = [
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
];
+5 -1
View File
@@ -50,7 +50,11 @@ export type ArticleStatus = 'draft' | 'published';
export type Article = { export type Article = {
id: number; id: number;
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string; slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
image: string; read: string; read_en?: string; excerpt: string; excerpt_en?: string; image: string;
read: string; read_en?: string; excerpt: string; excerpt_en?: string;
/** پرسش‌وپاسخ‌های پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
faqs?: { question: string; answer: string }[];
faqs_en?: { question: string; answer: string }[] | null;
/** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */ /** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */
body: ArticleBodyDoc | string[]; body: ArticleBodyDoc | string[];
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */ /** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
+18 -2
View File
@@ -53,8 +53,16 @@ export const fa = {
projectNotFound: 'این پروژه پیدا نشد', projectNotFound: 'این پروژه پیدا نشد',
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.', projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.',
backToProjects: 'بازگشت به پروژه‌ها', backToProjects: 'بازگشت به پروژه‌ها',
specStatus: 'وضعیت',
specLandArea: 'مساحت عرصه (زمین)',
specPilotFloors: 'طبقات روی همکف (پیلوت)',
specUnitsResidential: 'تعداد واحد مسکونی',
specUnitsCommercial: 'تعداد واحد تجاری',
specUnitsOffice: 'تعداد واحد اداری',
specLocation: 'موقعیت', specLocation: 'موقعیت',
specYear: 'سال', specProjectType: 'نوع پروژه',
specUsage: 'کاربری',
specYear: 'سال ساخت',
specArea: 'زیربنا', specArea: 'زیربنا',
specFloors: 'تعداد طبقات', specFloors: 'تعداد طبقات',
specUnits: 'واحدها', specUnits: 'واحدها',
@@ -166,8 +174,16 @@ export const en: Dict = {
projectNotFound: 'Project not found', projectNotFound: 'Project not found',
projectNotFoundText: 'The address may have changed. Browse the full list of projects.', projectNotFoundText: 'The address may have changed. Browse the full list of projects.',
backToProjects: 'Back to projects', backToProjects: 'Back to projects',
specStatus: 'Status',
specLandArea: 'Land area',
specPilotFloors: 'Floors above ground',
specUnitsResidential: 'Residential units',
specUnitsCommercial: 'Commercial units',
specUnitsOffice: 'Office units',
specLocation: 'Location', specLocation: 'Location',
specYear: 'Year', specProjectType: 'Project type',
specUsage: 'Land use',
specYear: 'Year built',
specArea: 'Built area', specArea: 'Built area',
specFloors: 'Floors', specFloors: 'Floors',
specUnits: 'Units', specUnits: 'Units',
+1
View File
@@ -99,6 +99,7 @@ export function localizeSite(data: SiteData, lang: Lang): SiteData {
read: s(a.read, a.read_en), read: s(a.read, a.read_en),
excerpt: s(a.excerpt, a.excerpt_en), excerpt: s(a.excerpt, a.excerpt_en),
body: docHasText(a.body_en) ? a.body_en! : a.body, body: docHasText(a.body_en) ? a.body_en! : a.body,
faqs: list(a.faqs ?? [], a.faqs_en),
})), })),
testimonials: data.testimonials.map((tm) => ({ testimonials: data.testimonials.map((tm) => ({
...tm, ...tm,
+36 -5
View File
@@ -130,21 +130,38 @@ html[lang='en'] .article-body h3 {
} }
.article-body h2 { .article-body h2 {
font-size: 16px; font-size: 16px;
padding-inline-start: 0.85rem; /* نوار مسی ندارد: نوار عمودی مسی نشانه اختصاصی نقل‌قول است و تیتر فقط با
border-inline-start: 3px solid #d5874c; اندازه و وزن متمایز می‌شود تا با نقل‌قول اشتباه گرفته نشود. */
} }
.article-body h3 { .article-body h3 {
font-size: 14px; font-size: 14px;
} }
/* متن مقاله در هر دو زبان هم‌تراز (justify) است: لبه‌های راست و چپ یکدست می‌مانند.
text-align: justify با direction درست، در فارسی و انگلیسی هر دو کار می‌کند. */
[data-dir='rtl'] .article-body { [data-dir='rtl'] .article-body {
direction: rtl; direction: rtl;
text-align: right; text-align: justify;
text-justify: inter-word;
} }
[data-dir='ltr'] .article-body { [data-dir='ltr'] .article-body {
direction: ltr; direction: ltr;
text-align: left; text-align: justify;
text-justify: inter-word;
font-size: 12px; font-size: 12px;
} }
/* فقط تیترها هم‌تراز نمی‌شوند (تیتر کوتاه با justify کشیده و بدقلق می‌شود) —
پاراگراف‌ها و فهرست‌ها کاملاً justify هستند و کپشن همیشه وسط‌چین است. */
[data-dir='rtl'] .article-body h1,
[data-dir='rtl'] .article-body h2,
[data-dir='rtl'] .article-body h3 {
text-align: right;
}
[data-dir='ltr'] .article-body h1,
[data-dir='ltr'] .article-body h2,
[data-dir='ltr'] .article-body h3 {
text-align: left;
}
[data-dir='ltr'] .article-body h1 { [data-dir='ltr'] .article-body h1 {
font-size: 16px; font-size: 16px;
} }
@@ -236,6 +253,15 @@ html[lang='en'] .article-body h3 {
border: 1px solid rgba(245, 243, 239, 0.1); border: 1px solid rgba(245, 243, 239, 0.1);
object-fit: cover; object-fit: cover;
} }
/* فهرست مطالب: اسکرول بدون نوار اسکرول — چرخ ماوس و لمس کار می‌کنند */
.no-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge legacy */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Chrome / Safari / Edge */
}
/* ---------- تصویر کپشن‌دار: <figure><img><figcaption> ---------- */ /* ---------- تصویر کپشن‌دار: <figure><img><figcaption> ---------- */
.article-body figure { .article-body figure {
margin-top: 2rem; margin-top: 2rem;
@@ -259,7 +285,12 @@ html[lang='en'] .article-body h3 {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.6rem; margin-top: 0.6rem;
text-align: right; /* تراز از جهت بلوک پیروی می‌کند — در مقاله انگلیسی چپ و در فارسی راست */
text-align: start;
}
/* متن کپشن در ویرایشگر هم وسط‌چین است تا با خروجی سایت یکی باشد */
[data-wysiwyg] .article-figure__caption input[type='text'] {
text-align: center;
} }
[data-wysiwyg] .article-figure-node.is-selected .article-figure { [data-wysiwyg] .article-figure-node.is-selected .article-figure {
outline: 1px dashed rgba(213, 135, 76, 0.8); outline: 1px dashed rgba(213, 135, 76, 0.8);
+3
View File
@@ -2,6 +2,9 @@ import "./index.css";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { initAnalytics } from "./analytics";
initAnalytics();
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
if (rootEl) { if (rootEl) {
+6 -5
View File
@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer'; import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { PremiumButton } from '../components/PremiumButton'; import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { ArticleMeta } from '../components/ArticleMeta'; import { ArticleMeta } from '../components/ArticleMeta';
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents'; import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
@@ -74,9 +74,10 @@ export function ArticleDetail() {
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> <section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1> <h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p> <p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
<PremiumButton to={href('/blog')} className="mt-8"> <ButtonLink to={href('/blog')} variant="copper" className="mt-8">
{t('backToBlog')} {t('backToBlog')}
</PremiumButton> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</section>); </section>);
} }
@@ -91,7 +92,7 @@ export function ArticleDetail() {
*/ */
const hasToc = toc.length > 0; const hasToc = toc.length > 0;
// در چیدمان راست‌به‌چپ ستون شماره ۱ سمت راست است؛ پس ستون‌ها صریح شماره می‌خورند // در چیدمان راست‌به‌چپ ستون شماره ۱ سمت راست است؛ پس ستون‌ها صریح شماره می‌خورند
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : ''; const gridColumns = hasToc ? 'lg:grid-cols-[21rem_minmax(0,1fr)]' : '';
const bodyColumns = 'lg:col-start-2'; const bodyColumns = 'lg:col-start-2';
const tocColumns = 'lg:col-start-1'; const tocColumns = 'lg:col-start-1';
+2 -8
View File
@@ -1,22 +1,16 @@
import React from 'react' import React from 'react'
import { PageHero } from '../components/PageHero' import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal'
import { useSite } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function ContactPage() { export function ContactPage() {
const { offices } = useSite()
const { t } = useLang() const { t } = useLang()
return ( return (
<> <>
<PageHero pageKey="contact" /> <PageHero pageKey="contact" />
<section className="bg-ink py-2" aria-label={t('officesAria')}> <section className="bg-ink py-2" aria-label={t('officesAria')} />
</section>
<ContactSection /> <ContactSection />
</> </>
+128 -147
View File
@@ -1,98 +1,81 @@
import { useEffect, useMemo, useState } from 'react' import React, { useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { ChevronLeftIcon } from 'lucide-react' import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
import { Reveal } from '../components/Reveal' import { Reveal } from '../components/Reveal'
import { PremiumButton } from '../components/PremiumButton' import { ButtonLink } from '../components/Button'
import { ProjectCard } from '../components/ProjectCard' import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox' import { Lightbox } from '../components/Lightbox'
import { LazyProjectsMapSingle } from '../map/LazyMaps'; import { useEffect } from 'react';
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects'
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
import { projectsData } from '../data/projectsData';
export function ProjectDetail() { export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const { projects, brand } = useSite() const { projects, brand } = useSite()
const { t, href, lang } = useLang() const { t, href, lang } = useLang()
const project = projects.find((p) => p.slug === slug)
const currentProject = projectsData.find((p) => p.slug === slug) const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null) const [lightbox, setLightbox] = useState<number | null>(null)
if (!currentProject) { // عنوان تب، توضیح متا و OG از خود پروژه
return <div className="text-center py-20 text-ivory">Project not found</div>
}
const backendProject = projects.find((p) => p.slug === slug)
// Dynamic bilingual text from projectsData
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en;
const displayLocation = currentProject.location[lang as 'en' | 'fa'] || currentProject.location.en;
const displayArea = currentProject.area[lang as 'en' | 'fa'] || currentProject.area.en;
const displayStatus = currentProject.status;
const statusLabel = useStatusLabel(displayStatus)
// Fallbacks for layout structure fields missing in projectsData
const summary = backendProject?.summary || (lang === 'en' ? 'A modern and luxurious architectural project built with high-quality materials and smart design.' : 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ.');
const body = backendProject?.body || (lang === 'en'
? ['Located in the heart of the city, this project offers unparalleled access to major urban hubs.', 'It features state-of-the-art facilities, green spaces, and a sustainable approach to modern living.']
: ['لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است.', 'چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.']);
const gallery = backendProject?.gallery?.length ? backendProject.gallery : [
'/seed-media/2026/09/1.png',
'/seed-media/2026/09/2.png',
'/seed-media/2026/09/3.png',
'/seed-media/2026/09/4.png'
];
const image = backendProject?.image || '/seed-media/2026/09/1.png';
const year = backendProject?.year || (lang === 'en' ? '2024' : '1402');
const floors = backendProject?.floors || '5';
const units = backendProject?.units || '20';
const client = backendProject?.client || (lang === 'en' ? 'Aramesh Group' : 'گروه آرامش');
// تصاویر پاپ‌آپ نقشه
const mapImages = useMemo(
() => [image, ...gallery].filter(Boolean),
[image, gallery]
)
// مختصات همین پروژه
const mapLocation = useMemo(
() => findMapProjectByName(displayTitle) ?? createPlaceholderProject(displayTitle, displayLocation),
[displayTitle, displayLocation]
)
// SEO
useEffect(() => { useEffect(() => {
if (!project) return;
applySeo({ applySeo({
title: `${displayTitle} | ${brand.name}`, title: `${project.title} | ${brand.name}`,
description: summary, description: project.summary,
path: href(`/projects/${currentProject.slug}`), path: href(`/projects/${project.slug}`),
image: image, image: project.image,
lang, lang,
}); });
}, [currentProject.slug, lang, href, displayTitle, summary, image, brand.name]); }, [project?.id, lang, href]);
const related = projectsData.filter((p) => p.slug !== currentProject.slug).slice(0, 3) if (!project) {
return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">
{t('projectNotFoundText')}
</p>
<ButtonLink to={href('/projects')} variant="copper" className="mt-8">
{t('backToProjects')}
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</section>
)
}
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
// مشخصات هر پروژه — همان فهرستی که تیم داده دارد:
// وضعیت / سال / تعداد طبقات / واحدها / نوع پروژه / زیربنا / کاربری
// ردیف‌های خالی نمایش داده نمی‌شوند.
const facts = [ const facts = [
{ label: t('specLocation'), value: displayLocation }, { label: t('specStatus'), value: statusLabel },
{ label: t('specYear'), value: year }, { label: t('specYear'), value: project.year },
{ label: t('specArea'), value: displayArea }, { label: t('specLandArea'), value: project.land_area },
{ label: t('specFloors'), value: floors }, { label: t('specArea'), value: project.area },
{ label: t('specUnits'), value: units }, { label: t('specUsage'), value: project.usage },
{ label: t('specClient'), value: client }, { label: t('specPilotFloors'), value: project.pilot_floors },
] { label: t('specFloors'), value: project.floors },
{ label: t('specUnitsResidential'), value: project.units_residential },
{ label: t('specUnitsCommercial'), value: project.units_commercial },
{ label: t('specUnitsOffice'), value: project.units_office },
].filter((f) => String(f.value ?? '').trim() !== '')
return ( return (
<> <>
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}> <section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
<div className="mx-auto max-w-site px-6 lg:px-10"> <img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50"> <div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to={href('/')} className="transition-colors hover:text-copper"> <Link to={href('/')} className="transition-colors hover:text-copper">
{t('home')} {t('home')}
</Link> </Link>
@@ -101,80 +84,85 @@ export function ProjectDetail() {
{t('projects')} {t('projects')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{displayTitle}</span> <span className="text-copper">{project.title}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
<div className="mb-8"> <p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1> {/* ویرایش این پروژه — صفحهٔ اختصاصی ویرایش */}
</div> <div className="mt-5">
<Link
{/* Gallery Desktop: Grid. Mobile: Grid */} to={`/admin/projects/${project.id}`}
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]"> data-admin-link
className="inline-flex items-center gap-2 rounded-full border border-copper/50 bg-ink/90 px-4 py-2 text-xs font-light text-copper backdrop-blur-sm transition-colors duration-200 hover:bg-copper hover:text-char"
{/* Main large image */}
<button
type="button"
className="col-span-2 lg:col-span-1 h-[250px] md:h-[400px] lg:h-full relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block"
onClick={() => setLightbox(0)}
> >
<img src={mapImages[0]} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" /> ویرایش این پروژه (عنوان، مشخصات، گالری، متن)
</button> </Link>
</div>
</div>
</section>
{/* 2x2 Small images */} <section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
<div className="col-span-2 lg:col-span-1 grid grid-cols-2 gap-2 md:gap-3 lg:grid-rows-2 lg:h-full"> <div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
{mapImages.slice(1, 5).map((img, i) => (
<button <Reveal className="order-1">
key={img} <span className="flex items-center gap-3 text-sm font-light text-copper">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
{project.body.map((para) => (
<p key={para} className="mt-5 text-justify text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right">
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
<div className="mt-10">
<ButtonLink to={href('/contact')} variant="outline" className="font-light">
{t('similarProjectCta')}
</ButtonLink>
</div>
</Reveal>
<Reveal delay={0.1} className="order-2">
<button
type="button"
onClick={() => setLightbox(0)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={project.gallery[0] || project.image}
alt=""
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
<div className="mt-4 grid grid-cols-3 gap-4">
{project.gallery.slice(1).map((img, i) => (
<button
key={img}
type="button" type="button"
className={`relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block h-[120px] md:h-[200px] lg:h-full ${i >= 2 ? 'hidden md:block' : ''}`}
onClick={() => setLightbox(i + 1)} onClick={() => setLightbox(i + 1)}
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
> >
<img src={img} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" /> <img
src={img}
alt=""
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button> </button>
))} ))}
</div> </div>
</Reveal>
</div>
{/* Smart Content Layout */}
<div className="max-w-4xl mx-auto mt-16 lg:mt-24 flex flex-col gap-16 lg:gap-20">
{/* Description */}
<Reveal>
<span className="flex items-center gap-3 text-sm font-light text-copper mb-6">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
<p className="mt-8 text-xl font-medium leading-9 text-ivory/90">{summary}</p>
{body.map((para) => (
<p key={para} className="mt-6 text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
</Reveal>
{/* Specifications Table - Perfectly center-aligned */}
<Reveal>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-px border border-ivory/10 bg-ivory/10 rounded-xl overflow-hidden">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 flex flex-col items-center justify-center text-center">
<dt className="text-xs font-light text-ivory/50 mb-2">{f.label}</dt>
<dd className="text-base font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
</Reveal>
{/* Map (بدون Reveal) */}
{mapLocation && (
<div className="h-80 md:h-[400px] rounded-xl overflow-hidden border border-white/10 shadow-2xl">
<LazyProjectsMapSingle location={mapLocation} images={mapImages} className="h-full w-full" />
</div>
)}
</div>
</div> </div>
</section> </section>
@@ -183,24 +171,17 @@ export function ProjectDetail() {
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2> <h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => { {related.map((item, i) => (
const dummyImageName = `${(i % 6) + 1}.png`; <Reveal key={item.slug} delay={0.06 * i} className="h-full">
return ( <ProjectCard project={item} />
<Reveal key={item.slug} delay={0.06 * i} className="h-full"> </Reveal>
<ProjectCard ))}
project={item}
imageUrl={`/seed-media/2026/09/${dummyImageName}`}
detailHref={href(`/projects/${item.slug}`)}
/>
</Reveal>
);
})}
</div> </div>
</div> </div>
</section> </section>
<Lightbox <Lightbox
images={gallery} images={project.gallery}
index={lightbox} index={lightbox}
onClose={() => setLightbox(null)} onClose={() => setLightbox(null)}
onChange={setLightbox} onChange={setLightbox}
+26 -16
View File
@@ -9,12 +9,12 @@ import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext'; import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit'; import { InlineEdit } from '../components/admin/InlineEdit';
import { projectsData } from '../data/projectsData'; import { AddItemButton, EditableItem } from '../admin/Editable';
import { LazyProjectsMapAll } from '../map/LazyMaps'; import { LazyProjectsMapAll } from '../map/LazyMaps';
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects'; import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
export function Projects() { export function Projects() {
const { status_labels } = useSite(); const { status_labels, projects: dbProjects } = useSite();
const { t } = useLang(); const { t } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
@@ -35,6 +35,10 @@ export function Projects() {
]; ];
const [status, setStatus] = useState<'done' | 'progress'>('done'); const [status, setStatus] = useState<'done' | 'progress'>('done');
// پروژه‌ها از دیتابیس خوانده می‌شوند تا ویرایش پنل همان‌جا دیده شود؛
// فایل استاتیک فقط وقتی دیتابیس خالی است جانشین می‌شود (محتوای قدیمی).
const cardProjects = useMemo(() => dbProjects, [dbProjects]);
// Filtering and Pagination State // Filtering and Pagination State
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -42,8 +46,8 @@ export function Projects() {
// Filter projects by status // Filter projects by status
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
return projectsData.filter((p) => p.status === status); return cardProjects.filter((p) => p.status === status);
}, [status]); }, [cardProjects, status]);
// Currently displayed projects // Currently displayed projects
const displayedProjects = useMemo(() => { const displayedProjects = useMemo(() => {
@@ -68,12 +72,13 @@ export function Projects() {
const BASE_IMG_URL = "/seed-media/2026/09/"; const BASE_IMG_URL = "/seed-media/2026/09/";
// Resolve a project href from a MapProject entry by matching against projectsData slugs // Resolve a project href from a MapProject entry by matching names — first in the
// database (so pins follow panel edits), then in the static legacy list.
const resolveProjectHref = (mapProject: MapProject): string | undefined => { const resolveProjectHref = (mapProject: MapProject): string | undefined => {
const match = projectsData.find( const dbMatch = dbProjects.find(
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name (p) => p.title === mapProject.name || p.title_en === mapProject.name,
); );
return match ? `/projects/${match.slug}` : undefined; return dbMatch ? `/projects/${dbMatch.slug}` : undefined;
}; };
return ( return (
@@ -131,19 +136,24 @@ export function Projects() {
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{displayedProjects.map((project, index) => { {displayedProjects.map((project, index) => {
const dummyImageName = `${(index % 6) + 1}.png`; // تصویر رکورد دیتابیس اولویت دارد؛ فقط ردیف‌های بدون تصویر از
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`; // تصاویر جایگزین تکراری استفاده می‌کنند.
const card = (
<ProjectCard
project={project}
detailHref={`/projects/${project.slug}`}
/>
);
return ( return (
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full"> <Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
<ProjectCard <EditableItem resource="projects" item={project} editHref={`/admin/projects/${project.id}`} className="h-full">
project={project} {card}
imageUrl={imageUrl} </EditableItem>
detailHref={`/projects/${project.slug}`}
/>
</Reveal> </Reveal>
); );
})} })}
<AddItemButton resource="projects" editHrefBase="/admin/projects" />
</div> </div>
{/* Load More Button */} {/* Load More Button */}
+4 -3
View File
@@ -72,10 +72,10 @@ function upsertAlternates(path: string): void {
} }
} }
function setJsonLd(data: Record<string, unknown> | null): void { function setJsonLd(data: Record<string, unknown> | Record<string, unknown>[] | null): void {
const id = 'seo-jsonld' const id = 'seo-jsonld'
let el = document.getElementById(id) let el = document.getElementById(id)
if (!data) { if (!data || (Array.isArray(data) && data.length === 0)) {
el?.remove() el?.remove()
return return
} }
@@ -98,7 +98,8 @@ export type SeoInput = {
type?: 'website' | 'article' type?: 'website' | 'article'
/** زبان جاری — برای og:locale و لینک‌های hreflang */ /** زبان جاری — برای og:locale و لینک‌های hreflang */
lang?: Lang lang?: Lang
jsonLd?: Record<string, unknown> | null /** اسکیمای JSON-LD — یک شیء یا آرایه‌ای از چند اسکیما (مثل Article + FAQPage) */
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
} }
/** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */ /** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */
+24
View File
@@ -0,0 +1,24 @@
# Alertmanager — تحویل هشدارها به بله (API بات بله همان شکل تلگرام است)
# توکن و شناسه چت از .env (ALERT_BOT_TOKEN / ALERT_CHAT_ID) — این فایل قالب است؛
# نسخهٔ رندرشده (alertmanager.rendered.yml) را scripts/stack.sh می‌سازد و در گیت نمی‌رود.
# تا وقتی توکن/چت واقعی تنظیم نشده، ارسال شکست می‌خورد و هشدارها در
# http://<prometheus>:9090/alerts دیده می‌شوند.
global:
resolve_timeout: 5m
route:
receiver: bale
group_by: [alertname, job]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: bale
telegram_configs:
# تنها تفاوت با تلگرام: نشانی API بات بله
- api_url: https://tapi.bale.ai
bot_token: "${ALERT_BOT_TOKEN}"
chat_id: ${ALERT_CHAT_ID}
send_resolved: true
@@ -0,0 +1,102 @@
{
"uid": "aramesh-app",
"title": "آرامش — اپلیکیشن و ترافیک",
"tags": ["aramesh"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"type": "timeseries",
"title": "درخواست‌ها بر ثانیه (هر روتر Traefik)",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "sum(rate(traefik_service_requests_total[5m])) by (service)",
"legendFormat": "{{ service }}"
}
],
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "خطاهای 5xx (بک‌اند)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "sum(rate(http_request_duration_seconds_count{status=~\"5..\"}[5m]))",
"legendFormat": "5xx/s"
}
],
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "تأخیر بک‌اند — p50 و p95",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "histogram_quantile(0.5, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p50"
},
{
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95"
}
],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"type": "stat",
"title": "سلامت /api/health (پروب عمق)",
"gridPos": { "h": 8, "w": 4, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{ "expr": "probe_success{job=\"blackbox-internal-health\"}", "legendFormat": "health" }
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "1": { "text": "سالم", "color": "green" }, "0": { "text": "خراب", "color": "red" } } }
],
"min": 0,
"max": 1
},
"overrides": []
}
},
{
"type": "stat",
"title": "تازگی آخرین پشتیبان (ثانیه)",
"gridPos": { "h": 8, "w": 4, "x": 16, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{ "expr": "time() - backup_success_time", "legendFormat": "age" }
],
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 72000 }, { "color": "red", "value": 93600 } ] } }, "overrides": [] }
},
{
"type": "timeseries",
"title": "اتصال‌های Postgres",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{ "expr": "sum(pg_stat_activity_count)", "legendFormat": "اتصال‌ها" }
],
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }
},
{
"type": "logs",
"title": "لاگ زندهٔ سرویس‌ها (Loki)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"datasource": { "type": "loki", "uid": "loki" },
"targets": [
{ "expr": "{service=~\"backend|traefik|chatbot|umami\"}" }
]
}
]
}
@@ -0,0 +1,74 @@
{
"uid": "aramesh-infra",
"title": "آرامش — زیرساخت (میزبان و کانتینرها)",
"tags": ["aramesh"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"type": "timeseries",
"title": "CPU میزبان",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "CPU %"
}
],
"fieldConfig": { "defaults": { "unit": "percent", "max": 100 }, "overrides": [] }
},
{
"type": "timeseries",
"title": "حافظه میزبان",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{ "expr": "node_memory_MemTotal_bytes", "legendFormat": "کل" },
{ "expr": "node_memory_MemAvailable_bytes", "legendFormat": "آزاد" }
],
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "دیسک — درصد پرشدگی",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "100 * (1 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs\"}))",
"legendFormat": "{{ mountpoint }}"
}
],
"fieldConfig": { "defaults": { "unit": "percent", "max": 100 }, "overrides": [] }
},
{
"type": "timeseries",
"title": "مصرف RAM هر کانتینر",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "topk(10, container_memory_usage_bytes{image!=\"\", container!=\"\"})",
"legendFormat": "{{ container }}"
}
],
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "CPU هر کانتینر",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "topk(10, rate(container_cpu_usage_seconds_total{image!=\"\", container!=\"\"}[5m]))",
"legendFormat": "{{ container }}"
}
],
"fieldConfig": { "defaults": { "unit": "percentunit" }, "overrides": [] }
}
]
}
@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: aramesh
folder: Aramesh
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,17 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: prom
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
- name: Loki
type: loki
uid: loki
access: proxy
url: http://loki:3100
editable: false
+30
View File
@@ -0,0 +1,30 @@
# Loki — ذخیرهٔ لاگ‌ها روی دیسک (۱۴ روز)
auth_enabled: false
server:
http_listen_port: 3100
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 336h
+130
View File
@@ -0,0 +1,130 @@
# قواعد هشدار — از Prometheus به Alertmanager (تلگرام) می‌روند.
# آستانه‌ها محافظه‌کارانه‌اند؛ پس از یک هفته داده، دقیق‌تر تنظیم کنید.
groups:
- name: aramesh-availability
rules:
- alert: ServiceDown
# سرویس‌های دارای متریک (backend/traefik/exporters) — بقیه با ProbeFailing پوشش می‌شوند
expr: up == 0
for: 2m
labels:
severity: critical
annotations:
summary: "{{ $labels.job }} پاسخ نمی‌دهد"
description: "سرویس {{ $labels.job }} ({{ $labels.instance }}) حداقل ۲ دقیقه پایین است."
- alert: ProbeFailing
expr: probe_success == 0
for: 2m
labels:
severity: critical
annotations:
summary: "پروب {{ $labels.job }} ناموفق است"
description: "سرویسِ پشت پروب {{ $labels.instance }} حداقل ۲ دقیقه پاسخ نمی‌دهد."
- alert: BackendHealthFailing
expr: probe_success{job="blackbox-internal-health"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "بررسی عمق سلامت بک‌اند شکست می‌خورد"
description: "‏/api/health اتصال DB یا MinIO را آزموده و 503 برگردانده — وابستگی‌ها را ببینید."
- alert: PublicSiteDown
expr: probe_success{job="blackbox-public"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "سایت از مسیر عمومی در دسترس نیست"
description: "پروب {{ $labels.instance }} ناموفق است — Traefik/TLS/DNS را بررسی کنید."
- name: aramesh-resources
rules:
- alert: HostDiskAlmostFull
# Docker Desktop/WSL سوارهای تکراری می‌سازد — فقط مسیرهای واقعی دیده می‌شوند
expr: |
1 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs", mountpoint!~"/run/desktop.*|/parent-distro.*|/usr/lib/wsl.*|/mnt/wsl.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs", mountpoint!~"/run/desktop.*|/parent-distro.*|/usr/lib/wsl.*|/mnt/wsl.*"}) > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "دیسک میزبان پر می‌شود"
description: "پرشدن بیش از ۸۵٪ روی {{ $labels.mountpoint }} — پشتیبان‌ها و لاگ‌ها را بررسی کنید."
- alert: HostMemoryPressure
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "RAM میزبان رو به تمام‌شدن است"
description: "کمتر از ۱۰٪ حافظه باقی مانده است."
- alert: ContainerMemoryNearLimit
expr: |
(container_memory_usage_bytes{image!="", container!=""}
/ container_spec_memory_limit_bytes{image!="", container!=""} > 0.9)
and (container_spec_memory_limit_bytes{image!="", container!=""} > 0)
for: 10m
labels:
severity: warning
annotations:
summary: "کانتینر {{ $labels.container }} به سقف حافظه نزدیک است"
- name: aramesh-app
rules:
- alert: Http5xxRateHigh
expr: |
sum(rate(http_request_duration_seconds_count{status=~"5.."}[5m])) by (instance)
/ sum(rate(http_request_duration_seconds_count[5m])) by (instance) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "نرخ خطای 5xx بک‌اند بالاست"
- alert: Proxy5xxRateHigh
expr: |
sum(rate(traefik_service_requests_total{code=~"5.."}[5m])) by (service)
/ sum(rate(traefik_service_requests_total[5m])) by (service) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "نرخ خطای 5xx روی سرویس {{ $labels.service }} پشت Traefik بالاست"
- alert: HighBackendLatency
expr: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 2
for: 10m
labels:
severity: warning
annotations:
summary: "p95 تأخیر بک‌اند بالای ۲ ثانیه است"
- alert: PostgresTooManyConnections
expr: sum(pg_stat_activity_count) > 80
for: 5m
labels:
severity: warning
annotations:
summary: "تعداد اتصال‌های Postgres نزدیک سقف است"
- alert: TlsCertExpiringSoon
expr: traefik_tls_certs_not_after - time() < 1209600
labels:
severity: warning
annotations:
summary: "گواهی TLS کمتر از ۱۴ روز اعتبار دارد"
description: "تمدید خودکار Let's Encrypt کار نکرده — ACME/TLS را بررسی کنید."
- alert: BackupStale
expr: time() - backup_success_time > 93600
for: 30m
labels:
severity: critical
annotations:
summary: "آخرین پشتیبان موفق بیش از ۲۶ ساعت پیش است"
description: "کرون/اسکریپت پشتیبان‌گیری یا سرویس db-backup خفته — فوراً بررسی کنید."

Some files were not shown because too many files have changed in this diff Show More