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>
15 KiB
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_keyPK،config_valuetext،updated_atint) - Repository:
src/Config/Repository/SiteConfigRepository.php—DEFAULTSconst،get()،getAll()،set() - Controller:
src/Config/Controller/SiteConfigController.php—GET/PATCH /api/v1/admin/settingsبا#[IsGranted('ROLE_ADMIN')]و whitelistALLOWED_KEYS - صفحه ادمین:
assets/admin/pages/SettingsPage.tsx(routesettingsدر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.requestsubscriber) نه پراکنده در کنترلرها
فایلهای مرتبط
| فایل | نقش |
|---|---|
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
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
private const ALLOWED_KEYS = [ /* whitelist 28 کلید */ ];
// PATCH کلیدهای خارج از whitelist را بیصدا نادیده میگیرد
src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php:34 — الگوی subscriber روی kernel.request:
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 این کلیدها اضافه شوند:
'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 کوتاه (مثلاً ۳۰ ثانیه) بخوان:
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ها در همان پوشه.
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 بود:
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_titleTextFieldچندخطی برای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):- maintenance روشن → کاربر ناشناس روی
/api/v1/doctorباید503بگیرد - کاربر ادمین لاگینشده روی همان endpoint باید
200بگیرد POST /api/v1/auth/...(لاگین) باید در حالت maintenance کار کندPATCH /api/v1/admin/settingsباmaintenance_enabled=0باید سایت را فوراً برگرداند (نه بعد از ۳۰ ثانیه)- باز کردن
/در مرورگر → صفحه HTML تعمیرات با status 503
- maintenance روشن → کاربر ناشناس روی