feat(config): add central maintenance mode
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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` در متا (`<meta name="robots" content="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` استفاده شود نه `<select>` بومی.
|
||||
- وقتی سوییچ روشن میشود، قبل از ذخیره یک تأیید (confirm dialog) نشان بده — این عمل کل سایت را برای کاربران عادی از دسترس خارج میکند.
|
||||
- وقتی maintenance فعال است، یک بنر هشدار ثابت و واضح در بالای صفحه (یا `Topbar`) نمایش داده شود تا ادمین فراموش نکند سایت down است.
|
||||
- خواندن/نوشتن دقیقاً از همان `GET/PATCH /api/v1/admin/settings` موجود؛ endpoint جدید نساز.
|
||||
- دسترسی صفحه فقط نقش `admin` (همان الگوی `RoleRoute` در `App.tsx`).
|
||||
|
||||
### ۷. دستور کنسول (اختیاری اما توصیهشده)
|
||||
|
||||
یک command مثل `app:maintenance` با آرگومان `on|off|status` بساز تا اگر پنل ادمین به هر دلیل در دسترس نبود، از طریق `ddev exec php bin/console app:maintenance off` بتوان خارج شد. باید بعد از تغییر، کش را `invalidate()` کند.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **Entity جدید نساز و migration ننویس** — `SiteConfig` کافی است.
|
||||
- Fail-open الزامی است: هر خطای Redis/DB داخل `MaintenanceService` نباید سایت را بلاک کند.
|
||||
- بدون کش، subscriber بهازای هر request یک کوئری میزند — این را حتماً پیاده کن.
|
||||
- `role_hierarchy` وجود ندارد؛ `ROLE_ADMIN` باید مستقیم در `roles` کاربر باشد. فرض نکن نقشهای دیگر آن را ارث میبرند.
|
||||
- کاربران با نقش `ROLE_DOCTOR` / `ROLE_CLINIC` / `ROLE_SECRETARY` **باید** بلاک شوند — فقط ادمین عبور میکند.
|
||||
- پاسخ 503 برای API باید دقیقاً envelope خطای پروژه را داشته باشد؛ اگر شکل متفاوتی برگردانی، کلاینتهای `nobat724_front` و `clinic-pro-tauri` هنگام parse خطا میدهند.
|
||||
- بعد از اتمام، `docs/api/admin.md` را بهروز کن: کلیدهای جدید تنظیمات + توضیح رفتار `503 MAINTENANCE_MODE` و هدر `Retry-After` روی همه endpointها.
|
||||
- تست دستی الزامی (با اکانت `09390039833 / 09390039833`):
|
||||
1. maintenance روشن → کاربر ناشناس روی `/api/v1/doctor` باید `503` بگیرد
|
||||
2. کاربر ادمین لاگینشده روی همان endpoint باید `200` بگیرد
|
||||
3. `POST /api/v1/auth/...` (لاگین) باید در حالت maintenance کار کند
|
||||
4. `PATCH /api/v1/admin/settings` با `maintenance_enabled=0` باید سایت را فوراً برگرداند (نه بعد از ۳۰ ثانیه)
|
||||
5. باز کردن `/` در مرورگر → صفحه HTML تعمیرات با status 503
|
||||
Reference in New Issue
Block a user