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