AppSettingsController.php 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Admin;
  4. use App\Domain\Audit\AuditAction;
  5. use App\Domain\Audit\AuditEmitter;
  6. use App\Domain\Settings\AppSettings;
  7. use Doctrine\DBAL\Connection;
  8. use Psr\Http\Message\ResponseInterface;
  9. use Psr\Http\Message\ServerRequestInterface;
  10. /**
  11. * `GET/PATCH /api/v1/admin/app-settings` — runtime-mutable feature flags.
  12. *
  13. * Currently exposes the two audit-emission toggles (report.received and
  14. * blocklist.requested) which are surfaced as switches on the settings
  15. * page so an admin can silence the high-volume audit rows without
  16. * restarting the api container.
  17. *
  18. * RBAC: Admin only — same trust level as the rest of the settings page.
  19. */
  20. final class AppSettingsController
  21. {
  22. use AdminControllerSupport;
  23. private const KEYS = [
  24. AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED,
  25. AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED,
  26. ];
  27. public function __construct(
  28. private readonly AppSettings $settings,
  29. private readonly AuditEmitter $audit,
  30. private readonly Connection $connection,
  31. ) {
  32. }
  33. public function show(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  34. {
  35. return self::json($response, 200, $this->snapshot());
  36. }
  37. public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  38. {
  39. $body = self::jsonBody($request);
  40. $errors = [];
  41. $changes = [];
  42. foreach (self::KEYS as $key) {
  43. if (!array_key_exists($key, $body)) {
  44. continue;
  45. }
  46. $raw = $body[$key];
  47. if (!is_bool($raw) && !in_array($raw, [0, 1, '0', '1', 'true', 'false'], true)) {
  48. $errors[$key] = 'must be a boolean';
  49. continue;
  50. }
  51. $value = is_bool($raw) ? $raw : in_array($raw, [1, '1', 'true'], true);
  52. $current = $this->settings->getBool($key, true);
  53. if ($current !== $value) {
  54. $changes[$key] = ['from' => $current, 'to' => $value];
  55. }
  56. }
  57. if ($errors !== []) {
  58. return self::validationFailed($response, $errors);
  59. }
  60. if ($changes !== []) {
  61. $auditCtx = self::auditContext($request);
  62. $this->connection->transactional(function () use ($changes, $auditCtx): void {
  63. foreach ($changes as $key => $diff) {
  64. $this->settings->setBool($key, (bool) $diff['to']);
  65. }
  66. $this->audit->emitOrThrow(
  67. AuditAction::APP_SETTINGS_UPDATED,
  68. 'app_settings',
  69. null,
  70. ['changes' => $changes],
  71. $auditCtx,
  72. 'audit-toggles',
  73. );
  74. });
  75. }
  76. return self::json($response, 200, $this->snapshot());
  77. }
  78. /**
  79. * @return array<string, bool>
  80. */
  81. private function snapshot(): array
  82. {
  83. return [
  84. AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED => $this->settings->getBool(
  85. AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED,
  86. true,
  87. ),
  88. AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED => $this->settings->getBool(
  89. AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED,
  90. true,
  91. ),
  92. ];
  93. }
  94. }