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
+104
View File
@@ -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);
}
}