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:
hamed
2026-07-19 22:01:34 +03:30
co-authored by Claude Fable 5
parent 6275b3da1e
commit 7ac8ddbd25
11 changed files with 1005 additions and 2 deletions
@@ -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();
}
}