From 7ac8ddbd2534ee3747d747d0c13672ec680eade4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 22:01:34 +0330 Subject: [PATCH] feat(config): add central maintenance mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a platform-wide maintenance switch controlled from the admin panel. A single kernel.request subscriber (priority 6, after the firewall listener) short-circuits every request with 503, so no controller has to check it and all API clients — the admin SPA, nobat724_front and clinic-pro-tauri — are covered at once. - SiteConfig gains five maintenance_* keys; no entity change, no migration - MaintenanceService caches the state in Redis for 30s and is fail-open: a Redis or database failure never takes the site down by itself - API responses reuse the BaseController::error() envelope with code MAINTENANCE_MODE plus a Retry-After header; browsers get a self-contained Twig page (inline CSS, noindex) that renders even mid-deploy - Whitelist keeps /oauth/*, the login endpoints and /api/v1/admin/settings reachable, otherwise an admin could neither sign in nor switch it back off - Admin bypass falls back to decoding the Authorization JWT, because several admin-panel endpoints sit in the public_endpoints firewall (security: false) where no token is ever resolved and isGranted always returns false - A kernel.exception handler at priority 20 covers routing 404/405 and firewall 401, which are thrown before the request listener runs - app:maintenance on|off|status is the escape hatch when the panel is down Also removes a stray `APP_SECRET = ...` line from .env.dev: the spaces around `=` are rejected by Symfony Dotenv, which made every console command and the whole app fatal. The secret already lives in .env.local, as the comment above that line instructs. Co-Authored-By: Claude Fable 5 --- .claude/prompt/maintenance-mode.md | 262 ++++++++++++++++++ .env.dev | 1 - assets/admin/pages/SettingsPage.tsx | 90 +++++- docs/api/admin.md | 66 +++++ src/Config/Command/MaintenanceCommand.php | 67 +++++ .../Controller/SiteConfigController.php | 18 ++ .../Repository/SiteConfigRepository.php | 7 + src/Config/Service/MaintenanceService.php | 104 +++++++ .../EventSubscriber/MaintenanceSubscriber.php | 186 +++++++++++++ templates/maintenance.html.twig | 87 ++++++ tests/Shared/MaintenanceModeTest.php | 119 ++++++++ 11 files changed, 1005 insertions(+), 2 deletions(-) create mode 100644 .claude/prompt/maintenance-mode.md create mode 100644 src/Config/Command/MaintenanceCommand.php create mode 100644 src/Config/Service/MaintenanceService.php create mode 100644 src/Shared/EventSubscriber/MaintenanceSubscriber.php create mode 100644 templates/maintenance.html.twig create mode 100644 tests/Shared/MaintenanceModeTest.php diff --git a/.claude/prompt/maintenance-mode.md b/.claude/prompt/maintenance-mode.md new file mode 100644 index 00000000..f37360ed --- /dev/null +++ b/.claude/prompt/maintenance-mode.md @@ -0,0 +1,262 @@ +# Maintenance Mode مرکزی (Web + API + پنل‌ها) + +## پروژه + +`clinicpro` (backend Symfony + پنل ادمین React) + +کنترل مرکزی کاملاً در backend انجام می‌شود؛ چون `nobat724_front` و `clinic-pro-tauri` هر دو کلاینت همان `/api/v1/...` هستند، وقتی API با `503` پاسخ دهد آن‌ها هم عملاً وارد حالت تعمیرات می‌شوند. **نیازی به تغییر جداگانه در آن ریپوها نیست** (اختیاری: نمایش زیباتر پیام در فرانت — خارج از این پرامپت). + +## زمینه + +پروژه از قبل یک key-value store برای تنظیمات سایت دارد: + +- Entity: `src/Config/Entity/SiteConfig.php` (`config_key` PK، `config_value` text، `updated_at` int) +- Repository: `src/Config/Repository/SiteConfigRepository.php` — `DEFAULTS` const، `get()`، `getAll()`، `set()` +- Controller: `src/Config/Controller/SiteConfigController.php` — `GET/PATCH /api/v1/admin/settings` با `#[IsGranted('ROLE_ADMIN')]` و whitelist `ALLOWED_KEYS` +- صفحه ادمین: `assets/admin/pages/SettingsPage.tsx` (route `settings` در `assets/admin/App.tsx:201`) + +پس **نباید Entity جدید ساخت** — فقط کلید جدید به همین جدول اضافه می‌شود؛ **migration لازم نیست**. + +همچنین چهار EventSubscriber در `src/Shared/EventSubscriber/` وجود دارد که الگوی موجود پروژه است: + +| کلاس | رویداد | priority | +|---|---|---| +| `ExceptionSubscriber` | `kernel.exception` | 10 | +| `NumericFieldNormalizerSubscriber` | `kernel.request` | 8 | +| `AdminCspSubscriber` | `kernel.response` | 0 | +| `SecurityHeadersSubscriber` | `kernel.response` | 0 | + +## هدف + +یک Maintenance Mode حرفه‌ای که: + +- با یک سوییچ از پنل ادمین کل سیستم (Web + API + همه کلاینت‌ها) وارد حالت تعمیرات شود +- کاربر عادی فقط صفحه/پاسخ maintenance ببیند +- فقط `ROLE_ADMIN` بتواند وارد شود و از پنل ادمین استفاده کند +- پیام و عنوان صفحه maintenance از پنل ادمین قابل ویرایش باشد +- کنترل **مرکزی** باشد (یک `kernel.request` subscriber) نه پراکنده در کنترلرها + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Config/Repository/SiteConfigRepository.php` | افزودن کلیدهای جدید به `DEFAULTS` + کش | +| `src/Config/Controller/SiteConfigController.php` | افزودن کلیدها به `ALLOWED_KEYS` | +| `src/Config/Service/MaintenanceService.php` | **جدید** — منبع واحد تصمیم maintenance | +| `src/Shared/EventSubscriber/MaintenanceSubscriber.php` | **جدید** — کنترل مرکزی روی `kernel.request` | +| `src/Shared/Controller/HealthController.php` | باید در whitelist بماند | +| `src/Admin/Controller/AdminController.php` | catch-all `/admin/{reactRouting}` — SPA shell | +| `templates/maintenance.html.twig` | **جدید** — صفحه HTML حالت تعمیرات | +| `assets/admin/pages/SettingsPage.tsx` | افزودن بخش «حالت تعمیرات» | +| `docs/api/admin.md` | مستندسازی کلیدهای جدید + رفتار 503 | + +## وضعیت فعلی + +`src/Config/Repository/SiteConfigRepository.php:11` + +```php +class SiteConfigRepository extends ServiceEntityRepository +{ + public const DEFAULTS = [ + 'site_name' => '...', + // ... ~28 کلید + ]; + + public function get(string $key): ?string { /* DB، fallback به DEFAULTS */ } + public function getAll(): array { /* findAll() + پر کردن با DEFAULTS */ } + public function set(string $key, ?string $value): void { /* بدون flush */ } +} +``` + +`src/Config/Controller/SiteConfigController.php:18` + +```php +private const ALLOWED_KEYS = [ /* whitelist 28 کلید */ ]; +// PATCH کلیدهای خارج از whitelist را بی‌صدا نادیده می‌گیرد +``` + +`src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php:34` — الگوی subscriber روی `kernel.request`: + +```php +public static function getSubscribedEvents(): array +{ + return [KernelEvents::REQUEST => ['onKernelRequest', 8]]; +} +``` + +`config/packages/security.yaml` — **بدون `role_hierarchy`**؛ نقش‌ها تخت‌اند. `/admin/*` در هیچ firewall pattern نیست (HTML عمومی؛ auth سمت کلاینت + روی `/api/v1`). + +`config/packages/cache.yaml` — app pool روی `cache.adapter.redis` (`REDIS_URL`). هیچ‌جا `SiteConfig` کش نمی‌شود. + +--- + +## وظایف + +### ۱. افزودن کلیدهای تنظیمات + +در `SiteConfigRepository::DEFAULTS` این کلیدها اضافه شوند: + +```php +'maintenance_enabled' => '0', +'maintenance_title' => 'در حال به‌روزرسانی سیستم', +'maintenance_message' => 'سامانه موقتاً برای انجام عملیات فنی در دسترس نیست. لطفاً چند دقیقه دیگر مجدداً تلاش کنید.', +'maintenance_retry_after' => '600', // ثانیه — هدر Retry-After +'maintenance_allowed_ips' => '', // CSV، اختیاری — دور زدن maintenance برای IP خاص +``` + +همین پنج کلید به `SiteConfigController::ALLOWED_KEYS` اضافه شوند تا از `PATCH /api/v1/admin/settings` قابل تغییر باشند. + +**نکته:** `maintenance_enabled` باید boolean-ish پارس شود (`'1'`, `'true'`, `'on'` → true). مقدار ورودی از PATCH ممکن است `true` boolean یا `"1"` باشد. + +### ۲. کش کردن خواندن تنظیمات (الزامی، نه اختیاری) + +`MaintenanceSubscriber` روی **هر** درخواست اجرا می‌شود؛ زدن به DB در هر request قابل قبول نیست. + +در `MaintenanceService` مقدار را از `CacheInterface` (app pool، همان الگوی `TokenService.php:15`) با TTL کوتاه (مثلاً ۳۰ ثانیه) بخوان: + +```php +final class MaintenanceService +{ + private const CACHE_KEY = 'maintenance_state'; + private const TTL = 30; + + public function __construct( + private SiteConfigRepository $configRepo, + private CacheInterface $cache, + ) {} + + /** @return array{enabled:bool,title:string,message:string,retryAfter:int,allowedIps:string[]} */ + public function getState(): array + { + return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item): array { + $item->expiresAfter(self::TTL); + // خواندن ۵ کلید از configRepo و نرمال‌سازی نوع + }); + } + + public function invalidate(): void + { + $this->cache->delete(self::CACHE_KEY); + } +} +``` + +در `SiteConfigController` بعد از هر `PATCH` موفق روی هر کلید `maintenance_*`، متد `invalidate()` صدا زده شود تا تغییر فوراً اعمال شود (نه بعد از ۳۰ ثانیه). + +**Edge case:** اگر Redis در دسترس نبود، `getState()` نباید کل سایت را down کند — استثنا را بگیر و مستقیم از DB بخوان؛ اگر DB هم خطا داد `enabled => false` برگردان (fail-open). Maintenance mode نباید خودش عامل قطعی شود. + +### ۳. Subscriber مرکزی + +فایل جدید `src/Shared/EventSubscriber/MaintenanceSubscriber.php`، دقیقاً با الگوی سایر subscriberها در همان پوشه. + +```php +public static function getSubscribedEvents(): array +{ + // priority بالا تا قبل از firewall/router کار کند اما بعد از تشخیص مسیر + return [KernelEvents::REQUEST => ['onKernelRequest', 6]]; +} +``` + +منطق: + +``` +if (!$event->isMainRequest()) return; +if (!$state['enabled']) return; + +$path = $request->getPathInfo(); + +// 1) whitelist مسیرهایی که هرگز نباید بلاک شوند +if (isWhitelisted($path)) return; + +// 2) IP allowlist +if (in_array($request->getClientIp(), $state['allowedIps'], true)) return; + +// 3) اگر کاربر لاگین‌شده ROLE_ADMIN دارد → عبور +if ($this->security->isGranted('ROLE_ADMIN')) return; + +// 4) بلاک +$event->setResponse($this->buildResponse($request, $state)); +$event->stopPropagation(); +``` + +**whitelist مسیرها (بحرانی — بدون این، ادمین قفل بیرون می‌ماند):** + +- `/api/v1/auth/*` (کل مسیرهای ورود/OTP/refresh token) — وگرنه ادمین نمی‌تواند لاگین کند +- `/api/v1/admin/settings` (GET و PATCH) — وگرنه راه خاموش کردن maintenance بسته می‌شود +- مسیر health check از `src/Shared/Controller/HealthController.php` +- `/admin` و `/admin/*` (SPA shell) — خودِ HTML باید بارگذاری شود؛ محافظت واقعی روی `/api/v1` است +- asset‌های Encore (`/build/*`) و `/favicon.ico` +- در محیط `dev`: `/_wdt/*`، `/_profiler/*` + +whitelist را به‌صورت آرایه‌ی const از prefixها در همان کلاس تعریف کن، نه پراکنده در if. + +**نکته ترتیب اجرا:** `Security::isGranted()` نیاز به توکن firewall دارد. Firewall listener روی `kernel.request` با priority `8` اجرا می‌شود. اگر priority انتخابی باعث شود توکن هنوز ست نشده باشد، `isGranted` همیشه false برمی‌گرداند و ادمین هم بلاک می‌شود. **این را عملاً تست کن**: با کاربر ادمین لاگین‌شده و maintenance روشن، یک endpoint معمولی `/api/v1/...` را صدا بزن و مطمئن شو `200` می‌گیری نه `503`. اگر بلاک شد، priority را پایین‌تر از firewall ببر (عدد کوچک‌تر) تا بعد از authentication اجرا شود. + +### ۴. تفکیک پاسخ API از پاسخ Web + +`buildResponse()` باید بر اساس نوع درخواست تصمیم بگیرد: + +**درخواست API** — اگر `str_starts_with($path, '/api/')` یا هدر `Accept` شامل `application/json` بود: + +```php +return new JsonResponse([ + 'success' => false, + 'data' => null, + 'errors' => [[ + 'code' => 'MAINTENANCE_MODE', + 'message' => $state['message'], + ]], +], 503, ['Retry-After' => (string) $state['retryAfter']]); +``` + +فرمت دقیقاً باید با envelope خطای `BaseController::error()` (`src/Shared/Controller/BaseController.php:32`) یکی باشد تا `nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js` بدون تغییر بتوانند آن را parse کنند. + +**درخواست Web** — رندر `templates/maintenance.html.twig` با status `503` و همان هدر `Retry-After`. + +قالب باید: +- RTL، فارسی، فونت Vazir (هماهنگ با بقیه templateها) +- بدون وابستگی به build اسِت‌ها (CSS اینلاین) — چون ممکن است در حین deploy اجرا شود +- `title` و `message` را از state بگیرد +- `noindex` در متا (``) تا صفحه تعمیرات ایندکس نشود + +### ۵. تعامل با `ExceptionSubscriber` + +`ExceptionSubscriber` روی `kernel.exception` با priority `10` است. چون maintenance پاسخ را روی `kernel.request` ست می‌کند و exception پرتاب نمی‌کند، تداخلی نباید باشد — اما بررسی کن که `setResponse()` + `stopPropagation()` باعث رد شدن از `SecurityHeadersSubscriber` (روی `kernel.response`) نشود. `stopPropagation` فقط روی همان event اثر دارد، پس `kernel.response` همچنان اجرا می‌شود — تأیید کن هدرهای امنیتی روی پاسخ 503 هم ست می‌شوند. + +### ۶. UI پنل ادمین + +در `assets/admin/pages/SettingsPage.tsx` یک بخش (Card/Section هم‌شکل با بخش‌های موجود همان فایل) با عنوان «حالت تعمیرات» اضافه کن: + +- سوییچ/چک‌باکس `maintenance_enabled` +- `TextField` برای `maintenance_title` +- `TextField` چندخطی برای `maintenance_message` +- عدد برای `maintenance_retry_after` (ثانیه) +- ورودی متنی `maintenance_allowed_ips` (CSV) با helper text + +الزامات UI: + +- **حتماً از کامپوننت‌ها و تم موجود همان صفحه استفاده کن؛ طراحی جدید نساز.** اگر `select` لازم شد، `SearchableSelect` استفاده شود نه ` + + +
+ + ثانیه +
+
+ +