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
|
||||
@@ -3,4 +3,3 @@
|
||||
# APP_SECRET intentionally not committed here — set it in .env.local (git-ignored).
|
||||
# .env.dev loads AFTER .env.local, so a value here would override the local one.
|
||||
###< symfony/framework-bundle ###
|
||||
APP_SECRET = c7f26807530b758460918148a3d11b2f
|
||||
|
||||
@@ -10,7 +10,9 @@ import { numericField } from '../lib/forms';
|
||||
import {
|
||||
Cog6ToothIcon, ClockIcon, CalculatorIcon, CreditCardIcon, ChatBubbleLeftRightIcon,
|
||||
MagnifyingGlassIcon, CheckCircleIcon, ExclamationTriangleIcon, EyeIcon, EyeSlashIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
interface TaxHistoryRow {
|
||||
tax_percent: number;
|
||||
@@ -46,6 +48,12 @@ const schema = z.object({
|
||||
mellat_password: z.string(),
|
||||
sep_enabled: z.string(),
|
||||
sep_terminal_id: z.string(),
|
||||
// maintenance mode
|
||||
maintenance_enabled: z.string(),
|
||||
maintenance_title: z.string().min(1, 'عنوان صفحه تعمیرات الزامی است'),
|
||||
maintenance_message: z.string().min(1, 'پیام صفحه تعمیرات الزامی است'),
|
||||
maintenance_retry_after: z.string(),
|
||||
maintenance_allowed_ips: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
@@ -78,11 +86,16 @@ const toForm = (s: Partial<Settings>): FormValues => ({
|
||||
mellat_password: s.mellat_password ?? '',
|
||||
sep_enabled: s.sep_enabled ?? '1',
|
||||
sep_terminal_id: s.sep_terminal_id ?? '',
|
||||
maintenance_enabled: s.maintenance_enabled ?? '0',
|
||||
maintenance_title: s.maintenance_title ?? 'در حال بهروزرسانی سیستم',
|
||||
maintenance_message: s.maintenance_message ?? '',
|
||||
maintenance_retry_after: s.maintenance_retry_after ?? '600',
|
||||
maintenance_allowed_ips: s.maintenance_allowed_ips ?? '',
|
||||
});
|
||||
|
||||
// ── Section definitions (drive the nav rail + search) ───────────────────────
|
||||
|
||||
type SectionId = 'general' | 'appointments' | 'financial' | 'payment' | 'sms';
|
||||
type SectionId = 'general' | 'appointments' | 'financial' | 'payment' | 'sms' | 'maintenance';
|
||||
|
||||
interface SectionDef {
|
||||
id: SectionId;
|
||||
@@ -99,6 +112,7 @@ const SECTIONS: SectionDef[] = [
|
||||
{ id: 'financial', label: 'مالی', desc: 'پورسانت، مالیات و کارمزدها', Icon: CalculatorIcon, bg: 'var(--info-bg)', fg: 'var(--info)', keywords: 'پورسانت مالیات کارمزد پیامک نوبت مبلغ ریال tax commission' },
|
||||
{ id: 'payment', label: 'درگاه پرداخت', desc: 'ملت، سپ و حالت تست', Icon: CreditCardIcon, bg: 'var(--success-bg)', fg: 'var(--success)', keywords: 'درگاه پرداخت ملت سپ mellat sep terminal تست gateway' },
|
||||
{ id: 'sms', label: 'پیامک', desc: 'پیکربندی سرویس پیامک', Icon: ChatBubbleLeftRightIcon, bg: 'var(--violet-bg)', fg: 'var(--violet)', keywords: 'پیامک sms کاوهنگار kavenegar api' },
|
||||
{ id: 'maintenance', label: 'حالت تعمیرات', desc: 'قطع سراسری سرویس', Icon: WrenchScrewdriverIcon, bg: 'var(--danger-bg)', fg: 'var(--danger)', keywords: 'تعمیرات نگهداری maintenance قطع سرویس بستن سایت downtime' },
|
||||
];
|
||||
|
||||
// ── Small presentational helpers ────────────────────────────────────────────
|
||||
@@ -150,6 +164,7 @@ export default function SettingsPage() {
|
||||
const [active, setActive] = useState<SectionId>('general');
|
||||
const [search, setSearch] = useState('');
|
||||
const [savedFlash, setSavedFlash] = useState(false);
|
||||
const [confirmMaintenance, setConfirmMaintenance] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
@@ -222,10 +237,21 @@ export default function SettingsPage() {
|
||||
const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1';
|
||||
const taxEnabled = watch('tax_enabled') === '1';
|
||||
const altchaEnabled = watch('altcha_enabled') === '1';
|
||||
const maintenanceEnabled = watch('maintenance_enabled') === '1';
|
||||
|
||||
const toggle = (name: keyof FormValues, current: boolean) =>
|
||||
setValue(name, current ? '0' : '1', { shouldDirty: true });
|
||||
|
||||
// روشن کردن این کلید کل سایت را برای کاربران عادی از دسترس خارج میکند؛
|
||||
// خاموش کردن بیخطر است و تأیید نمیخواهد.
|
||||
const onMaintenanceToggle = () => {
|
||||
if (maintenanceEnabled) {
|
||||
toggle('maintenance_enabled', true);
|
||||
return;
|
||||
}
|
||||
setConfirmMaintenance(true);
|
||||
};
|
||||
|
||||
// search filters the nav rail
|
||||
const q = search.trim();
|
||||
const filtered = useMemo(
|
||||
@@ -245,6 +271,19 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
{maintenanceEnabled && (
|
||||
<div className="toggle-row warn on" style={{ marginBottom: 'var(--gap)', cursor: 'pointer' }}
|
||||
onClick={() => setActive('maintenance')}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<ExclamationTriangleIcon style={{ width: 20, height: 20, color: 'var(--danger)' }} />
|
||||
<div>
|
||||
<div className="tr-title">سایت در حالت تعمیرات است</div>
|
||||
<div className="tr-desc">کاربران عادی به هیچ بخشی دسترسی ندارند. برای بازگشایی به بخش «حالت تعمیرات» بروید.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 'var(--gap)' }}>
|
||||
<h1 className="section-title">تنظیمات</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>پیکربندی کلی پلتفرم — تغییرات پس از ذخیره اعمال میشوند</div>
|
||||
@@ -497,6 +536,45 @@ export default function SettingsPage() {
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* حالت تعمیرات */}
|
||||
{current.id === 'maintenance' && (
|
||||
<>
|
||||
<div className={`toggle-row warn${maintenanceEnabled ? ' on' : ''}`}>
|
||||
<div>
|
||||
<div className="tr-title">{maintenanceEnabled ? 'حالت تعمیرات فعال است' : 'حالت تعمیرات غیرفعال'}</div>
|
||||
<div className="tr-desc">
|
||||
{maintenanceEnabled
|
||||
? 'سایت عمومی، اپ دسکتاپ و همه APIها برای کاربران عادی با کد ۵۰۳ بستهاند. فقط مدیران دسترسی دارند.'
|
||||
: 'با فعالسازی، همه درخواستها بهجز ورود و همین صفحه تنظیمات مسدود میشوند. فقط مدیران دسترسی خواهند داشت.'}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle checked={maintenanceEnabled} onChange={onMaintenanceToggle} label="حالت تعمیرات" />
|
||||
</div>
|
||||
|
||||
<div className="settings-grid" style={{ marginTop: 18 }}>
|
||||
<Field label="عنوان صفحه تعمیرات" required error={errors.maintenance_title?.message}
|
||||
hint="در سربرگ صفحهای که به کاربران نمایش داده میشود.">
|
||||
<input {...register('maintenance_title')} className={`input${errors.maintenance_title ? ' err' : ''}`} placeholder="در حال بهروزرسانی سیستم" />
|
||||
</Field>
|
||||
<Field label="مدت تخمینی قطعی" hint="در هدر Retry-After پاسخها ارسال میشود تا کلاینتها بدانند چه زمانی دوباره تلاش کنند.">
|
||||
<div className="input-suffix">
|
||||
<input {...numericField(register('maintenance_retry_after'))} className="input" style={{ maxWidth: 140 }} placeholder="600" />
|
||||
<span className="suf">ثانیه</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="پیام صفحه تعمیرات" required span2 error={errors.maintenance_message?.message}
|
||||
hint="همین متن هم در صفحه HTML و هم در پاسخ JSON APIها به کاربران نمایش داده میشود.">
|
||||
<textarea {...register('maintenance_message')} className={`input${errors.maintenance_message ? ' err' : ''}`} rows={3}
|
||||
placeholder="سامانه موقتاً برای انجام عملیات فنی در دسترس نیست." />
|
||||
</Field>
|
||||
<Field label="IPهای مجاز" optional span2
|
||||
hint="با کاما جدا کنید. این IPها حتی در حالت تعمیرات و بدون ورود، دسترسی کامل دارند — برای تست پیش از بازگشایی.">
|
||||
<input {...register('maintenance_allowed_ips')} className="input" dir="ltr" placeholder="1.2.3.4, 5.6.7.8" />
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -528,6 +606,16 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmMaintenance}
|
||||
danger
|
||||
title="فعالسازی حالت تعمیرات"
|
||||
message="با ذخیره این تغییر، سایت عمومی، اپ دسکتاپ و تمام APIها برای همه کاربران غیرمدیر بسته میشوند. فقط مدیران میتوانند وارد شوند."
|
||||
confirmLabel="بله، فعال کن"
|
||||
onConfirm={() => { toggle('maintenance_enabled', false); setConfirmMaintenance(false); }}
|
||||
onCancel={() => setConfirmMaintenance(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1138,6 +1138,72 @@ Update one or more settings. Unknown keys are silently ignored.
|
||||
|
||||
---
|
||||
|
||||
## Maintenance Mode
|
||||
|
||||
A single switch that takes the **whole platform** offline — the public site, the admin SPA's data calls, every `/api/v1/*` endpoint, and therefore `nobat724_front` and `clinic-pro-tauri` too. Enforced centrally by `App\Shared\EventSubscriber\MaintenanceSubscriber`; no controller checks it itself.
|
||||
|
||||
### Settings keys
|
||||
|
||||
Managed through the same `GET`/`PATCH /api/v1/admin/settings` endpoints (whitelisted in `SiteConfigController::ALLOWED_KEYS`). Admin panel: `/admin/settings` → بخش «حالت تعمیرات».
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `maintenance_enabled` | `"0"` | `"1"`/`"true"`/`"on"`/`"yes"` = maintenance active |
|
||||
| `maintenance_title` | `در حال بهروزرسانی سیستم` | Heading of the HTML maintenance page |
|
||||
| `maintenance_message` | `سامانه موقتاً ...` | Shown both on the HTML page and as the API error `message` |
|
||||
| `maintenance_retry_after` | `"600"` | Seconds; sent as the `Retry-After` response header |
|
||||
| `maintenance_allowed_ips` | `""` | Comma-separated IPs that bypass maintenance without logging in |
|
||||
|
||||
Changing any `maintenance_*` key invalidates the 30-second `MaintenanceService` cache immediately, so a toggle takes effect on the next request.
|
||||
|
||||
### Behaviour while enabled
|
||||
|
||||
**API requests** (path starts with `/api/`, or `Accept: application/json`, or `X-Requested-With: XMLHttpRequest`):
|
||||
|
||||
```
|
||||
HTTP/1.1 503 Service Unavailable
|
||||
Retry-After: 600
|
||||
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{ "code": "MAINTENANCE_MODE", "message": "<maintenance_message>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The envelope is identical to `BaseController::error()`, so existing clients parse it unchanged. Clients should detect maintenance by **both** `status === 503` **and** `errors[0].code === "MAINTENANCE_MODE"` — a bare 503 may come from a reverse proxy.
|
||||
|
||||
**Browser requests** — `templates/maintenance.html.twig` rendered with HTTP `503`, same `Retry-After` header, `noindex, nofollow`.
|
||||
|
||||
Both the `kernel.request` (priority 6) and `kernel.exception` (priority 20) paths are covered, so routing 404/405 and firewall 401 responses also return maintenance rather than leaking their normal errors.
|
||||
|
||||
### Who gets through
|
||||
|
||||
1. **Whitelisted paths** — never blocked, in this order of importance:
|
||||
`/oauth/*`, `/api/v1/user/{login,send-code,verify-code,otp-login}`, `/session/token` (admins must still be able to sign in), `/api/v1/admin/settings` (the only way to turn maintenance back off), `/health`, `/admin*` (the SPA shell HTML — its data calls are still guarded), `/build/*`, `/favicon.ico`, `/_wdt`, `/_profiler`.
|
||||
2. **`maintenance_allowed_ips`** — exact client-IP match.
|
||||
3. **`ROLE_ADMIN`** — resolved from the firewall token. Several admin-panel endpoints (`/api/v1/doctors`, `/api/v1/categorys/*`, …) live in the `public_endpoints` firewall with `security: false`, where no token is ever resolved; for those the subscriber falls back to decoding the `Authorization: Bearer` JWT and checking its `roles` claim. An invalid or forged token does not bypass.
|
||||
|
||||
Every other role — `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_REPRESENTATION` — is blocked.
|
||||
|
||||
### Console escape hatch
|
||||
|
||||
If the admin panel is unreachable:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:maintenance status
|
||||
ddev exec php bin/console app:maintenance on
|
||||
ddev exec php bin/console app:maintenance off
|
||||
```
|
||||
|
||||
### Failure behaviour
|
||||
|
||||
`MaintenanceService` is **fail-open**: if Redis is unavailable it reads straight from the database, and if the database also fails it reports maintenance as disabled. This layer must never become the cause of an outage.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Registration Management
|
||||
|
||||
### GET `/api/v1/admin/pre-registrations`
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config\Command;
|
||||
|
||||
use App\Config\Service\MaintenanceService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* راه فرار از حالت تعمیرات وقتی پنل ادمین در دسترس نیست.
|
||||
*
|
||||
* ddev exec php bin/console app:maintenance status
|
||||
* ddev exec php bin/console app:maintenance off
|
||||
*/
|
||||
#[AsCommand(name: 'app:maintenance', description: 'Enable, disable or inspect maintenance mode')]
|
||||
class MaintenanceCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MaintenanceService $maintenance,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('action', InputArgument::REQUIRED, 'on | off | status');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$action = strtolower((string) $input->getArgument('action'));
|
||||
|
||||
if (!in_array($action, ['on', 'off', 'status'], true)) {
|
||||
$io->error(sprintf('Unknown action "%s". Use on, off or status.', $action));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
if ($action === 'status') {
|
||||
$state = $this->maintenance->getState();
|
||||
$io->definitionList(
|
||||
['enabled' => $state['enabled'] ? 'yes' : 'no'],
|
||||
['title' => $state['title']],
|
||||
['message' => $state['message']],
|
||||
['retry_after' => $state['retryAfter']],
|
||||
['allowed_ips' => implode(', ', $state['allowedIps']) ?: '-'],
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$action === 'on' ? $this->maintenance->enable() : $this->maintenance->disable();
|
||||
$this->em->flush();
|
||||
$this->maintenance->invalidate();
|
||||
|
||||
$io->success(sprintf('Maintenance mode is now %s.', $action === 'on' ? 'ENABLED' : 'DISABLED'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,12 @@ class SiteConfigController extends BaseController
|
||||
'mellat_password',
|
||||
'sep_enabled',
|
||||
'sep_terminal_id',
|
||||
// maintenance mode
|
||||
'maintenance_enabled',
|
||||
'maintenance_title',
|
||||
'maintenance_message',
|
||||
'maintenance_retry_after',
|
||||
'maintenance_allowed_ips',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
@@ -50,6 +56,7 @@ class SiteConfigController extends BaseController
|
||||
private readonly \App\Config\Repository\TaxRateHistoryRepository $taxHistoryRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly \App\Shared\Captcha\AltchaService $altcha,
|
||||
private readonly \App\Config\Service\MaintenanceService $maintenance,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/admin/settings', methods: ['GET'])]
|
||||
@@ -72,15 +79,26 @@ class SiteConfigController extends BaseController
|
||||
$prevTaxPercent = $this->configRepo->get('tax_percent');
|
||||
$prevTaxEnabled = $this->configRepo->get('tax_enabled');
|
||||
|
||||
$maintenanceTouched = false;
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (!in_array($key, self::ALLOWED_KEYS, true)) {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($key, 'maintenance_')) {
|
||||
$maintenanceTouched = true;
|
||||
}
|
||||
$this->configRepo->set($key, $value === null ? null : (string) $value);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
// بدون این، تغییر تا انقضای کش (۳۰ ثانیه) اعمال نمیشود؛ برای خاموش کردن
|
||||
// حالت تعمیرات این تأخیر قابل قبول نیست.
|
||||
if ($maintenanceTouched) {
|
||||
$this->maintenance->invalidate();
|
||||
}
|
||||
|
||||
$newTaxPercent = $this->configRepo->get('tax_percent');
|
||||
$newTaxEnabled = $this->configRepo->get('tax_enabled');
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ class SiteConfigRepository extends ServiceEntityRepository
|
||||
'mellat_username' => '',
|
||||
'mellat_password' => '',
|
||||
'sep_terminal_id' => '',
|
||||
// maintenance mode — کنترل مرکزی در MaintenanceSubscriber
|
||||
'maintenance_enabled' => '0',
|
||||
'maintenance_title' => 'در حال بهروزرسانی سیستم',
|
||||
'maintenance_message' => 'سامانه موقتاً برای انجام عملیات فنی در دسترس نیست. لطفاً چند دقیقه دیگر مجدداً تلاش کنید.',
|
||||
'maintenance_retry_after' => '600',
|
||||
// CSV؛ IPهایی که حتی در حالت تعمیرات دسترسی کامل دارند
|
||||
'maintenance_allowed_ips' => '',
|
||||
];
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config\Service;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* منبع واحد تصمیم برای حالت تعمیرات.
|
||||
*
|
||||
* روی هر درخواست خوانده میشود، پس مقدار کش میشود. هر خطای کش/دیتابیس باعث
|
||||
* برگشت وضعیت «غیرفعال» میشود (fail-open) — این لایه نباید خودش عامل قطعی سایت شود.
|
||||
*/
|
||||
class MaintenanceService
|
||||
{
|
||||
private const CACHE_KEY = 'maintenance_state';
|
||||
private const TTL = 30;
|
||||
|
||||
/** @var array{enabled:bool,title:string,message:string,retryAfter:int,allowedIps:string[]} */
|
||||
private const DISABLED = [
|
||||
'enabled' => false,
|
||||
'title' => '',
|
||||
'message' => '',
|
||||
'retryAfter' => 600,
|
||||
'allowedIps' => [],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/** @return array{enabled:bool,title:string,message:string,retryAfter:int,allowedIps:string[]} */
|
||||
public function getState(): array
|
||||
{
|
||||
try {
|
||||
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item): array {
|
||||
$item->expiresAfter(self::TTL);
|
||||
|
||||
return $this->readFromDatabase();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Maintenance state cache read failed', ['exception' => $e]);
|
||||
|
||||
try {
|
||||
return $this->readFromDatabase();
|
||||
} catch (\Throwable $dbError) {
|
||||
$this->logger->error('Maintenance state database read failed', ['exception' => $dbError]);
|
||||
|
||||
return self::DISABLED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->getState()['enabled'];
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
try {
|
||||
$this->cache->delete(self::CACHE_KEY);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Maintenance state cache invalidation failed', ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
public function enable(): void
|
||||
{
|
||||
$this->configRepo->set('maintenance_enabled', '1');
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
{
|
||||
$this->configRepo->set('maintenance_enabled', '0');
|
||||
}
|
||||
|
||||
/** @return array{enabled:bool,title:string,message:string,retryAfter:int,allowedIps:string[]} */
|
||||
private function readFromDatabase(): array
|
||||
{
|
||||
$allowedIps = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) $this->configRepo->get('maintenance_allowed_ips')),
|
||||
)));
|
||||
|
||||
return [
|
||||
'enabled' => self::isTruthy($this->configRepo->get('maintenance_enabled')),
|
||||
'title' => (string) $this->configRepo->get('maintenance_title'),
|
||||
'message' => (string) $this->configRepo->get('maintenance_message'),
|
||||
'retryAfter' => max(1, (int) $this->configRepo->get('maintenance_retry_after')),
|
||||
'allowedIps' => $allowedIps,
|
||||
];
|
||||
}
|
||||
|
||||
/** پنل ممکن است boolean بفرستد و در ستون متنی به '1' یا 'true' تبدیل شود. */
|
||||
private static function isTruthy(?string $value): bool
|
||||
{
|
||||
return in_array(strtolower((string) $value), ['1', 'true', 'on', 'yes'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\EventSubscriber;
|
||||
|
||||
use App\Config\Service\MaintenanceService;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* کنترل مرکزی حالت تعمیرات برای همهٔ درخواستها — وب، API و کلاینتهای خارجی
|
||||
* (`nobat724_front`, `clinic-pro-tauri`). هیچ کنترلری نباید خودش این را چک کند.
|
||||
*
|
||||
* priority عمداً پایینتر از فایروال Symfony (که روی ۸ اجرا میشود) است تا توکن
|
||||
* احراز هویت ست شده باشد؛ در غیر این صورت `isGranted('ROLE_ADMIN')` همیشه false
|
||||
* برمیگشت و خودِ ادمین هم پشت صفحهٔ تعمیرات قفل میشد.
|
||||
*/
|
||||
class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/**
|
||||
* مسیرهایی که هرگز نباید مسدود شوند.
|
||||
*
|
||||
* بدون `/oauth` و مسیرهای ورود، ادمین نمیتواند لاگین کند و بدون
|
||||
* `/api/v1/admin/settings` راهی برای خاموش کردن حالت تعمیرات باقی نمیماند.
|
||||
*/
|
||||
private const WHITELIST_PREFIXES = [
|
||||
'/health',
|
||||
'/oauth/',
|
||||
'/session/token',
|
||||
'/api/v1/user/login',
|
||||
'/api/v1/user/send-code',
|
||||
'/api/v1/user/verify-code',
|
||||
'/api/v1/user/otp-login',
|
||||
'/api/v1/admin/settings',
|
||||
'/admin',
|
||||
'/build/',
|
||||
'/favicon.ico',
|
||||
'/_wdt',
|
||||
'/_profiler',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly MaintenanceService $maintenance,
|
||||
private readonly Security $security,
|
||||
private readonly Environment $twig,
|
||||
private readonly JWTEncoderInterface $jwtEncoder,
|
||||
) {}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 6],
|
||||
// بالاتر از ExceptionSubscriber (۱۰) تا خطاهای مسیریابی و احراز هویت —
|
||||
// که پیش از priority ۶ پرتاب میشوند — هم صفحهٔ تعمیرات بگیرند نه ۴۰۴/۴۰۱.
|
||||
KernelEvents::EXCEPTION => ['onKernelException', 20],
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->maintenanceResponseFor($event->getRequest());
|
||||
if ($response === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse($response);
|
||||
$event->stopPropagation();
|
||||
}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->maintenanceResponseFor($event->getRequest());
|
||||
if ($response === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse($response);
|
||||
$event->stopPropagation();
|
||||
}
|
||||
|
||||
private function maintenanceResponseFor(Request $request): ?Response
|
||||
{
|
||||
$state = $this->maintenance->getState();
|
||||
if (!$state['enabled']) {
|
||||
return null;
|
||||
}
|
||||
if ($this->isWhitelisted($request->getPathInfo())) {
|
||||
return null;
|
||||
}
|
||||
if (in_array((string) $request->getClientIp(), $state['allowedIps'], true)) {
|
||||
return null;
|
||||
}
|
||||
if ($this->isAdmin($request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildResponse($request, $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* چند مسیر پرمصرف پنل ادمین (`/api/v1/doctors`, `/api/v1/categorys/`, …) داخل
|
||||
* فایروال `public_endpoints` با `security: false` هستند؛ آنجا هیچ توکنی resolve
|
||||
* نمیشود و `isGranted` همیشه false است. برای همین اگر فایروال ادمین را نشناخت،
|
||||
* JWT هدر Authorization مستقیماً بررسی میشود.
|
||||
*/
|
||||
private function isAdmin(Request $request): bool
|
||||
{
|
||||
if ($this->security->isGranted('ROLE_ADMIN')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$header = (string) $request->headers->get('Authorization');
|
||||
if (!str_starts_with($header, 'Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->jwtEncoder->decode(substr($header, 7));
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array('ROLE_ADMIN', (array) ($payload['roles'] ?? []), true);
|
||||
}
|
||||
|
||||
private function isWhitelisted(string $path): bool
|
||||
{
|
||||
foreach (self::WHITELIST_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array{title:string,message:string,retryAfter:int} $state */
|
||||
private function buildResponse(Request $request, array $state): Response
|
||||
{
|
||||
$headers = ['Retry-After' => (string) $state['retryAfter']];
|
||||
|
||||
if ($this->expectsJson($request)) {
|
||||
// شکل پاسخ باید دقیقاً با BaseController::error() یکی بماند تا کلاینتها
|
||||
// بدون تغییر بتوانند آن را parse کنند.
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'errors' => [[
|
||||
'code' => 'MAINTENANCE_MODE',
|
||||
'message' => $state['message'],
|
||||
]],
|
||||
], Response::HTTP_SERVICE_UNAVAILABLE, $headers);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
$this->twig->render('maintenance.html.twig', [
|
||||
'title' => $state['title'],
|
||||
'message' => $state['message'],
|
||||
]),
|
||||
Response::HTTP_SERVICE_UNAVAILABLE,
|
||||
$headers,
|
||||
);
|
||||
}
|
||||
|
||||
private function expectsJson(Request $request): bool
|
||||
{
|
||||
return str_starts_with($request->getPathInfo(), '/api/')
|
||||
|| str_contains((string) $request->headers->get('Accept'), 'application/json')
|
||||
|| $request->isXmlHttpRequest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{ title }}</title>
|
||||
{# استایل عمداً inline است: این صفحه ممکن است حین deploy و پیش از build اسِتها رندر شود. #}
|
||||
<style>
|
||||
:root {
|
||||
--primary: #5559CE;
|
||||
--bg: #f6f7fb;
|
||||
--surface: #ffffff;
|
||||
--text: #1f2233;
|
||||
--text-2: #5b6076;
|
||||
--border: #e4e6f0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Vazirmatn", "Vazir", Tahoma, sans-serif;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
padding: 40px 32px;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 32px rgba(31, 34, 51, .08);
|
||||
}
|
||||
.icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(85, 89, 206, .1);
|
||||
color: var(--primary);
|
||||
}
|
||||
h1 { margin: 0 0 12px; font-size: 22px; font-weight: 700; }
|
||||
p { margin: 0; font-size: 15px; line-height: 2; color: var(--text-2); }
|
||||
.retry {
|
||||
display: inline-block;
|
||||
margin-top: 28px;
|
||||
padding: 10px 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14161f;
|
||||
--surface: #1c1f2b;
|
||||
--text: #eef0f7;
|
||||
--text-2: #a2a7bd;
|
||||
--border: #2a2e3d;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="icon">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 7v5l3 2"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
<a class="retry" href="{{ app.request.requestUri }}">تلاش مجدد</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Config\Service\MaintenanceService;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Maintenance mode is enforced centrally, so these cases cover the three ways a
|
||||
* request can legitimately get through (whitelist, allowed IP, admin) plus the
|
||||
* blocking behaviour for everyone else.
|
||||
*/
|
||||
class MaintenanceModeTest extends ApiTestCase
|
||||
{
|
||||
private SiteConfigRepository $configRepo;
|
||||
private MaintenanceService $maintenance;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->configRepo = static::getContainer()->get(SiteConfigRepository::class);
|
||||
$this->maintenance = static::getContainer()->get(MaintenanceService::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->setMaintenance('0');
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function setMaintenance(string $enabled, string $allowedIps = ''): void
|
||||
{
|
||||
$this->configRepo->set('maintenance_enabled', $enabled);
|
||||
$this->configRepo->set('maintenance_allowed_ips', $allowedIps);
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
$this->maintenance->invalidate();
|
||||
}
|
||||
|
||||
public function testAnonymousApiRequestIsBlockedWithMaintenanceEnvelope(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctors');
|
||||
$response = $this->client->getResponse();
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
self::assertSame(503, $response->getStatusCode());
|
||||
self::assertSame('MAINTENANCE_MODE', $body['errors'][0]['code']);
|
||||
self::assertFalse($body['success']);
|
||||
self::assertNull($body['data']);
|
||||
self::assertNotEmpty($response->headers->get('Retry-After'));
|
||||
}
|
||||
|
||||
public function testRoutingErrorsAlsoReturnMaintenance(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
|
||||
// 404 is thrown by the router at a higher priority than the request
|
||||
// listener, so it is only covered by the kernel.exception path.
|
||||
$this->client->request('GET', '/api/v1/definitely-not-a-route');
|
||||
|
||||
self::assertSame(503, $this->responseCode());
|
||||
self::assertSame(
|
||||
'MAINTENANCE_MODE',
|
||||
json_decode($this->client->getResponse()->getContent(), true)['errors'][0]['code'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testAdminBypassesMaintenance(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/pre-registrations', $admin);
|
||||
|
||||
self::assertNotSame(503, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testNonAdminRoleIsBlocked(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
$doctor = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/admin/pre-registrations', $doctor);
|
||||
|
||||
self::assertSame(503, $this->responseCode());
|
||||
self::assertSame('MAINTENANCE_MODE', $body['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testSettingsEndpointStaysReachableSoMaintenanceCanBeTurnedOff(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/settings', $admin);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAllowedIpBypassesWithoutAuthentication(): void
|
||||
{
|
||||
$this->setMaintenance('1', '127.0.0.1, 10.0.0.1');
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctors', server: ['REMOTE_ADDR' => '10.0.0.1']);
|
||||
|
||||
self::assertNotSame(503, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDisabledMaintenanceLetsEveryoneThrough(): void
|
||||
{
|
||||
$this->setMaintenance('0');
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctors');
|
||||
|
||||
self::assertNotSame(503, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user