feat: add RichTextEditor component for rich text editing in articles

feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
This commit is contained in:
hamed
2026-08-08 11:40:17 +03:30
parent 934405c42d
commit 47323daa27
36 changed files with 3461 additions and 12686 deletions
+42 -1
View File
@@ -296,7 +296,7 @@ Create a new blog post.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | ✅ | Post title (slug auto-generated) |
| `body` | string | ✅ | Full HTML body (from the admin CKEditor) |
| `body` | string | ✅ | Full HTML body (from the admin CKEditor). **پیش از ذخیره پاک‌سازی می‌شود** — [سیاست پاک‌سازی بدنه](#سیاست-پاکسازی-بدنه). |
| `summary` | string | ❌ | Short excerpt |
| `tags` | integer[] | ❌ | Array of tag IDs |
| `status` | string | ❌ | `"draft"` (default) or `"published"` |
@@ -582,6 +582,47 @@ Upload blog post header image.
---
## سیاست پاک‌سازی بدنه
بدنهٔ مقاله در **لحظهٔ ذخیره** پاک‌سازی می‌شود، نه هنگام نمایش — هر چهار نقطهٔ ورود
(`POST`/`PATCH` در `BlogController` و `RepresentationBlogController`) از
`App\Blog\Service\BlogBodySanitizer` می‌گذرند. دلیلش چند مصرف‌کننده بودنِ بدنه است:
پنل ادمین، سایت عمومی و فید. با پاک‌سازی در لایهٔ نمایش، هر مصرف‌کنندهٔ تازه دوباره
آسیب‌پذیر شروع می‌کرد.
سیاست در `config/packages/html_sanitizer.yaml` است. سه قاعده‌ای که رفتار قابل‌مشاهده
دارند:
- `<script>`, `<style>`, `<iframe>`, `<object>`, `<embed>`, `<form>`, `<input>`,
`<button>`, `<noscript>` با محتوایشان **حذف** می‌شوند (`drop` نه `block`) — وگرنه
`<script>alert(1)</script>` به متنِ `alert(1)` تبدیل می‌شد.
- روی هر `<a>` مقدار `rel="noopener noreferrer"` **تحمیل** می‌شود؛ طرح‌های مجاز فقط
`http`, `https`, `mailto`.
- `<table>` سه attributeِ ظاهریِ قدیمی را نگه می‌دارد — `border`, `cellpadding`,
`cellspacing` — ولی `style` را از **هر** عنصری حذف می‌کند. ظاهرِ جدولِ بدنهٔ مقاله
در پنل از `.blog-body` در `assets/admin/styles.css` می‌آید.
بدنه‌ای که پس از پاک‌سازی خالی شود `422` می‌گیرد، نه اینکه خالی ذخیره شود.
### پاک‌سازی مقاله‌های قدیمی
مقاله‌هایی که پیش از این سیاست ذخیره شده‌اند با یک دستور به همان وضع می‌رسند:
```bash
ddev exec php bin/console app:blog:sanitize-bodies --dry-run # گزارش، بدون نوشتن
ddev exec php bin/console app:blog:sanitize-bodies # اعمال
ddev exec php bin/console app:blog:sanitize-bodies --show=42 # قبل/بعدِ یک مقاله
```
گزارشِ dry-run تغییرها را تفکیک می‌کند: «سخت‌سازی» یعنی فقط `rel` اضافه یا entity
decode شده، و «حذفِ تگ یا attribute غیرمجاز» یعنی آن مقاله markupی داشته که سیاست
نمی‌پذیرد. بدونِ این تفکیک، عددِ کلِ تغییرات گمراه‌کننده است — در اجرای ۲۰۲۶-۰۸-۰۸ از
۴۲۶ مقالهٔ تغییریافته، ۳۹۸ فقط سخت‌سازی بودند.
دستور idempotent است: اجرای دوم صفر تغییر گزارش می‌دهد.
---
## باطل‌سازی کش سایت عمومی (webhook خروجی)
سایت عمومی (`nobat724_front`) پاسخ `GET /api/v1/blog/{slug}` را با `next: { revalidate: 3600, tags: [...] }` کش می‌کند. بدون باطل‌سازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمی‌شد.
+174 -12
View File
@@ -21,24 +21,27 @@ authz/headers/cors/inject) + پروب‌های دستی با JWT واقعی هر
| 1 | `TreatmentProtocolController` هیچ گِیت مجوزی نداشت — منشیِ `services:false` می‌توانست پروتکل درمان را بخواند، بازنویسی و حذف کند | 🟧 High | ✅ رفع شد |
| 2 | `react-router`۵ advisory از جمله XSS و open redirect | 🟧 High | ✅ رفع شد (مهاجرت به v8) |
| 3 | `lodash-es` — code injection در `_.template` + دو prototype pollution | 🟧 High | ✅ رفع شد (override به 4.18.1) |
| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ⚠️ risk پذیرفته‌شده تصمیم ۲۰۲۶-۰۸-۰۷ |
| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ (مهاجرت به `ckeditor5@48`) |
| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ✅ رفع شد (sanitize هنگام ذخیره) |
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ✅ رفع شد |
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) |
| 8 | ۱۰ روت `GET` که مجوزِ رجیستری‌شان را enforce نمی‌کنند | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 9 | `AppointmentPlanController` هیچ گِیت مجوزی نداشت — دوقلوی یافتهٔ ۱ | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ⚠️ باز — فهرست کامل زیر |
| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ◐ نیمه‌رفع — ۲۲ روت بسته شد، ۱۲ روت باز |
| 11 | `MyAppointmentsController::$branches` تزریق نشده بود — اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰ می‌داد | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 12 | سیاست پاک‌سازی، `style` جدول را می‌برد — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵ | 🟦 Low | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافته‌های باز را خواست، پس
یافته‌های ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۴ طبق تصمیم صریح خارج از محدوده ماند و یافتهٔ ۸
حین همین کار کشف شد.
یافته‌های ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۸ حین همین کار کشف شد.
یافته‌های ۹ و ۱۰ در جلسهٔ ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتهٔ ۸. شرحشان در بخش
یافته‌های ۹ تا ۱۲ در جلسه‌های ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافته‌های ۸ و ۴. شرحشان در بخش
«پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است.
```bash
npm audit --omit=dev # قبل: high=2 moderate=62 بعد: high=0 moderate=3
ddev exec php bin/phpunit # ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز
npm audit --omit=dev # ۲۰۲۶-۰۸-۰۷ قبل: high=2 moderate=62 · بعد: high=0 moderate=3
# ۲۰۲۶-۰۸-۰۸ پس از مهاجرت CKEditor: ۰ آسیب‌پذیری
ddev exec php bin/phpunit # ۱۵۶۸ تست سبز (بود ۱۵۵۵)
ddev exec php vendor/bin/phpstan # No errors (بود ۱۷ خطا بیرون از baseline)
```
---
@@ -681,18 +684,177 @@ fail-closed مستندِ `SecretaryPermissionChecker`. «خالیِ خاموش»
برخلاف پیشنهاد ۳ گزارش، پرسنل در DB زنده ساخته **نشد**: دادهٔ دستی با اولین re-seed می‌رود و
هیچ‌وقت خودکار اجرا نمی‌شود.
## یافتهٔ ۱۱ 🟧 HIGH — `$branches` تزریق‌نشده: اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰
**۱. فایل:** `src/Appointment/Controller/MyAppointmentsController.php:384`
**۲. شرح:** کنترلر `$this->branches->pair($user)` را صدا می‌زد ولی `AddressResolver` هرگز در
constructor نبود. هر `POST /api/v1/my/appointment` که `treatment_session_uuid` داشت روی
«Undefined property» می‌افتاد. یعنی اتصال نوبت به جلسهٔ درمان از پنل **هیچ‌وقت کار نکرده**.
**۳. چرا کسی ندید:** این شاخه هیچ تستی نداشت. `phpstan` دقیقاً همین را گزارش می‌کرد، ولی بین
۱۶ خطای بی‌اثرِ دیگر گم شده بود — «۱۷ خطا» عددی ثابت شده بود که همه ازش رد می‌شدند. درسش این
است که baselineِ نخوانده، باگ زنده را پنهان می‌کند.
**۴. رفع:** تزریق `AddressResolver $branches` — همان سرویسی که `AppointmentPlanController` و
`TreatmentProtocolController` با همین نام استفاده می‌کنند.
**۵. تست:** `tests/Appointment/AppointmentTreatmentSessionLinkTest.php` — سه سناریو: بدون
اتصال (۲۰۱)، uuidِ ناموجود (۴۰۴ با `treatment_session_uuid` در فیلد خطا، نه ۵۰۰)، و رشتهٔ
خالی (۲۰۱).
## یافتهٔ ۱۲ 🟦 LOW — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵
**۱. شرح:** سیاست `html_sanitizer.yaml` برای `<table>` هیچ attributeی مجاز نکرده بود. از آنجا
که پاک‌سازی در **لحظهٔ ذخیره** است، هر مقاله‌ای که از پنل ویرایش می‌شد حاشیه و فاصلهٔ جدولش را
از دست می‌داد. رگرسیون از ۲۰۲۶-۰۸-۰۷ فعال بود و کسی ندیده بود، چون هنوز کسی مقالهٔ جدول‌دار را
ویرایش نکرده بود.
**۲. رفع:** `border`, `cellpadding`, `cellspacing` مجاز شدند — هر سه عددی و غیرقابل‌اجرا.
`style` عمداً ممنوع ماند و ظاهرِ از دست رفته (`border-collapse`, `width`) در `.blog-body`
داخل `assets/admin/styles.css` بازسازی شد؛ یعنی presentation به لایهٔ درستش رفت.
**۳. تست:** `BlogBodySanitizerTest::testTableKeepsInertLayoutAttributesButLosesStyle`.
**۴. کارِ باقی‌مانده در repo دیگر:** `nobat724_front` همان بدنه را رندر می‌کند و قاعدهٔ CSS
معادل را ندارد. جدولِ مقاله‌ها آنجا بدون `border-collapse` نمایش داده می‌شود.
## یافتهٔ ۴ — بسته شد: مهاجرت CKEditor
`@ckeditor/ckeditor5-build-classic@44.3.0` (deprecated، ۶۲ advisory) با پکیج umbrella
`ckeditor5@48.4.0` جایگزین شد. `@ckeditor/ckeditor5-react@11.2.0` از قبل نصب بود و
peer dependency اش `ckeditor5 >= 46` است، پس bump دیگری لازم نشد.
مهاجرت کم‌ریسک بود چون سطح مصرف کوچک است: دو صفحه، یک `ClassicEditor`، ده دکمهٔ نوار ابزار.
پیکربندی در کامپوننت مشترک `assets/admin/components/RichTextEditor.tsx` متمرکز شد تا مهاجرت
بعدی یک فایل باشد و نوار ابزارِ دو صفحه از هم واگرا نشود.
در پکیج umbrella، برخلاف buildِ آماده، فهرست پلاگین‌ها باید صریح باشد. عمداً دقیقاً همان
پلاگین‌های دکمه‌های قبلی آورده شد و نه بیشتر: هر پلاگین اضافه یعنی markup تازه‌ای که
`html_sanitizer.yaml` مجازش نکرده و هنگام ذخیره حذف می‌شود.
```
npm audit --omit=dev # قبل: 61 (moderate=3, low=58) → بعد: 0
npx tsc --noEmit # بدون خطا
ddev exec yarn dev # webpack compiled successfully — 54 فایل
npx vitest --run # ۸۰۱ تست فرانت‌اند سبز
```
## یافتهٔ ۱۰ — نیمه‌رفع: ۲۲ روت از ۳۴ بسته شد
چکِ اصلیِ این روت‌ها شیء‌محور است و بالا نمی‌رود — `ClinicController::update` به `$clinic`ِ همان
رکورد نیاز دارد. ولی **سهمِ منشی** از آن چک همیشه همان توگلِ رجیستری است، پس یک پیش‌چکِ
فقط-منشی اکیداً ضعیف‌تر است: هر کسی را که رد کند، چکِ پایین‌تر هم رد می‌کرد. یعنی هیچ مسیرِ
مجازی بسته نمی‌شود و فقط ۴۰۴ به ۴۰۳ تبدیل می‌شود.
`ClinicDoctorAccessChecker` عمداً در پیش‌چک نیست: پزشکِ عضو ممکن است روی رکوردِ کلینیکِ دیگری
که مالکش است اقدام کند، و آنجا محیطِ فعال با محیطِ رکورد یکی نیست. آن حالت را فقط چکِ
شیء‌محورِ پایین می‌تواند درست بسنجد.
بسته شد (۲۲): `AppointmentController` × ۴ · `AppointmentSettingsController` × ۹ ·
`ClinicController::update`, `detachDoctor` · `ClinicDoctorPermissionController` ×۱ ·
`ClinicInvitationController` × ۴ · `ResourceBlockController` × ۲.
باز ماند (۱۲) — هیچ‌کدام منبعی در `PermissionCatalog` ندارند و چکشان مالکیتِ خودِ رکورد است:
- `ClinicController``createAddress`, `updateAddress`, `deleteAddress`. `addresses` فقط
`view` دارد؛ نوشتنِ آدرس صریحاً owner-or-admin است و اصلاً قابل واگذاری نیست.
- `DoctorController``update`, `delete`, `updateAddress`, `deleteAddress`.
- `RepresentationController::update`.
- `SecretaryController``create`, `update`, `deactivate`, `syncClinicDoctors`.
پوششِ نشتِ باقی‌مانده کم‌ارزش است: uuidِ پزشک از فهرست عمومی پزشکان در دسترس است. برای منشی و
نماینده ارزشش بیشتر است ولی همچنان Low. بستنشان یا کلیدِ تازه در رجیستری می‌خواهد — که UI
مجوزها و دو Entity و تایپ‌های فرانت را درگیر می‌کند — یا یک مکانیزم موازیِ نقش‌محور، که یافتهٔ ۱
توصیه کرد نسازیم.
`ApiLeastPrivilegeTest::testHoistedGatesAnswer403BeforeTheLookup` این ۲۲ تا را قفل می‌کند: اگر
کسی خطِ پیش‌چک را بردارد، پاسخ به ۴۰۴ برمی‌گردد و تست قرمز می‌شود.
## پاک‌سازی `phpstan` — و آنچه زیرش پنهان بود
هر ۱۷ خطای بیرون از baseline بسته شد و `phpstan` حالا `No errors` می‌دهد. baseline از ۴۴ به
۴۳ ردیف رسید. جنس خطاها:
- **۱ باگ زنده** — یافتهٔ ۱۱ بالا.
- **۳ ناهم‌خوانی نوع** — `BillingController` فیلترهای `from`/`to` را string می‌فرستاد و
`InvoiceService` عددِ صحیح می‌خواست. تبدیل در مرزِ ورودی نشست، نه در repository.
- **۶ property تزریق‌شده و بلااستفاده** در پنج سرویس — حذف شدند.
- **۲ فراخوانیِ `getEntityManager()` از بیرون** در `SecretaryService` — با `EntityManagerInterface`
تزریق‌شده جایگزین شد.
- **۲ ignore pattern کهنه** که دیگر با هیچ خطایی مطابقت نداشتند.
- **۳ مقایسهٔ همیشه-درست** — ساده شدند.
دو مورد عمداً با `@phpstan-ignore-next-line` ماندند: `ServiceItem::getConsumables()` و
`getStaffMembers()`. `phpstan` فقط constructor را می‌بیند و می‌گوید property همیشه مقدار دارد؛
Doctrine اما بدون constructor هیدریت می‌کند. گاردِ `??=` عمدی است.
## پاک‌سازی مقاله‌های قدیمی
`app:blog:sanitize-bodies` ساخته شد و روی هر ۴۲۷ مقاله اجرا شد. دستور است نه migration، چون
سیاست ممکن است دوباره سفت شود و آن‌وقت باید همین گذر تکرار شود.
عددِ خام گمراه‌کننده بود: «۴۲۶ مقاله تغییر می‌کند» در نگاه اول یعنی ۴۲۶ مقالهٔ آلوده. تفکیکِ
جنسِ تغییر نشان داد:
| جنس تغییر | تعداد | یعنی چه |
|---|---|---|
| سخت‌سازی | ۳۹۸ | افزودن `rel="noopener noreferrer"` یا decode شدن `&nbsp;` |
| حذف attribute | ۲۸ | فقط `style` روی ۲۲ جدول و ۱ `div`؛ بقیه ترمیمِ HTML شکسته |
| بدون تغییر | ۱ | — |
**هیچ مقاله‌ای markup اجرایی نداشت** — نه `<script>`، نه `onerror`، نه `javascript:`. یعنی
یافتهٔ ۵ یک ریسک بالقوه را بست، نه یک نشتِ فعال را.
پس از اجرا، ۴۲۵ مقاله `rel="noopener noreferrer"` دارند و هیچ‌کدام `style=` ندارند. اجرای دوم
صفر تغییر گزارش می‌دهد.
## ناپایداریِ سوییت — ریشه‌اش پیدا و بسته شد
گزارش ۲۰۲۶-۰۸-۰۷ نوشته بود یک تست، ۲۰۰ تست بعدتر تستی بی‌ربط را می‌شکند، و آن را با
`resetManager()` در `tearDown` همان تست مهار کرده بود. مهار بود نه رفع: با اضافه‌شدنِ
تست‌های این جلسه، خطا سه بار روی سه تستِ متفاوت ظاهر شد — `ClinicRecordAccessTest`،
`ServiceRescheduleTest`، `CommentListNPlusOneTest` — و هر سه در اجرای تکی سبز بودند.
ریشه، بسته‌شدنِ manager نبود. تستی که موجودیتی را `persist()` می‌کند و بی‌`flush()` تمام
می‌شود، همان unit of work را برای تست بعدی به ارث می‌گذارد؛ آنجا اولین `flush()` با
«A new entity was found through the relationship …» می‌شکند. `setUp` فقط وقتی ریست می‌کرد
که manager **بسته** باشد، و این حالت manager را باز ولی آلوده می‌گذارد.
رفع: یک `$this->em->clear()` در `ApiTestCase::setUp`. عمداً `clear()` نه `resetManager()`
همان نمونه می‌ماند، پس هیچ ارجاعی به managerِ مرده نمی‌رسد و فقط identity map خالی می‌شود.
دو اجرای کاملِ پیاپی سبز شد.
## تغییرات این جلسه
```
src/Shared/Controller/PermissionGateTrait.php گِیت مشترک (جدید)
src/Shared/Controller/PermissionGateTrait.php گِیت مشترک + پیش‌چکِ منشی (جدید)
src/Resource/Controller/ResourcePermissionTrait.php روی گِیت مشترک سوار شد
src/Billing/Controller/BillingController.php ۱۲ روت → payments.view/create/update
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.view/create
src/Billing/Controller/BillingController.php ۱۲ روت → payments.* + تبدیل نوعِ فیلترها
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.* + تزریق AddressResolver
src/Appointment/Plan/Controller/AppointmentPlanController.php ۳ روت → services.* (یافتهٔ ۹)
tests/Shared/ApiLeastPrivilegeTest.php پویش نوشتنی + ALLOWED_WRITE؛ KNOWN_GAPS خالی شد
src/Appointment/Controller/AppointmentController.php ۴ پیش‌چک (یافتهٔ ۱۰)
src/Appointment/Controller/AppointmentSettingsController.php ۹ پیش‌چک
src/Clinic/Controller/ClinicController.php ۲ پیش‌چک
src/Clinic/Controller/ClinicDoctorPermissionController.php ۱ پیش‌چک
src/ClinicInvitation/Controller/ClinicInvitationController.php ۴ پیش‌چک
src/Appointment/Availability/Controller/ResourceBlockController.php ۲ پیش‌چک
src/Blog/Command/SanitizeBlogBodiesCommand.php پاک‌سازی مقاله‌های قدیمی (جدید)
config/packages/html_sanitizer.yaml سه attributeِ ظاهریِ جدول مجاز شد
assets/admin/components/RichTextEditor.tsx ادیتور مشترک روی ckeditor5@48 (جدید)
assets/admin/pages/BlogFormPage.tsx استفاده از ادیتور مشترک
assets/admin/pages/RepresentationBlogFormPage.tsx استفاده از ادیتور مشترک
assets/admin/styles.css .blog-body — ظاهر جدولِ مقاله
package.json ckeditor5@48؛ build-classic حذف شد
phpstan-baseline.neon دو ردیفِ کهنه رفت، ۴۴ → ۴۳
پنج سرویس (Patient/Inventory/Secretary/ClinicService/…) propertyهای بلااستفاده حذف شد
tests/Shared/ApiLeastPrivilegeTest.php پویش نوشتنی + ALLOWED_WRITE + قفلِ GATE_BEFORE_LOOKUP
tests/Staff/StaffCrossTenantTest.php IDOR بین‌محیطیِ پرسنل (جدید)
tests/Appointment/AppointmentTreatmentSessionLinkTest.php رگرسیونِ یافتهٔ ۱۱ (جدید)
tests/Blog/BlogBodySanitizerTest.php attributeهای جدول
tests/Secretary/SecretaryAppointmentScopeTest.php منشیِ بی‌رابطه: خالی → ۴۰۳
docs/api/billing.md · appointment.md · appointment-plan.md مجوز هر روت
tests/ApiTestCase.php clear() در setUp — رفعِ ناپایداریِ سوییت
docs/api/billing.md · appointment.md · appointment-plan.md · blog.md · insurance.md · doctor-service.md
```
---