Files
clinicpro/.claude/prompt/maintenance-mode.md
T
hamedandClaude Fable 5 7ac8ddbd25 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>
2026-07-19 22:01:34 +03:30

15 KiB
Raw Blame History

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.phpDEFAULTS const، get()، getAll()، set()
  • Controller: src/Config/Controller/SiteConfigController.phpGET/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

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_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