From 47323daa27c38b9a719aaf847d377d9dc79101a2 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 8 Aug 2026 11:40:17 +0330 Subject: [PATCH] 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 --- assets/admin/components/RichTextEditor.tsx | 78 + assets/admin/pages/BlogFormPage.test.tsx | 5 +- assets/admin/pages/BlogFormPage.tsx | 16 +- .../pages/RepresentationBlogFormPage.tsx | 12 +- assets/admin/styles.css | 10 + config/packages/html_sanitizer.yaml | 7 +- docs/api/blog.md | 43 +- docs/security/AUDIT-2026-08-07.md | 186 +- package-lock.json | 13056 +++------------- package.json | 2 +- phpstan-baseline.neon | 24 +- .../Controller/ResourceBlockController.php | 12 + .../Controller/AppointmentController.php | 22 + .../AppointmentSettingsController.php | 54 + .../Controller/MyAppointmentsController.php | 4 + src/Auth/Controller/AuthController.php | 2 +- src/Billing/Controller/BillingController.php | 11 +- .../Command/SanitizeBlogBodiesCommand.php | 188 + src/Clinic/Controller/ClinicController.php | 10 + .../ClinicDoctorPermissionController.php | 5 + .../Controller/ClinicInvitationController.php | 20 + .../Controller/ClinicServiceController.php | 2 - src/ClinicService/Entity/ServiceItem.php | 8 + src/Doctor/Service/DoctorClaimService.php | 5 +- src/Inventory/Service/InventoryService.php | 2 - src/Patient/Service/PatientService.php | 6 - src/Patient/Service/RecordNumberGenerator.php | 2 +- .../Controller/ResourceController.php | 2 +- src/Secretary/Service/SecretaryService.php | 6 +- src/Shared/Controller/PermissionGateTrait.php | 32 +- tests/ApiTestCase.php | 12 + .../AppointmentTreatmentSessionLinkTest.php | 100 + tests/Blog/BlogBodySanitizerTest.php | 24 + tests/Shared/ApiLeastPrivilegeTest.php | 72 +- tests/Staff/StaffCrossTenantTest.php | 13 + yarn.lock | 2094 +-- 36 files changed, 3461 insertions(+), 12686 deletions(-) create mode 100644 assets/admin/components/RichTextEditor.tsx create mode 100644 src/Blog/Command/SanitizeBlogBodiesCommand.php create mode 100644 tests/Appointment/AppointmentTreatmentSessionLinkTest.php diff --git a/assets/admin/components/RichTextEditor.tsx b/assets/admin/components/RichTextEditor.tsx new file mode 100644 index 00000000..b0f8d0b5 --- /dev/null +++ b/assets/admin/components/RichTextEditor.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { CKEditor } from '@ckeditor/ckeditor5-react'; +import { + ClassicEditor, + Essentials, + Paragraph, + Heading, + Bold, + Italic, + Link, + List, + BlockQuote, + Table, + TableToolbar, + Undo, +} from 'ckeditor5'; +import translations from 'ckeditor5/translations/fa.js'; +import 'ckeditor5/ckeditor5.css'; + +/** + * ادیتور متن غنی مقاله‌ها. + * + * تا ۲۰۲۶-۰۸-۰۸ هر دو صفحهٔ مقاله مستقیم `@ckeditor/ckeditor5-build-classic` را + * import می‌کردند. آن پکیج deprecated بود و ۶۲ advisory داشت (یافتهٔ ۴ آدیت + * ۲۰۲۶-۰۸-۰۷). جایگزینش پکیج umbrella `ckeditor5` است که در آن، برخلاف build + * آماده، فهرست پلاگین‌ها صریح است. + * + * پیکربندی اینجا متمرکز شد تا مهاجرت بعدی یک فایل باشد نه دو صفحه — و تا نوار + * ابزارِ دو صفحه از هم واگرا نشود. + * + * فهرست پلاگین‌ها دقیقاً همان دکمه‌های نوار ابزارِ قبلی است، نه بیشتر: هر پلاگین + * اضافه یعنی markup تازه‌ای که `html_sanitizer.yaml` هنوز مجازش نکرده و هنگام + * ذخیره حذف می‌شود. + */ +export default function RichTextEditor({ + value, + onChange, +}: { + value: string; + onChange: (html: string) => void; +}) { + return ( +
+ onChange(editor.getData())} + config={{ + licenseKey: 'GPL', + language: 'fa', + translations: [translations], + plugins: [ + Essentials, + Paragraph, + Heading, + Bold, + Italic, + Link, + List, + BlockQuote, + Table, + TableToolbar, + Undo, + ], + toolbar: [ + 'heading', '|', + 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', + 'blockQuote', 'insertTable', '|', + 'undo', 'redo', + ], + table: { + contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'], + }, + }} + /> +
+ ); +} diff --git a/assets/admin/pages/BlogFormPage.test.tsx b/assets/admin/pages/BlogFormPage.test.tsx index c7520fe0..06be5ce4 100644 --- a/assets/admin/pages/BlogFormPage.test.tsx +++ b/assets/admin/pages/BlogFormPage.test.tsx @@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event'; import { Routes, Route } from 'react-router'; import { renderWithProviders } from '@/test/utils'; -vi.mock('@ckeditor/ckeditor5-react', () => ({ CKEditor: () => null })); -vi.mock('@ckeditor/ckeditor5-build-classic', () => ({ default: {} })); +// ادیتور در jsdom بالا نمی‌آید و به این تست ربطی ندارد؛ کلِ wrapper mock می‌شود +// تا mockهای پکیج‌های داخلی‌اش با هر مهاجرت CKEditor عوض نشوند. +vi.mock('../components/RichTextEditor', () => ({ default: () => null })); vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock('@/components/ui/SearchableSelect', () => ({ default: () => null })); vi.mock('@/lib/api', () => ({ diff --git a/assets/admin/pages/BlogFormPage.tsx b/assets/admin/pages/BlogFormPage.tsx index 9f5e7c73..a94bbb7a 100644 --- a/assets/admin/pages/BlogFormPage.tsx +++ b/assets/admin/pages/BlogFormPage.tsx @@ -4,8 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; -import { CKEditor } from '@ckeditor/ckeditor5-react'; -import ClassicEditor from '@ckeditor/ckeditor5-build-classic'; +import RichTextEditor from '../components/RichTextEditor'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { Blog, City } from '../types'; @@ -174,18 +173,7 @@ export default function BlogFormPage() { control={control} name="body" render={({ field }) => ( -
- string }) => field.onChange(editor.getData())} - config={{ - licenseKey: 'GPL', - language: 'fa', - toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'], - }} - /> -
+ )} /> {errors.body &&

{errors.body.message}

} diff --git a/assets/admin/pages/RepresentationBlogFormPage.tsx b/assets/admin/pages/RepresentationBlogFormPage.tsx index c8fb01e2..0dca36b4 100644 --- a/assets/admin/pages/RepresentationBlogFormPage.tsx +++ b/assets/admin/pages/RepresentationBlogFormPage.tsx @@ -3,8 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; -import { CKEditor } from '@ckeditor/ckeditor5-react'; -import ClassicEditor from '@ckeditor/ckeditor5-build-classic'; +import RichTextEditor from '../components/RichTextEditor'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { Blog } from '../types'; @@ -117,14 +116,7 @@ export default function RepresentationBlogFormPage() { control={control} name="body" render={({ field }) => ( -
- string }) => field.onChange(editor.getData())} - config={{ licenseKey: 'GPL', language: 'fa', toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'] }} - /> -
+ )} /> {errors.body &&

{errors.body.message}

} diff --git a/assets/admin/styles.css b/assets/admin/styles.css index 9dd46720..573d919b 100644 --- a/assets/admin/styles.css +++ b/assets/admin/styles.css @@ -1020,6 +1020,16 @@ html, body { max-width: 100%; overflow-x: hidden; } .wh-two-col { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--gap); align-items: start; } @media (max-width: 1100px) { .wh-two-col { grid-template-columns: minmax(0, 1fr); } } +/* ── بدنهٔ مقاله در صفحهٔ بازبینی ─────────────────────────────────────────── + جدول‌های مقاله‌ها تا ۲۰۲۶-۰۸-۰۸ ظاهرشان را از `style` inline می‌گرفتند. آن + attribute در sanitizer عمداً ممنوع است — تنها attributeِ ظاهریِ جدول که + می‌تواند بارِ اجرایی حمل کند — پس همان ظاهر اینجا، در لایهٔ درست، بازسازی + می‌شود. `border` و `cellpadding` هنوز از خودِ HTML می‌آیند. */ +.blog-body table { border-collapse: collapse; width: 100%; } +.blog-body th, .blog-body td { border: 1px solid var(--border); padding: 8px; } +.blog-body th { background: var(--surface-2); font-weight: 600; } +.blog-body img { max-width: 100%; height: auto; } + /* ── CKEditor 5 ──────────────────────────────────────────────────────────── ادیتور همهٔ رنگ‌هایش را از متغیرهای --ck-color-* خودش می‌گیرد و پیش‌فرض آن‌ها روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید می‌ماند. */ diff --git a/config/packages/html_sanitizer.yaml b/config/packages/html_sanitizer.yaml index f438694c..336922b7 100644 --- a/config/packages/html_sanitizer.yaml +++ b/config/packages/html_sanitizer.yaml @@ -45,7 +45,12 @@ framework: div: [] a: ['href', 'title', 'target', 'rel'] img: ['src', 'alt', 'title', 'width', 'height'] - table: [] + # سه attributeِ ظاهریِ قدیمیِ جدول عمداً مجازند. هیچ‌کدام + # نمی‌توانند حاملِ اسکریپت یا URL باشند — مقدارشان عدد است — و + # بدونشان جدولِ صدها مقالهٔ موجود حاشیه و فاصله‌اش را از دست + # می‌داد. `style` همچنان ممنوع است: تنها attributeِ ظاهریِ این + # جمع که می‌تواند بارِ اجرایی حمل کند. + table: ['border', 'cellpadding', 'cellspacing'] thead: [] tbody: [] tfoot: [] diff --git a/docs/api/blog.md b/docs/api/blog.md index eceadc60..9a7d8fd9 100644 --- a/docs/api/blog.md +++ b/docs/api/blog.md @@ -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` است. سه قاعده‌ای که رفتار قابل‌مشاهده +دارند: + +- `` به متنِ `alert(1)` تبدیل می‌شد. +- روی هر `` مقدار `rel="noopener noreferrer"` **تحمیل** می‌شود؛ طرح‌های مجاز فقط + `http`, `https`, `mailto`. +- `` سه 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: [...] }` کش می‌کند. بدون باطل‌سازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمی‌شد. diff --git a/docs/security/AUDIT-2026-08-07.md b/docs/security/AUDIT-2026-08-07.md index 064dc7a5..a2a550ff 100644 --- a/docs/security/AUDIT-2026-08-07.md +++ b/docs/security/AUDIT-2026-08-07.md @@ -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` برای `
` هیچ 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 شدن ` ` | +| حذف attribute | ۲۸ | فقط `style` روی ۲۲ جدول و ۱ `div`؛ بقیه ترمیمِ HTML شکسته | +| بدون تغییر | ۱ | — | + +**هیچ مقاله‌ای markup اجرایی نداشت** — نه `