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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user